CeruleanCutlass-ForgeKit icon

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 plus Mark / IsScripted / Describe. Purely additive, so COMPAT_SINCE stays 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/Merchant machinery is stripped by design), no lock-on name (a puppet has no Character component at all), invisible to every roadsmerchant / roadsstatus / NpcRegistry census — 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, and unstick fix now 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 polls Time.unscaledTime by design). It is intermittent and self-clearing, not a permanent latch — a later same-moment reading showed paused=False pausedBy=[] with the pet sim's ticks= climbing and pausedTicks= frozen — but a bad window ran past a minute (six consecutive polls reading paused=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 — so unstick fix had to walk down to rung 4 (forceunpause), which clears m_gameplayPausedBy wholesale (the Prologue key with it) and so releases the WORLD SIM even though the load coroutine stays parked. At roughly one invocation per poll that is the observed t+0 … t+61s staircase, and it is why the latency varied with how many rungs a given attempt got through. With rung 0 in front, the first unstick fix should be the one that works.

    The mechanism, from the decompile:

    • ProloguePanel.Show pushes PauseGameplay("Prologue") (ProloguePanel.cs:27). It is released ONLY by ProloguePanel.OnHideUnPauseGameplay("Prologue") (:63-70), reached only when GoToNextPage walks past the last screen (:34-60). The sole vanilla caller of MenuManager.GoToNextProloguePage is LocalCharacterControl.cs:185-189, behind ControlsInput.QuickDialogueUpa real keypress on an OS-focused window. Unfocused or headless, that press never happens.
    • NetworkLevelLoader.FinishLoadLevel parks on while (MenuManager.Instance.IsProloguePanelDisplayed) (:1523-1526), which sits BEFORE the while (!m_continueAfterLoading) gate (:1538) and before the SendReadyToContinue RPC (:1541). So readyIds stays empty, AllPlayerReadyToContinue stays false, and UnPauseGameplay("Loading") (:1581) is never reached — which is why the pause stack read [Loading,Prologue] with allDone=True allReady=False.
    • By that point m_gameplayLoading (:1461), m_loadingLevel (:1215) and m_waitingForOtherPlayers (:1490) have all been cleared, so IsOverallLoadingDone reads 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 answered done about it.
    • The gate rung was therefore a no-op on this wedge: it set a latch the coroutine had not reached. It was not being "overwritten" — m_continueAfterLoading has exactly two writers, SetContinueAfterLoading (:970) and BaseLoadLevel (:870), and every observed revert to False followed a fresh load.

    What changed:

    • New rung 0, prologue, tried FIRST by unstick fix: call MenuManager.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.
    • LoadSnapshot gained ProloguePanelUp; LoadPhase gained the prologue phase, classified ABOVE done (an IsOverallLoadingDone snapshot is not proof the sim is running); and GateGuardHolds gained !ProloguePanelUp, so neither the watchdog nor the gate rung 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 reach forceready.
    • A rung no longer grades itself. The same-frame re-dump can be stale — the loader settles its pause state in NetworkLevelLoader.UpdateUpdateGameplayPaused (:352, :382-406), which is exactly why applied forceunpause was followed by paused=True otherPlayerPaused=True and then by False on the next poll. Every applied rung now schedules a deferred [UNSTICK] verify step=… pausedBefore=… pausedNow=… → HELD | REVERTED | NO-CHANGE line 0.75 s (realtime) later, and re-dumps under it. NO-CHANGE exists 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 gained prologuePanel=… — the field whose absence made the wedge unreadable.
  • On the otherPlayerPaused=True with peers=0 lead. Real, and explained: when the pause stack goes 0→1, OnReceivePauseGameplay (:1976-1999) broadcasts SendPauseStatus(true), which in an offline room lands locally and puts the LOCAL player's own id into m_playerCurrentlyInPause; UpdateGameplayPaused (:401) then reports it as m_otherPlayerPaused. It clears only when the stack drains to 0 (:2009-2017) — impossible while Prologue is 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 by OnPhotonPlayerDisconnected (:1797-1802), and in a normal session the stack always drains. The forceunpause rung's raw Clear() does bypass the SendPauseStatus(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

  • swing now 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/100 followed by AttackInput(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) is m_inLocomotion && m_nextIsLocomotion && !Blocking && !LocomotionAction && !Sheathing && !InChargeCancelCooldown && !m_cancelChargingSent — so inLocomotion=False ALONE 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 castspell itemiser convention (SkillVerbs.WhyNotReady):

    • the state line carries the whole gate now, plus a gate=OPEN|CLOSED verdict 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's else 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;
    • swing WAITS 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 names unstick.

    Additive: DevSwing is new public surface, nothing existing is re-signed, so COMPAT_SINCE stays 0.4.4. Pinned by tests/ForgeKit.Tests/DevSwingTests.cs. Live-verify owed.

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 exactly y = -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 downward Physics.Raycast returns 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 said no navmesh here and it placed anyway. That fallback is gone: only a NavMesh.SamplePosition hit (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 than standoff's 12 m RoofSanityMeters because walkto has 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 ColliderColumnReport method, so the placement path provably contains no raycast; the [WALKTO] REFUSED line names the probe heights and reports what collider is in the column, flagged NOT used.
  • Why it mattered beyond the verb: the off-mesh character made the pet report CantReach, put the anchor off-mesh, and made standoff — 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. off extinguishes it in place (recovery can then be watched), remove destroys it, status prints the campfire's live TemperatureSource bands and the step predicted at the player's distance. Host-only.
  • Why the verb had to exist: useitem Campfire Kit answers TryUse -> True and deploys nothing. A deployable's Use does not deploy — it opens an interactive placement mode (BasicDeployable.OnItemUse -> DeployablePlacer.StartPlacement -> Character.StartDeploy) that idles until Character.DeployInput is driven by a real keypress, then round-trips an RPC and a SetupGround cast animation before Deployable.DeployableCast finally instantiates anything. A command channel has no input frames, so the chain can never complete from a verb; firecamp does DeployableCast's own work directly. Lighting is mandatory, not cosmetic: FueledContainer.StartInit DISABLES the campfire's TemperatureSource and only Kindle() re-enables it, and EnvironmentConditions skips any source that is not isActiveAndEnabled — an unlit campfire radiates nothing.
  • Additive only: new public ForgeKit.DevFire (grammar + the distance→step lookup), new FireAction/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>] and walkto <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. standoff reads its pet through the existing CommonVerbsOptions.PetTarget seam, so a pet-owning consumer gets target=pet with no registration change.
  • teleport unchanged 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 to BepInEx/screenshots/ and prints [SHOT] saved <path> <bytes> bytes once the file is size-stable, so a driving agent can retrieve and Read the PNG for visual analysis (scripts/game-shot.sh is the dispatch+retrieval wrapper). Pure grammar in DevShot (unit-tested).

  • New (built, NOT live-verified): removeitem [qty 1-999|all] <name-or-ItemID> CommonVerbs verb (Items domain, [REMOVE] tag) — the reverse of give, built on Inventories.ConsumeOne so guest-side destruction rides vanilla's compensation. Refuses ambiguous name matches, reports observed TOTAL QTY before -> after, never touches equipped gear. Pure grammar + stack allocation in DevRemove (unit-tested).

  • New (built, NOT live-verified): Inventories.ConsumeOne — authoritative item consumption on any Photon role. A bare Item.RemoveQuantity is 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/AllByContainer now skip destroy-wanted items (the vanilla ItemContainer.ItemStackCount precedent), so a guest's just-spent item is no longer enumerable — or counted by invdump — 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: unstick verb, [LOADGATE] watchdog, hardened goto
  • Lane 3: container + caravan queue-unblocker verbs
  • Add script runner: one command line, several verbs, real time between steps
  • Hyena/Pearlbird tuning wave: HAO taunt, gifts, bone relic, feed rule