ForgeKit
Dependency-free dev-tooling for Outward BepInEx mods: file-driven dev command loop, self-test harness, on-screen toasts, player-ready lifecycle wait, embedded/override table loaders, and a shared dev-verb pack (movement/combat/skill/status probes).
CHANGELOG
ForgeKit changelog
0.4.10 — 2026-08-28
-
ScriptedBody/ScriptedBodies: "this Character belongs to a script — do not adopt it". A one-field marker plusMark/IsScripted/Describe. Purely additive, soCOMPAT_SINCEstays at 0.4.4.It lives in ForgeKit for one reason: ForgeKit is the assembly every mod already depends on, so the stamper and the reader cost no new dependency edge and never have to know about each other. StoryKit stamps every NPC body it rigs; Hireling's recruit/re-form scan honours it.
The bug it closes (live, legion, single-player): Cobalt's saved Hireling follower was named
Travelling Merchant, and Hireling's re-form rung matches a live scene Character by DISPLAY NAME. The moment DangerousRoads' road-merchant event spawned a StoryKit NPC of exactly that name, the re-form cloned it into the follower puppet. What Cobalt then had glued to him was a merchant- dressed body with no dialogue prompt (a puppet's dialogue/Merchantmachinery is stripped by design), no lock-on name (a puppet has noCharactercomponent at all), invisible to everyroadsmerchant/roadsstatus/NpcRegistrycensus — and it outlived the real merchant's despawn by ten minutes, because it was cloned before it. Every symptom read as "the road merchant is broken"; none of them was.Wider than the merchant, on purpose. StoryKit stamps EVERY NPC it spawns, so this makes every StoryKit NPC — Maren, the trainers, both DangerousRoads road events — permanently un-recruitable as a Hireling follower, not just the one that caused the bug. A scripted quest/vendor NPC should never have been adoptable; the refusal is now explicit and logged rather than accidental. A follower recruited from a StoryKit NPC on an older build will no longer re-form.
0.4.9 — 2026-08-25
-
The world-pause wedge, named: it is the
ProloguePanel, andunstick fixnow presses the key it is waiting for. A long live session lost at least three results to a session whose world sim was pinned while every dev verb answered normally (the command channel pollsTime.unscaledTimeby design). It is intermittent and self-clearing, not a permanent latch — a later same-moment reading showedpaused=False pausedBy=[]with the pet sim'sticks=climbing andpausedTicks=frozen — but a bad window ran past a minute (six consecutive polls readingpaused=True), which is long enough to invalidate every timed observation taken inside it.Why recovery took so long, which is the practically important half. The ladder applies ONE rung per invocation, deliberately. On THIS wedge rung 1 (
gate) is a no-op — it sets a latch the coroutine has not reached — sounstick fixhad to walk down to rung 4 (forceunpause), which clearsm_gameplayPausedBywholesale (theProloguekey with it) and so releases the WORLD SIM even though the load coroutine stays parked. At roughly one invocation per poll that is the observedt+0 … t+61sstaircase, and it is why the latency varied with how many rungs a given attempt got through. With rung 0 in front, the firstunstick fixshould be the one that works.The mechanism, from the decompile:
ProloguePanel.ShowpushesPauseGameplay("Prologue")(ProloguePanel.cs:27). It is released ONLY byProloguePanel.OnHide→UnPauseGameplay("Prologue")(:63-70), reached only whenGoToNextPagewalks past the last screen (:34-60). The sole vanilla caller ofMenuManager.GoToNextProloguePageisLocalCharacterControl.cs:185-189, behindControlsInput.QuickDialogueUp— a real keypress on an OS-focused window. Unfocused or headless, that press never happens.NetworkLevelLoader.FinishLoadLevelparks onwhile (MenuManager.Instance.IsProloguePanelDisplayed)(:1523-1526), which sits BEFORE thewhile (!m_continueAfterLoading)gate (:1538) and before theSendReadyToContinueRPC (:1541). SoreadyIdsstays empty,AllPlayerReadyToContinuestays false, andUnPauseGameplay("Loading")(:1581) is never reached — which is why the pause stack read[Loading,Prologue]withallDone=True allReady=False.- By that point
m_gameplayLoading(:1461),m_loadingLevel(:1215) andm_waitingForOtherPlayers(:1490) have all been cleared, soIsOverallLoadingDonereads TRUE while the sim is pinned. That is the whole "the load is entirely finished yet the world is paused" shape, and it is why the[LOADGATE]classifier answereddoneabout it. - The
gaterung was therefore a no-op on this wedge: it set a latch the coroutine had not reached. It was not being "overwritten" —m_continueAfterLoadinghas exactly two writers,SetContinueAfterLoading(:970) andBaseLoadLevel(:870), and every observed revert toFalsefollowed a fresh load.
What changed:
- New rung 0,
prologue, tried FIRST byunstick fix: callMenuManager.GoToNextProloguePage()until the panel hides (bounded at 64 pages), then re-assert up to twice more in case a further context screen was queued behind the first. This is the cure, not a workaround — it is the same call vanilla's keypress makes. LoadSnapshotgainedProloguePanelUp;LoadPhasegained theprologuephase, classified ABOVEdone(anIsOverallLoadingDonesnapshot is not proof the sim is running); andGateGuardHoldsgained!ProloguePanelUp, so neither the watchdog nor thegaterung can claim "passed the continue gate — no keypress needed" about a coroutine parked earlier.- The auto ladder's ORDER is now pure and unit-tested (
DevUnstick.ChooseAuto), including the property that Auto can never reachforceready. - A rung no longer grades itself. The same-frame re-dump can be stale — the loader settles
its pause state in
NetworkLevelLoader.Update→UpdateGameplayPaused(:352,:382-406), which is exactly whyapplied forceunpausewas followed bypaused=True otherPlayerPaused=Trueand then byFalseon the next poll. Every applied rung now schedules a deferred[UNSTICK] verify step=… pausedBefore=… pausedNow=… → HELD | REVERTED | NO-CHANGEline 0.75 s (realtime) later, and re-dumps under it.NO-CHANGEexists so a rung applied to a session that was never paused cannot read as a repair. - The dump gained
[UNSTICK] prologue: panelUp=…and[LOADGATE]'s gate line gainedprologuePanel=…— the field whose absence made the wedge unreadable.
-
On the
otherPlayerPaused=Truewithpeers=0lead. Real, and explained: when the pause stack goes 0→1,OnReceivePauseGameplay(:1976-1999) broadcastsSendPauseStatus(true), which in an offline room lands locally and puts the LOCAL player's own id intom_playerCurrentlyInPause;UpdateGameplayPaused(:401) then reports it asm_otherPlayerPaused. It clears only when the stack drains to 0 (:2009-2017) — impossible whilePrologueis pinned. It is a SYMPTOM of the stuck stack, not a second cause, and it is not player-facing: a real peer's id is removed byOnPhotonPlayerDisconnected(:1797-1802), and in a normal session the stack always drains. Theforceunpauserung's rawClear()does bypass theSendPauseStatus(false)broadcast, which is why the phantom reappears on the next pause push — documented, unchanged, and now visible in the deferred verify line.
0.4.8 — 2026-08-25
-
swingnow NAMES its refusal, and waits for the gate instead of refusing instantly. A live headless session lost five attacks in a row to a state line that read as if nothing were wrong:weapon='Brand' type=Sword_1H attackOnRelease=False sheathed=False inLocomotion=False blocking=False stamina=100/100followed byAttackInput(0,0) -> False. (preconditions failed — see state line…). The line printed the deciding fact and gave no polarity, so the deciding fact read as an idle detail. Vanilla's gate (Character.AttackInput, Character.cs:5749) ism_inLocomotion && m_nextIsLocomotion && !Blocking && !LocomotionAction && !Sheathing && !InChargeCancelCooldown && !m_cancelChargingSent— soinLocomotion=FalseALONE refuses every attack. And "locomotion" is not movement: it is the animator's Locomotion TAG (the neutral stand/walk/run state), so a character mid-animation — an item use, a stagger, a knockback, the tail of a dodge, sitting — is out of it while standing perfectly still. That makes the refusal both ORDINARY and TRANSIENT.Three changes, mirroring the
castspellitemiser convention (SkillVerbs.WhyNotReady):- the state line carries the whole gate now, plus a
gate=OPEN|CLOSEDverdict and the five flags it never printed (nextIsLocomotion,locomotionAction,sheathing,chargeCancelCd,cancelChargingSent,nextAtkAllowed); - a refusal is itemised by the new pure
ForgeKit.DevSwing(FailingConditions/Explain), which names every failing conjunct AND the remedy, and is logged as a Warning — except a QUEUED press (vanilla'selse if (m_nextAttackAllowed > 0)branch banks a combo/charge step and still returns false), which is reported as Info because it is not a failure at all; swingWAITS up to 2 s for the gate to open before striking, exactly as the sheathed path has always waited for the draw animation. Refusing instantly there while waiting here was the inconsistency that made the verb read as broken. If the wait does not pay off it says so and does not attack.
Cross-finding, same session: those five refusals almost certainly sat inside a WEDGED PAUSE (
[UNSTICK] paused=True pausedBy=[Loading,Prologue] pauseScreenOpen=True), which independently stopped every AI detecting — and the command channel keeps answering throughout, because it polls unscaled time. A gate that stays closed for a full 2 s is that shape, so the timeout line now says so and namesunstick.Additive:
DevSwingis new public surface, nothing existing is re-signed, soCOMPAT_SINCEstays 0.4.4. Pinned bytests/ForgeKit.Tests/DevSwingTests.cs. Live-verify owed. - the state line carries the whole gate now, plus a
0.4.7 — 2026-08-25
walkto <x> <z>rebuilt: navmesh or refusal. As shipped in 0.4.5/0.4.6 the verb placed the character at a fixed altitude instead of on the ground — five live calls across ~250 m of map all landed at exactlyy = -1465.0, and a control call on the character's OWN x/z lifted them 23.9 m into the air. Root cause was the collider fallback, which was a design error rather than a tuning bug: a downwardPhysics.Raycastreturns the FIRST collider it meets — as likely a roof or a bounding volume as the floor — and it was reached exactly when the navmesh check had already failed, so the verb surrendered its one safety property precisely where it was needed. Its own log line saidno navmesh hereand it placed anyway. That fallback is gone: only aNavMesh.SamplePositionhit (settled onto the collider surface beneath it) may set the height, and a column with no navmesh is a flat, loud refusal —standoff's rule, which has never dropped anyone.- Two supporting fixes for the same root cause. The sample centre now sweeps a downward-first
ladder (
DevStandoff.PlanColumn: 0, −8, −25, −60, −150, −400, +8, +25 m) instead of sitting at the caller's own y, so a call made from an already-wrong elevation recovers rather than compounds — 0.4.5 re-cast from the new elevated position and could never come back down. And a rise gate (DevStandoff.RiseOk/WalkToMaxRiseMeters= 3 m, tighter thanstandoff's 12 mRoofSanityMetersbecausewalktohas no reference body) means a landing above the caller is taken only when no lower rung had navmesh at all, and is announced before the write. - The collider probe survives as diagnosis only, in its own
ColliderColumnReportmethod, so the placement path provably contains no raycast; the[WALKTO] REFUSEDline names the probe heights and reports what collider is in the column, flaggedNOT used. - Why it mattered beyond the verb: the off-mesh character made the pet report
CantReach, put the anchor off-mesh, and madestandoff— correctly — refuse every subsequent placement. One bad verb disabled the good one and parked the world on a navmesh-free plane. - Additive only: new public
DevStandoff.PlanColumn/MaxColumnProbes/WalkToColumnOffsets/RiseOk/WalkToMaxRiseMeters.walkto's help text now states the navmesh contract it actually keeps. COMPAT_SINCE stays 0.4.4 — nothing was removed or re-signed.
0.4.6 — 2026-08-25
- New World-domain verb
firecamp [on|off|remove|status] [distance 0.5-20]: places a REAL vanilla campfire on the ground ahead of the player and LIGHTS it, so a session can sample genuine ambient temperature instead of narrowing a band to fake the arithmetic.offextinguishes it in place (recovery can then be watched),removedestroys it,statusprints the campfire's liveTemperatureSourcebands and the step predicted at the player's distance. Host-only. - Why the verb had to exist:
useitem Campfire KitanswersTryUse -> Trueand deploys nothing. A deployable'sUsedoes not deploy — it opens an interactive placement mode (BasicDeployable.OnItemUse->DeployablePlacer.StartPlacement->Character.StartDeploy) that idles untilCharacter.DeployInputis driven by a real keypress, then round-trips an RPC and aSetupGroundcast animation beforeDeployable.DeployableCastfinally instantiates anything. A command channel has no input frames, so the chain can never complete from a verb;firecampdoesDeployableCast's own work directly. Lighting is mandatory, not cosmetic:FueledContainer.StartInitDISABLES the campfire'sTemperatureSourceand onlyKindle()re-enables it, andEnvironmentConditionsskips any source that is notisActiveAndEnabled— an unlit campfire radiates nothing. - Additive only: new public
ForgeKit.DevFire(grammar + the distance→step lookup), newFireAction/FireArgs, one new verb in the World domain. COMPAT_SINCE stays 0.4.4 — nothing was removed or re-signed.
0.4.5 — 2026-08-25
- Ground-safe placement pair:
standoff <metres> [bearing=<deg>] [target=pet|nearest|<species>]andwalkto <x> <z>. Both probe real ground (navmesh sample, collider settle, path-connectivity check from the reference body's own polygon) and REFUSE rather than place the character in the air.standoffreads its pet through the existingCommonVerbsOptions.PetTargetseam, so a pet-owning consumer getstarget=petwith no registration change. teleportunchanged in behaviour (it still writes the RAW height it is given) but now warns BEFORE the write when the destination is more than 3 m above the ground, naming walkto/standoff.- Additive only: new public
ForgeKit.DevStandoff(grammar + landing-search plan), two new verbs in the World domain. COMPAT_SINCE stays 0.4.4 — nothing was removed or re-signed.
0.4.4 — 2026-08-19
- ForgeKit: restore the 6-arg CommandChannel ctor as a binary-compat overload
0.4.4 — 2026-08-19
- ForgeKit: restore the 6-arg CommandChannel ctor as a binary-compat overload
0.4.3 — 2026-08-19
- Cheat verbs: ForgeKit Cheats domain drives the game's own Debug Mode
- Merge V6b fix (log-level gate: 14 unwrapped sinks retyped + requested-output exemption), peer-reviewed
- SF11 review fixes: vanilla over-capacity gate, sibling cache reads, durable host
- Fix V6b: the Quiet-tier exemption was inverted (ambient ungated, verbs gated)
- SF11 fix A3b: petinvgive reports a SETTLED weight, inside the forge capture
- Merge A1+A2+A5 fix (removeitem/unequip deferred settled reports via DeferredReport + script-control verbs unblocked), peer-reviewed
- Merge B1+B2 fix (forge shell stale-cache self-heal + script response window), peer-reviewed
- forge B1+B2 review follow-ups: keep-in-sync back-ref, skew-proof cache stamps, lease-timeout note
- Review fixes: deferred verb reports stay inside the forge response capture
- ForgeKit fix batch A (A1/A2/A5): settled readbacks + exact-verb script guard
- Merge A6+B3 fix (SaveVerbs refusal order + consumed-savelist message), peer-reviewed
- SaveVerbs A6+B3: honest post-select savelist message + in-world refusal reordered
- Merge branch 'fix/sa-0815-leafkits' into feature/pet-self-feed
- Review follow-ups on the F3c leaf-kit wave (M1, M2, L3, L5, L6)
- Static-analysis wave F3c: leaf-kit fixes (A7-1/2/4/8/9/13/14/15/16/20/23/24/25/27)
- A10 verb re-homing: photondump body -> NetKit.ViewRegistryDump; waiver docs; V28-V31
- Coercion wave: every ForgeKit integration completes cleanly through the TUI seam
- cfgdump filter accepts a full Section.Key (interface-sweep note) + sweep results recorded
- Catalog-audit fixes: comment/noise rows out of completion, real skill completion, placeholder-aware usage grammar
- UsageSpec: derive arg specs from help-string usage clauses — completion for the whole verb corpus with zero mod edits
- … and 2 more (see git history)
0.4.2 — 2026-08-11
- ForgeKit agent test-automation verbs: screenshot pipeline + removeitem
- Merge branch 'main' into fix/sa-0808-kits
- Static-analysis 2026-08-08 wave D-kits: P2-13/P2-15 + the kit P3 group
- Static-analysis 2026-08-08 wave E: close the test-debt list (§4)
- Static-analysis 2026-08-08 wave 0: nine P1/P2 fixes
- Fix BUG-RAGDOLLJOINTLOSS: the rig re-init pass ate healthy ragdoll joints
- Review fixes for lane B: drop the dead destroy companions, close the destroy-wanted ghost window, honest wording
- Fix BUG-CHOWNOTCONSUMED family: authoritative guest consumption via ForgeKit.Inventories.ConsumeOne
- invdump: read the player's inventory, and answer "how many X" with a number
- loadsave: a session can now start its own world (live-verified)
- W4: fixes for everything D1 found, plus the join-race P1
- Log levels: a per-mod [Diag] LogLevel, gating at the source
- Stuck-in-combat hardening: wedge-proof the vanilla hostility checker
- ForgeKit: fake-null sweep in GiveVerbs equip/unequip (UNT0008)
Unreleased
-
New (built, NOT live-verified):
screenshot [supersize 1-4] [label]CommonVerbs verb (EngineDiag domain,[SHOT]tag, works at the main menu) — captures the frame toBepInEx/screenshots/and prints[SHOT] saved <path> <bytes> bytesonce the file is size-stable, so a driving agent can retrieve and Read the PNG for visual analysis (scripts/game-shot.shis the dispatch+retrieval wrapper). Pure grammar inDevShot(unit-tested). -
New (built, NOT live-verified):
removeitem [qty 1-999|all] <name-or-ItemID>CommonVerbs verb (Items domain,[REMOVE]tag) — the reverse ofgive, built onInventories.ConsumeOneso guest-side destruction rides vanilla's compensation. Refuses ambiguous name matches, reports observedTOTAL QTY before -> after, never touches equipped gear. Pure grammar + stack allocation inDevRemove(unit-tested). -
New (built, NOT live-verified):
Inventories.ConsumeOne— authoritative item consumption on any Photon role. A bareItem.RemoveQuantityis a no-op for the last unit on a co-op guest (item destruction is master-gated in vanilla), which let guests feed/tame/bandage for free in consumer mods. The helper mirrors vanilla's own compensation (CharacterInventory.RemoveItem: destroy RPC to the master + local hide) and returns read-back facts (before/after/shortfall/ destroy-requested) so callers log from observation, not intent. With it,Inventories.All/AllByContainernow skip destroy-wanted items (the vanillaItemContainer.ItemStackCountprecedent), so a guest's just-spent item is no longer enumerable — or counted byinvdump— during the destroy round-trip window.
0.4.1 — 2026-08-02
- Cleanup: collapse three repeated verb preambles into helpers that already had a home
- ForgeKit/SkillKit/StoryKit: fix five guards that could never fire, and stop the recovery verb lying
- ForgeKit: one numeric-token parser for every dev verb, and it refuses NaN
- Docs sweep: archive, condense, and validate the whole documentation tree
0.4.0 — 2026-07-30
- Cap all-status build-up resistance below the engine's refusal sentinel
- Review fixes: the shared kits must not enforce OUR id allocation
- Draw every custom id from the community-allocated pool (87000-87999)
- Wire the test-automation tooling wave: Containers+Resilience CommonVerbs domains, caravanreroll rows, ForgeKit 0.4.0, ledger/STATUS, limits survey
- Merge lane 3: container/caravan queue-unblocker verbs + review fixes (scavengesim reachability honesty, quest-event disclosure, reopen gate typing, pristine-state skip)
- Lane 3 review fixes: honesty gates on the container + scavenge-sim verbs
- Merge lane 2: session resilience (unstick/LoadGate/goto hardening) + review fixes (Done precedence, sawLoading acceptance, latch decoupling)
- Merge lane 1: ForgeKit command-script runner (script/scriptcancel/scriptstatus) + review fixes (waitloaded stale-flag acceptance rule, NaN rejection, pump-gap wording)
- Review fixes F2/F3/F4-F5/F6: watchdog pre-load window, granular refusal, comment truth, latch ownership
- Review fixes: waitloaded stale-TRUE trap, NaN waits, pump-gap misblame
- Concurrent-session work committed as-is: grantstatus [force] + StatusApplyGate (DoT thread) + 2026-07-29/30 live-session testplan results
- Session resilience:
unstickverb,[LOADGATE]watchdog, hardenedgoto - Lane 3: container + caravan queue-unblocker verbs
- Add
scriptrunner: one command line, several verbs, real time between steps - Hyena/Pearlbird tuning wave: HAO taunt, gifts, bone relic, feed rule