CeruleanCutlass-CompanionKit icon

CompanionKit

Persistent-creature companion mechanics for Outward mods: clone/brain-strip body factory, navmesh follower, invisible combat anchor, manual combat. Donor-scene harvesting and template caching moved to DonorKit; aggro control to AggroKit.

CHANGELOG

CompanionKit changelog

0.4.20 — 2026-08-28

  • Fix stale dependency pins across the fleet; DonorKit 0.1.7
  • release prep: sync version numbers across all four files
  • CompanionKit/NetKit: park+release the anchor's Photon view-ID
  • LT22: templateclear spares prebuilt bundle bodies; bundlebody reload actually reloads; [STATS] from-save breadcrumb
  • LT17/LT18/LT19 root causes: cfg layer resolves first (ForLayered), ladder label from installed table, buffdump names the Communion gate
  • Loyalty 14-tier ladder WIP: LoyaltyLadder/LoyaltyCurve, locks (save v18), 0-8 payout stretch, [Loyalty] cfg, TierMax 8 (CK 0.4.20)
  • CompanionKit: settled pets face the owner's heading (LookTarget.OwnerHeading); PL1/PL7 PASS
  • BW: [Pet] LeashDistance 42 -> 84 (one leash number); decouple the coverage-gap backstop
  • CK 0.4.18: the anchor's own leash is unreachable by design under GlueMode=Always
  • Cross-area pet orphan fix + three combat-staging diagnostics
  • Fix two live-found P1 regressions: the first-crossing +5 and the pouch leak
  • CK 0.4.15: correct the zone-fix claims, cover the latched path, file the live debt
  • BW/CK: review fixes — combat-aware zone re-place, public wiki defaults, stale comments
  • BW: widen pet leash distances by 40%
  • Merge feat/fletch-arrow-budget: fletched-arrow budget, owner-focus assist, Merchants-faction synergy fix
  • Pearlbird locomotion paradigm — Build A: wire fast hold, testplan PL1–PL10, ledger + handoff status
  • Merge branch 'wt/pearlbird-loco-p12' into feat/pearlbird-loco
  • Review fixes (M1/L1/L3/L5): strafe force exempt for FacePoint + planted Enemy looks, un-prime the anim displacement at every reposition site, drop dead effigy blend writes, doc literals
  • CompanionKit 0.4.14: COMPAT_SINCE -> 0.4.14 for the ICompanionSettings growth (review P2)
  • Docs: CompanionKit CHANGELOG/README + BW README for the movement rework and the allowsBackpedal axis
  • … and 43 more (see git history)

Unreleased

  • CK 0.4.20 — NetProtocol.TierMax 4 → 8 (built, NOT live-verified). The BW loyalty ladder grew to 14 tiers with a 0–8 payout column (2026-08-25); the quantized loyalty tier on the ck announce wire follows it. Const — inlined at each consumer's build; a pre-0.4.20 peer clamps a 5–8 badge at 4, which now reads as half the ladder. No public surface change; COMPAT_SINCE stays 0.4.14.

  • LookTarget.OwnerHeading — settled out of combat (no FacePoint hold) the body now faces the way the OWNER faces instead of turning to stare at them (Cobalt's live ruling 2026-08-25: "it would be nice to have it facing forward when loafing too"; the vanilla familiar faces its follow slot the same way). LookTargetPolicy.Choose's settled rung returns it; both drive paths feed the owner's flat forward. Feed/bandage still turn to the owner via FacePoint.

  • CK 0.4.19 — the coverage-gap backstop is DECOUPLED from the leash (behaviour change, built, NOT live-verified). FollowPolicy.Decide computes the owner-keyed zone re-place trigger as zone = max(ZoneReplaceDistance (50), ownerLeash). Every shipped LeashDistance had always sat BELOW 50, so out of combat zone was always exactly 50 and the coupling was invisible. BW's [Pet] LeashDistance 42 → 84 on 2026-08-25 ends that: for BW the out-of-combat zone trigger is now 84 as well. That is CORRECT for the rungs that still have a working follow — a body merely BEHIND its owner, anywhere inside the rope the owner's own leash grants it, must not be hard-teleported by the zone leg while the graceful off-screen leash warp is the intended recovery; re-placing it at 50 would reintroduce exactly the snap the ruling removed. It is NOT correct for one rung: the coverage-gap (!GoalOnMesh) backstop added in 0.4.17 for the cross-area orphan. A body there with a goal PINNED away from the owner (Stay spot, SceneSpot, a combat target still carrying the previous area's coordinates) direct-drives at goalDist≈0 and has no other way home at all — its rescue must not slide out with a comfort knob, or a stale pinned goal landing 50–84 m away strands the body permanently. So that rung now uses f.GoalIsOwner ? zone : t.ZoneReplaceDistance: goal is the owner → full rope (it is genuinely just catching up); goal pinned elsewhere → the flat 50 m floor, unchanged from before the ruling. The 0.4.17 F20 case (dist=1829) is far beyond either number and is unaffected. Hireling is unaffected end to end — it still ships LeashDistance 30, so its trigger resolves to exactly the old 50. New pin: FollowPolicyTests.CoverageGapBackstop_PinnedGoal_KeepsTheFlatFloor. Owed live: docs/follow-polish-testplan.md F22.

  • CK 0.4.18 — the anchor's own leash is UNREACHABLE BY DESIGN under GlueMode=Always, and the build now says so instead of going silent. F18/F19 (docs/follow-polish-testplan.md) were stuck on an unfalsifiable read: the [ANCHOR] leash warp line added in 0.4.17 never fired in four live stagings (27.1 m, 29.0 m, 39.9 m, and a STAY pet parked at 34.7 m for 15 s with [Anchor] AnchorLeashDistance = 28 live), and from outside the code a logging bug and a leash that correctly does not apply look identical. The code answer: in CompanionAnchor.Upkeep the whole leash block sits behind if (!recalled && !glued && dist > leash), and glued comes from AnchorGlue.Engaged(GlueMode, hasBody: _body != null, …), which returns true unconditionally for AnchorGlueMode.Always + a body. Every local companion has a body whenever Upkeep runs, so !glued is never true and the warp — and its brand-new log line — cannot execute. Not a logging bug; not dead code either (it is the live path for a bodiless, undriven ProxyPets anchor and for the legacy GlueMode=Off/Combat-out-of-combat topologies). It is also the CORRECT suppression: AnchorLeashDistance (28) sits below the body's LeashDistance (42), so a literal anchor-vs-owner leash under the weld would yank the ghost home while the body it is welded to is still legitimately out — visibly splitting the pet in two. Changes: the rule moves into the pure layer as the new additive Core.AnchorGlue.LeashApplies(mode, hasBody, inCombat, externallyDriven) (unit-tested in tests/CompanionKit.Tests/AnchorGlueTests.cs), glued is now its complement, and a throttled [ANCHOR] leash suppressed: dist=… > leash=… (follow|combat leash) but the anchor's position is not its own (GlueMode=… body=… driven=…) line fires in place of the silence. F18/F19 are RE-SPECIFIED against that line and stay OWED. No shipped default was changed — the 28/42 ordering is Cobalt's call and is inert for local pets either way.

  • CK 0.4.17 — the CROSS-AREA ORPHAN: a body whose goal is PINNED can no longer be stranded in the previous area's coordinates for the rest of its life. Live evidence (2026-08-25): after an area change out of Cierzo the pet sat 1829 m behind at the old scene's coordinates, logging [PUPPET]#4 direct-drive: goalDist=0.0 speed=0.0 every two seconds — no follow, no leash, no way home short of a forced resummon. Root cause is rung ORDER in FollowPolicy.Decide: the navmesh-coverage-gap rung (if (!f.GoalOnMesh) -> Direct) returns BEFORE the owner-distance zone re-place. That was correct while "the goal" always MEANT the owner — a coverage gap under the owner's own feet is a metre-scale problem the straight-line drive walks out of. It stopped being correct once the goal could be pinned somewhere the owner is not (a Stay spot, a SceneSpot, a combat target): a pinned goal keeps its OLD AREA's coordinates across the load, the new scene has no navmesh there, GoalOnMesh reads false forever, and the zone re-place two rungs down was unreachable — so OnWarpedToOwner never fired either, and a Stay order was never ended. The coverage-gap rung now sets the same PreReplaceOwner the abandoned-agent latch has carried since M-f: the zone trigger only (no sprint leg — a genuine metre-scale gap near the owner must still be walked out of, never teleported through), same 0.4 s throttle, same SuppressLeashWarp opt-out for a mount rider. CompanionBody applies it through the new shared AgentFreeReplaceOntoOwner helper (the latched path's text and behaviour are byte-identical); the coverage-gap path raises OnWarpedToOwner("CoverageGap"), which ends a Stay like every other owner-keyed warp. Out of the pinned-goal case nothing changes: with the goal at the owner, DistToOwner > zone and GoalOnMesh == false cannot both hold in any healthy frame. This is player-facing, not a dev-verb artefact — the goto verb reaches NetworkLevelLoader.LoadLevel(scene, spawn), and a door / region fast-travel reaches the SAME method: CharacterManager -> NetworkLevelLoader.RequestSwitchArea -> RPC SendLoadLevel -> LoadLevel(_levelName, _spawnPoint, _spawnOffset) -> BaseLoadLevel. One funnel, one sceneLoaded. Pinned by FollowPolicyTests.CoverageGapDirect_CarriesTheOwnerKeyedZoneBackstop. Live-verify owed.

  • The anchor leash-teleport now says so. CompanionAnchor.Upkeep's leash block logged only the FAILURE half, so the anchor leash could not be proved from a log the way the body leash can ([PUPPET] ... reason=Leash) — docs/follow-polish-testplan.md F18 sat BLOCKED-NEEDS-EYES purely for want of a line. The success half now emits, at Info (the level the testplans read at): [ANCHOR] leash warp: dist=<m> > leash=<m> (follow|combat leash) reason=Leash - teleported the anchor to the owner's feet. Unthrottled on purpose: the teleport lands the anchor at the owner's feet, so a healthy leash emits ONE line per trip and a repeating line is itself the finding. No public surface change. Live-verify owed (F18).

  • CK 0.4.16 — PlayerStatBuff.Purge(stat, sourceId, mult): withdraw a stat stack by SourceID off the LIVE stat, whatever the applier's bookkeeping believes, and recompute the stat's cached CurrentValue on the spot. Additive; nothing existing changes shape. The instance Clear() can only remove what _applied remembers, and a Beastwhispering pouch share was found standing on PouchCapacity with the applier reporting it held nothing (BB5b, 2026-08-25) — a grant outliving its owner is the save-adjacent bug class, so a withdrawal now has an authority-first half that cannot be desynced by our own state. Stat.m_rawStack is keyed by SourceID, so the removal is exact and idempotent. The Update() matters too: CurrentValue is a cache vanilla refreshes on its own schedule (CharacterStats.UpdateStats, every third frame for the local player), so a readback taken right after a removal would otherwise still report the withdrawn value.

  • The owner-distance zone re-place now honors the COMBAT leash while a body is fighting (or inside the run-home grace). FollowPolicy.Decide's zone trigger was max(ZoneReplaceDistance, LeashDistance) unconditionally, and LeashDistance is the OUT-OF-COMBAT value only (CompanionBody.EffectiveLeash never relaxes it) — so a body allowed out to CombatLeashDistance by the leash leg (and by CompanionAnchor's own fighting ? CombatLeashDistance : AnchorLeashDistance) was still warped home by the ZONE leg at the 50 m floor, then pathed straight back out. Everything between the floor and the combat leash was a re-place/chase oscillation band, and it widened every time the combat leash was raised (40 -> 60 -> 84). The trigger is now max(floor, HasCombatTarget || graced ? CombatLeashDistance : LeashDistance). Out of combat with no grace running it is byte-for-byte what it was, so every non-combat path is unchanged — but note two things it DOES change on purpose:

    • the latched (agent-abandoned) backstop consumes the same zone for its PreReplaceOwner, so it relaxes from the floor to the combat leash in combat too. That is wanted: it keeps the latched path consistent with the live-agent one.
    • Hireling is byte-identical only OUT OF COMBAT. It has no FollowFacts fill site of its own (it runs through CompanionBody), so a hireling follower WITH a combat target now gets its zone trigger relaxed to Hireling's own CombatLeashDistance (60, HirelingSettings.cs). Intended, and the same fix — not a regression.

    No public surface change; version bumped to 0.4.15 anyway so [STAMP]/[CONTRACT] can tell an install with this fix from one without. COMPAT_SINCE stays 0.4.14. Live-verify owed: F15/F16 (BW pet, live + latched) and F17 (HIRELING) in docs/follow-polish-testplan.md.

  • 0.4.14 — ICompanionSettings grew StationEnemyFastMetersPerSecond (era 3); COMPAT_SINCE -> 0.4.14. Fast hold on the combat station's far rule: while the enemy runs faster than the knob (m/s; 0 = off, the default) the pet keeps the station it has instead of re-stationing after a receding mob. CombatStation.Decide(..., enemySpeed) overload (old arity forwards with NaN), CombatStationTuning.EnemyFastMetersPerSecond, CombatStationState.FastHolds, and the pure SpeedEstimate (m/s from consecutive positions). Consumers implementing the interface directly (BW) must add the member; CompanionSettingsDefaults gets a virtual 0.

  • Pearlbird movement rework, Phases 1+2 (docs/pearlbird-movement-handoff.md) — BUILT, live pass owed. Two structural fixes for the moonwalk / faces-away / spin family (Bugs 36/37):

    • Body-frame animator. Core.LocoFrame projects the per-frame displacement against the smoothed visual forward into a signed moveForward + moveSide (vanilla Lerp→MoveTowards smoothing); LocoRig gains Drive(moving, forward, side), HasSide and ReadSide (the 2-arg Drive is unchanged). Core.StrafeCapability gates it: side is fed only when the rig declares moveSide AND the species opted in (CompanionBody.AllowsBackpedal, host-stamped; the effigy-side CompanionSettingsDefaults.AllowsBackpedal virtual, default false — deliberately NOT an ICompanionSettings member, so no ABI break); every other rig is forward-only and byte-identical, and is forced to face its travel direction past a 60°/25° band. EffigyPin applies the same projection from pin displacement.
    • One look target, one turn law. Core.LookTarget + LookTargetPolicy.Choose replace the two divergent inline ladders (agent drive + DirectDrive); Core.TurnLaw is vanilla's (angle+15)·TurnSpeed·dt (radians) capped by the kit's deg/s cap with the ±0.7 pitch guard; FollowTuning.TurnSpeed = 0.08. The 2 m heading freeze, MinHeadingSqrForFacing and the IdleFacingPolicy re-aim hand-over are retired from CompanionBody (the Core class stays); FacingPolicy.* survive as forwarders onto the ladder. FollowPolicy.ApproachDestination gains a hysteresis overload (wasRefused/refused, ApproachRefuseEnterMeters 1.5 / ApproachRefuseExitMeters 2.0); the old overloads forward with no hysteresis.
    • Instrumentation: mS= + look= on the [PUPPET] heartbeat, facedump prints look/strafe/blend, automatic [FACE] approach began/ended: snapshots and [FACE] approach spot refused:.
    • Retest rows: the pearlbird-movement handoff's live list (sprint-away catch-up, walk toward a settled pet → WalkBack, feed at 2 m / 0.5 m / on top, idle faces owner, MP effigy backpedal).
  • Owner-focus assist (0.4.13). The pet now engages the enemy its OWNER's own attack last landed on, as a new priority-1 tier in Core.CombatTargetPolicy (above anchor-defend, below an explicit commanded order). Reported by Cobalt playing an archer: in Engage stance the pet hung back until the enemy walked into it. Two things blocked it — the player-engaged tier is gated on AggroRange (12 m, measured pet-to-enemy), and vanilla does not even list a target you shot at 30 m in player.EngagedCharacters until it aggros back and closes to 25 m (Character.UpdateCombatStatus / CheckIfCombatWorthy). The new tier reads a signal that did not previously exist: OwnerFocusTracker postfixes Character.HasHit (melee) and Projectile.OnProjectileHit (ranged — required, since the melee seam excludes bows), holds the struck enemy for Core.OwnerFocus.DefaultHoldSeconds (8 s, re-stamped by every landed hit), and is range-gated by its own OwnerFocusRange rather than AggroRange. A DISENGAGE order forgets the focus outright. New ICompanionSettings members AssistOnOwnerHit (default true) and OwnerFocusRange (default 60 m) — an added interface member is an ABI break for a consumer implementing the interface directly, hence the VERSION bump; see the era-3 note beside COMPAT_SINCE.

  • CombatLeashDistance default 40 -> 60 m. Same change, same reason: this value is Core.FollowPolicy's leash limit whenever a body has a combat target, and AnchorTargeting.UnifyLock's refusal threshold, so a pet sent after a 50 m bow target on the new tier would have been leash-warped home mid-charge with its anchor left un-unified. BepInEx never migrates a changed default into an existing .cfg — existing installs need the config/ overlay (scripts/sync-config.py), not just this build.

  • COMPAT_SINCE moved 0.4.7 -> 0.4.10 by hand (AF1-7). The release train's ABI gate only ever priced REMOVED surface, so both ICompanionSettings growth eras published with the floor untouched. Both are priced now, against what SHIPPED (the Thunderstore tag) rather than whichever dev commit carried a VERSION string, and the floor lands at the EARLIER era: era 1 is the 0.4.8-0.4.10 dev window, 33 -> 40 members — the Station* block (StationArriveMeters, StationLineAngleDeg, StationMaxRestations, StationRestationMeters, StationRestationSeconds, StationRingFraction) and StationFarMeters, all of which first reached players in the 0.4.10 tag; era 2 is StationProgressMeters alone (AF6-3), 40 -> 41, which postdates that tag and ships at 0.4.12. A consumer built against 0.4.8/0.4.9 predates the first shipped growth and must be refused; one built against 0.4.10 is the oldest that may still bind, so a floor any higher would refuse builds that are in fact fine. This hand-set value covers the SHIPPED kit only — because era 2 postdates the 0.4.10 publish anchor, the train will move the floor again to 0.4.12 on the next publish, unaided. scripts/release-train.py now treats an abstract member ADDED to a public interface as a break (added_interface_signatures / abi_break_signatures, with a selftest case), so the next one moves the floor by itself. Consumer [BepInDependency] floors need no edit — they inline CompanionKit.Plugin.VERSION at the consumer's own build — and the Thunderstore dep pins in each consumer's manifest.json / thunderstore.toml are regenerated from code truth by the train's own regen_dependencies on its next run, so they are deliberately NOT hand-edited. Retest row: docs/kit-versioning-testplan.md SA-T2-1.

  • ICompanionSettings grew an abstract member — 0.4.12. StationProgressMeters (the converge-or-chase gate's knob, default 1 m) joins the interface, alongside a virtual default on CompanionSettingsDefaults so ordinary consumers recompile unchanged. Per docs/wiki/kits/versioning.md an abstract member ADDED to a shipped public interface is a BREAK for anyone implementing it directly (the host and proxy shims do), which is why the version moves. ⚠ Release-train note: this is the SECOND interface growth point in this train — the 0.4.8-0.4.10 era grew ICompanionSettings too. Whoever moves COMPAT_SINCE (AF1-7, branch fix/sa-0823-train2) must price BOTH, not just this one; the floor belongs at or above the EARLIER of the two, not at 0.4.12.

  • Combat station "converge or chase" (AF6-3, 0.4.12): a far re-station wanted while the pet is still walking, whose fresh ideal is no closer (by StationProgressMeters) than the walk it was handed last time, is a STALL; two consecutive stalls latch CapFallback with LastWhy = "stalled" and the caller chases for the rest of the engagement. reach gains half a metre of hysteresis (CombatStation.ReachSlackMeters) — settled is deliberately NOT widened. CombatStationState gains LastStationDist, Stalls and an uncounted Reaches tally (all additive).

  • CompanionBody.SuspendAgent(bool) / AgentSuspended (AF5-1, 0.4.12, strictly ADDITIVE): the owner of a stretch that drives the body's transform itself (FtkLeap's leap arc) says so, and the follow drive stops re-arming the NavMeshAgent underneath it. FollowFacts gains AgentEnabled + AgentSuspendedExternally; FollowPolicy.ShouldReArmAgent is the new pure rung.

  • New Core types (additive): AnimParamLatch (when an Animator's parameter census may be believed — AF5-7) and StationPlantEdge (the [STATION] planted rising edge — AF5-6). LocoRig.ReadForward reads locomotion back from the animator the writes go to (AF5-2).

  • Flourish drops are counted under the leg that carried them (AF1-5): CompanionPetFx.ApplyCast / CompanionPlayerFx.ApplyLocal take the wire verb and count no-body / no-recipe / play-failed under it instead of hardcoding the cast verb — a master-side failure on a GUEST's ck.proxy.*.cast report used to be filed under the master's own relay verb, so netdump named the wrong leg and the report leg read clean. Pinned in FireAndForgetRelaySeamTests.

  • Flourish reports whether it fired (AF1-3, 0.4.11, strictly ADDITIVE): new CompanionPetFx.TryFlourish / CompanionPlayerFx.TryFlourish return bool beside the unchanged void Flourish (re-signing a shipped public member is a break — versioning.md), and FireAndForgetChannel gains TryFire beside Fire. A master with [PetFx] EnablePetFx=false ate every flourish with NO send and NO drop, so a caller could report "flash fired" with zero evidence anywhere in a dump. New CompanionPetFx.PetFxEnabled exposes the kill-switch so a consumer's dev verb can name the gate that ate it (BW's synergyflash now prints it, and the grant path warns once).

  • Self-play unarmed fallback (AF1-2, 0.4.11 — a CORRECTION to a recorded ruling): the player-body flourish let vanilla bind and never rebound, on the belief that an unarmed player would get "the prefab's default shape at the clone root". The prefab cannot do that: VFXParticlesOnWeapon.ChooseRenderer returns null with no weapon equipped, RefreshVFXOnChar only re-stamps the ShapeModule for a SkinnedMeshRenderer/MeshRenderer, and a mesh-shape emitter left with a null renderer emits nothing at all. After Play, self-play now rebinds only the emitters still holding a null target renderer onto the player's own body mesh; a resolved weapon renderer is untouched. Rule is pure (Core.BodyFxMath.SelfPlayNeedsRebind, pinned by BodyFxSelfPlayFallbackTests); the three docs carrying the old expectation are corrected.

  • Transient-flourish SUCCESS line (AF1-1, additive): CompanionPetFx/CompanionPlayerFx now log [PETFX]/[PLAYERFX] played '<key>' via <verb> on <target> src=<source root> on every apply that actually spawned a clone, and the PLAYER (self-play) leg appends what each on-renderer emitter bound to — <type>@<node> -> renderer=<name|NONE>. Until now the legs counted only DROPS, so a session with 30 sends and zero drops read exactly like 30 flashes played on an invisible surface, and the binding decision that decides visibility was never written down. New BodyFx.DescribeBindings(GameObject) + Core.BodyFxDescribe (pure, pinned by BodyFxBindingDescribeTests) and a PlayOneShot(..., out string sourceName) overload. The pet leg carries the fragments too (FX-1) — its T4 rebind stamps the first SkinnedMeshRenderer found under the body, which on an optimized rig can be a wrong or disabled renderer. Callers read BodyFx.LastPlayBindings rather than describing by hand: that latch is sampled AFTER the rebind, and on the one-frame-deferred path it reads bindings pending … while the deferred play logs the real [BODYFX] deferred one-shot on '<body>' bound: … line itself.

  • ProxyPets.AuthorizePlayerOwner / ProxyPets.ResolvePlayerUidOwner are PUBLIC (additive): the row-less player-owner authorizer rung is the gate any consumer needs to bind a fire-and-forget verb to the sending player rather than to a pet row (BW's bw.ftk.leap / bw.ftk.stomp migration onto NetBus.RegisterFireAndForget). No behaviour change — the same delegate CompanionPlayerFx already passes to its own channel.

  • Combat station review fixes (0.4.10, additive — same-tier review of the Pearlbird stance): H2 the deadband now keys off an OWNER-vantage corridor (Core.CombatStation.CorridorBlockAngleDeg

    • InCorridor: angle at the owner < LineAngleDeg AND the pet more than ring closer to the owner than the enemy) — the enemy-vantage CorridorAngleDeg stays as a diagnostic; LineAngleDeg default 35 → 20 (≈ 3.5 m lateral at 10 m). H1 CombatStationTuning.FarMeters (3): the enemy further than attackRange + FarMeters from the pet breaks the deadband (LastWhy="far"); the body binds it to StationRestationMeters. New "reach" reason: a planted pet whose enemy stands past attackRange re-stations. RingFraction default 0.8 → 0.6 (ring + the 0.6 m agent stop was past AttackRange, so a planted bird could not bite a mob that did not close). NEAR also swallows a recompute whose ideal is the held station while the pet is still walking to it. H3 the body emits [STATION] planted …, [STATION] restation #n why=…, [STATION] cap fallback — chasing for this engagement …, [STATION] set(UNREACHABLE: chasing) … (edge-triggered, body id at the end of the line so the literals grep). M1 a body planted on its station keeps its face on the enemy inside the 2 m facing freeze (DriveFacingAndAnim(forceHeading:)). M3 the station sample gets the RoofSanityMeters height check; a station whose path stays partial/invalid > 2 s is given up for the engagement. M4 PlantedOnStation / the station-active flag are re-derived every frame (a Hold/Direct/Replace frame can no longer arm the pin). CombatStationTests rewritten for the new predicate + two dynamics sims (mob runs at the owner; settled mob doing the vanilla melee hop).
  • Combat station / CompanionBody.CombatStyle (0.4.10, additive): the Pearlbird "opposite stance". A body set to CombatStyle.Opposite stations itself on the far side of its combat target from the owner (Core.CombatStation — pure, with a deadband / settle-bias / rate-limit / cap anti-loop contract, CombatStationTests) and plants there (AgentDriveFacts.HasStation/StationDist/ StationArriveMeters, FollowTuning.StationArriveMeters), facing the enemy; CapFallback returns it to plain chase for that engagement. CompanionBody.Station / PlantedOnStation expose the state; [STATION] log lines on first/recompute/cap. ICompanionSettings gains the six Station* knobs (defaults = CombatStationTuning's). Default CombatStyle.Chase is byte-identical to before. COMPAT_SINCE stays 0.4.7.

  • NetBus.RegisterFireAndForget(castVerb, proxyVerb, authorize, apply, enabled)FireAndForgetChannel (0.4.9, additive): the transient (fire-and-forget) relay ladder as one registration — Fire(uid, payload) applies locally, then in a room the master relays castVerb to Others / a guest reports proxyVerb to the master; the cast handler (SenderMustBeMaster | SenderMustNotBeSelf) drops an empty identity and SKIPS the owner's own returning cast; the proxy handler (RunOnMasterOnly) counts the authorizer's reason as the drop and relays on success — only what its apply PARSED, as the consumer's canonical rebuild (refused bytes never reach peers). Decision ladder is pure (NetKit.Core.FireAndForgetLadder, unit-tested). CompanionPetFx's cast half and CompanionPlayerFx ride it now — same verbs, same payload bytes, same drop reasons; their hand-written OnCast/MasterOnCast and ProxyPets' OnPetFxCast/OnPlayerFxCast/ReportFxCast/ ReportPlayerFxCast (all internal) are gone. ProxyPets.AuthorizePetOwner / AuthorizePlayerOwner (internal) are the two gates as reusable authorizers. ck.effigy.swing untouched. COMPAT_SINCE stays 0.4.7.

  • CompanionPlayerFx (0.4.8, additive): the player-body cosmetic wire leg — Flourish(ownerUid, spellKey) plays an allowlisted one-shot FX on the owning PLAYER's Character on every machine (ck.playerfx.cast M→Others, ck.proxy.playerfx.cast G→M authorized by the owner-binding rung, no pet row needed). Self-play binder, so weapon-bound vanilla VFX bind to that player's weapon. Master-side drop reasons for the G→M leg: no-replica (the uid resolves no replica yet — mid-join race) and not-owner (a binding rung refused); playerfx rung refusals warn once under their own bind|playerfx|… key, so they never silence the store's pet-announce warning.

  • Protected characters honoured (AggroKit TargetableOverrides / AggroTools.Protect): the anchor's upkeep releases a lock its vanilla AI holds on a protected character ([ANCHOR] released lock on PROTECTED …) and UnifyLock never asserts one; CompanionCombat filters every ladder candidate (commanded / anchor-defend / player-engaged) through one RefuseProtected choke point ([PETCOMBAT] refusing PROTECTED …), which SpecialAttackTarget and the proxy mirror inherit. The 2026-08-22 road-merchant bite (combat ENTER … locked=Travelling Merchant → anchor-defend).

  • Command STAY: CommandStance gains Stay / Mode / Changed; Core.CommandMode + CommandCycle (Engaged → Follow → Stay); CompanionBody.StaySpot + OnWarpedToOwner; CompanionCombat plants the spot on the Stay edge and demotes Stay → Follow on any warp home or body rebuild. FollowPolicy: a stay spot disables loaf/approach/catch-up and the can't-reach warp; the owner-distance leash is unchanged on purpose.

0.4.7 — 2026-08-19

  • Second-review adjudication: drop a dead dedupe, lift the claim rule into Core
  • Review fixes: ck.proxy.status must never read as live when it isn't
  • Review fixes: LocoRig's param cache must not latch an uninitialized animator
  • ck.proxy.status: master streams the proxied anchor's status list to its owner
  • Fix companion locomotion params never reaching the skin-driving animator
  • Fix companion locomotion freeze on optimized-rig species (Veaber/Pearlbird)

0.4.6 — 2026-08-19

  • Review fixes for the locomotion wave: LocoRig re-probes an animator that reported no parameters yet (the uninitialized-Animator trap would have latched "writes nothing" for a body's whole life), and every skin-driving animator is fed, not just the first
  • Fix BUG-PETICESKATE: feed locomotion params to the SKIN-driving animator too (LocoRig), and raise the moveForward blend ceiling from a walk (3) to the run rung (8) — the parameter is in m/s
  • Fix BUG-PETANIMFROZEN (round-1 regression): never disable the animator that drives the skin
  • Review nits: TagRig const, HasParam asymmetry comment, live-retest watchpoints
  • Fix BUG-PETANIMSTUCKIDLE: disable child override animators so the root attack rig renders
  • Review fixes for V6b: the tab-injector field, and the row keeps its id
  • Fix V6b: the Quiet-tier exemption was inverted (ambient ungated, verbs gated)
  • Merge branch 'fix/sa-0815-leafkits' into feature/pet-self-feed
  • Static-analysis wave F3c: leaf-kit fixes (A7-1/2/4/8/9/13/14/15/16/20/23/24/25/27)
  • Merge branch 'fix/sa-0815-bw-glue' into feature/pet-self-feed
  • Review fixes for the SA lane-F3 MP/net wave (H1, M1, M2, L1-L3)
  • BW glue: self-feed kill-switches, armor-wear save debounce, inert armor survival
  • SA 2026-08-15 lane F3: MP/net teardown, mirror-race + hello-warn fixes
  • Pet Equipment inventory section + review-fix wave; BUG-PREBUILTADOPT filed
  • Pet armor: CompanionKit equipment fabric + PetArmor.json-driven Pet Leather Armor
  • DonorKit: refuse a body-template capture with no AI graph
  • Forge shell fixes: SSH commands via bash -c (fish login shell); set/cfgdump on every channel mod
  • Forge shell: catalog dump + response protocol + set/cfgdump in ForgeKit; forge CLI/REPL + completion packs; wiki-enriched name db
  • Merge branch 'release/pre-bundle-bodies'
  • Feed scene tune: 0.5s settle beat on arrival + spot 1.75→2.05m (Hyena live tune)
  • CompanionScene: SceneSpot hold — pet walks onto the mark and stays until the scene concludes
  • Feed/bandage interaction scene: pet runs over first, THEN the player's SetupGround anim, then the effect — on a reusable CompanionKit scene engine
  • Bundle body tier: Pearlbird ships as an AssetBundle rig — expedition bypassed (B1/B2/B3a live-verified)

0.4.5 — 2026-08-11

  • Merge phantom view-ID work: root-cause writeup (donor-stray duplicate registration evicts scene-baked views via PUN's destroy-old resolution), DuplicateViewPolicy veto in DonorPhotonGuard, unknown-view runaway telemetry + hb top= attribution, corpse-mute window accepted as bounded (NK-PHANTOM1/2 + V-PARKLEAK A/B retests owed)
  • Phantom view-ID root cause + fixes: NetKit hb per-id attribution (top=[id×n]) + unknown-view RUNAWAY detector, DonorPhotonGuard duplicate-registration veto over live scene-baked views (DuplicateViewPolicy, unit-tested), V-PARKLEAK bounded-window acceptance + pt-stamped mute line; analysis doc + NK-PHANTOM1/2 retest rows — built, retest owed
  • Fix BUG-BOONFXSILENT + SR-D10 in the FX capture/strip pipeline
  • Slope-tilt review wave: pure smoother + hysteresis deadband + per-species axis
  • Merge feat/companion-aura: CompanionAura registry (Burning Man fire / Wendigo frost infuse auras) + leg-A VFX fixes
  • Companion auras: abstract persistent-visual system + Burning Man/Wendigo infuse auras
  • Merge feat/runic-pet-lantern-bolt: CompanionProjectile registry + visible bolt from the pet's lantern
  • Leg E: a real bolt from the pet's lantern, via a CompanionKit pet-projectile registry
  • Infuse rider audit: no defect found; ship the diagnostics that settle it
  • Shaman resonance leg D: Infuse Fire/Frost -> a typed damage rider on the pet
  • Merge fix/sa-0808-tests: test-debt wave (SA 2026-08-08 §4)
  • Merge fix/sa-0808-bw: BW credit/identity wave (SA 2026-08-08)
  • Merge fix/sa-0808-spawn: spawn recovery wave (SA 2026-08-08 §8)
  • Wave D-bw review fixes: per-SHAPE synergy arm, charged-weapon grace, save one-shot at the write site
  • Static-analysis 2026-08-08 wave E: close the test-debt list (§4)
  • SA-0808 Wave C data half: DonorScenes pin wave + sweep-doc corrections
  • Wave D-bw: Hunt-Synergy armed-skill binding + BW P3 hygiene (2026-08-08 static analysis)
  • NetKit hardening wave (static-analysis 2026-08-08 §7 items 1-7 + P1-6/P2-8)
  • Merge branch 'feat/pet-health-orb'
  • Pet health orb: replace the IMGUI bar with a green Outward-style vitals circle
  • … and 38 more (see git history)

Unreleased

  • Diagnostics (built, NOT live-verified): the master's ck.proxy.hit drop reason no-target is split into target-unknown (the UID did not resolve on the master) vs target-dead (resolved but no longer alive) — dead-on-the-kill-swing is expected and benign (the verb is aggro replay only; damage rides vanilla replication), so target-unknown is now the signal worth watching. Both stay silent drop-counts; ladder order unchanged. The [ANCHOR] hp drift OUT OF COMBAT probe line gains appended locked= (the tick-start AI lock — the attacker evidence the calm paths erase), ai=(state=… locked=…) (the current AI view via DescribeAI) and calmedThisTick= (whether the stale-lock sweep or the B6 re-calm suppressed fighting in the same Upkeep pass) so it distinguishes "no attacker anywhere" from "attacker present but the fighting flag was suppressed" — the probe's two documented false-negative windows. Existing tokens on both surfaces stay byte-stable (append-only).

  • Fixed (MP-PETAIMDRIFT; built, NOT live-verified): a guest-owned pet visibly desynced on the host — its master-side proxy anchor had NO position link to the guest's puppet (two independent walkers; a proven ~3.8 m at-rest offset). New verb ck.proxy.pos (G→M) makes the guest's puppet the single position authority: the position + facing yaw stream off CompanionBody.OnAfterMove, change-detected against the last-sent value (≥0.25 m per axis / ≥5° circular yaw, with movement hysteresis so a grid-boundary sitter or the ±180° yaw seam can never dither) behind a ~0.2 s attempt-rate floor — the whole build/latch path runs at most ~5 Hz on the guest's per-frame hot path — plus a 4 s at-rest resend (Core.ProxyPosPacing). The master's new per-frame ProxyDrive places the proxy anchor on the stream through the weld's one engine apply path (AnchorWeld.Apply, extracted from GlueTick — snap past 3 m, else MoveTowards at a speed cap, the vanilla NetworkCharacterControl AI-replica semantics; drive snaps log under [PROXY], keeping [GLUE] a pure local-weld signal). Every other peer converges via the anchor's native replication — one new wire hop total. While the stream is live AND actually being applied the row counts as WELDED (CompanionAnchor.ExternallyDriven requires fresh receive + recent apply, so a drive that stops writing reverts to normal recovery; the leash teleport and passive recall stand down; AISWander.FollowTransform released); an anchor stuck at void coords is snap-rescued onto the sane stream. Targeting is untouched (the anchor still aims and fights). Staleness fallback: no pos for [Proxy] PosStaleSeconds (default 10 s, floored to 2×resend+2 s so one lost resend cannot fake a blip) reverts the row to the shipped owner-follow with one transition line each way — an OLD guest that never sends pos behaves exactly as before, and an OLD master drops the verb with NetKit's usual warn-once accounting (no version bump; the release train owns versions). Codec NetProtocol.BuildPos/TryParsePos is refusing (drop reasons unparseable vs insane-pos — the decode-time WorldPosition.IsSane gate). The mirror re-seeds on every announce via the bond-level Companion.ResetProxyPosMirror (the pos mirror exists whether or not combat does). Observability: proxydump/effigydump/[PROXY] pos lines gain drive=(live|stale|none) driveAge= applyAge= lastPos= and a pt= shared-clock stamp (appended — existing tokens byte-stable); the verb is QUIET on the ck channel (see NetKit) so a ~5 Hz stream cannot wreck the log or the trace ring.

  • Fixed (MP-ANIMASYM; built, NOT live-verified): a guest-owned pet's attack animation never played on a machine running the shipped agent-follow effigy mode ([Effigy] PinToAnchor=false, the default) — every foreign swing dropped as no-pin, because the attack vocabulary lived on the pin-mode-only EffigyPin. The vocabulary is now the shared EffigySwingMirror, attached to effigy bodies in BOTH drive modes (it only fires Animator triggers — agent-follow's CompanionBody/agent movement brain is untouched); both swing carriers (ck.effigy.swing and the vanilla-attack-RPC belt) route through it; the ApplySwing drop reason no-pin is renamed no-mirror (a genuine fault now, not a mode choice); agent-follow swing lines log as [SWING/EFFIGY:<species>] while pin lines keep their byte-stable [PIN/EFFIGY:…] grep contract; effigydump surfaces the swings=/attackParams= counters for agent-follow bodies too. Also closes the latent latch bug: [Effigy] PinToAnchor is read at plugin Awake now, not first inside the effigy tick, so a body built before the first tick can no longer run agent-follow with pin=true in the cfg. Pure trigger selection is Core.SwingVocabulary (unit-pinned).

  • Refactor (no behavior change): BodyFactory's two census sites read their PhotonView / NetworkCharacterControl counts from NetKit.Views.CountNetwork instead of the local CountAll (phase-2 dedup; identical semantics, census log lines byte-unchanged).

  • Fixed: BodyFactory's component strip is now plural and children-inclusive, re-checked after activation, with a loud tripwire if a PhotonView survives onto a live puppet and a byte-stable [CLONE]#n strip census: breadcrumb (the MP10 guest-tame world-origin fix; also removes a latent hazard where a kept-look humanoid puppet's equipped-item child PhotonViews survived carrying the source NPC's item viewIDs).

  • New: stuck-body detection (Core.StuckBodyPolicy + per-body opt-in CompanionBody.StuckDetection, one-shot STUCK-WATCH warning at the trip edge) and Companion.DropBody() — destroy just the body while keeping the anchor, stance and bond aggregate, so a consumer can rebuild a wedged body in place. Census lines carry a stuck= token while detection is on. CONSUMER_CONTRACT 1 → 2 (three new hard-referenced seams).

  • Anchor replica recognition is ownership-aware in its logging: a machine seeing its OWN pet's proxied anchor replica now logs own-pet companion anchor replica recognized instead of claiming it is foreign; all foreign-case lines are byte-unchanged, and the dressing behavior itself is deliberately identical for both (Core.AnchorReplicaPolicy documents why every pass applies to every replica).

  • New: spell FX on companion bodies. BodyFx plays a one-shot flourish or wears a persistent visual on a brain-stripped body (puppet or effigy) — the two things a Character-less body cannot do for itself, since the engine's own bind path needs a live Character.

  • New: CompanionPetFx replicates those visuals across a co-op session on the ck channel (a petfx record store keyed by owner and slot, plus a transient cast message and the guest→master relay). One apply path serves the owner's real body and every other machine's effigy, so a guest can no longer be skipped by construction.

  • New: petfxdump on ck_cmd.txt — what this machine RECEIVED and whether it is on a body, including a sweep for orphaned FX clones. It needs no consumer mod installed.

  • Off-switch: [PetFx] EnablePetFx in cobalt.companionkit.cfg.

  • Fixed (MP10 fix wave 2, root cause): the strip tripwire above used to CLAIM it destroyed a survived PhotonView, but the destroy itself could be silently refused by Unity the same way the original strip was — the strip census could read pv=1 right after the tripwire "ran". Clone hygiene now delegates the Photon half to NetKit.Views — every dangerous field (removedFromLocalViewList, viewID, sync mode, send group) is neutralized with plain field writes BEFORE any destroy is attempted, so a refused destroy can no longer let a stray view evict the real replica. The tripwire's log is now honest: VerifySurvivors reports "REFUSED, not attempted-and-failed" and disarms what's left, instead of asserting a destroy that may not have happened.

  • New: build-time contamination verdict (Core.ContaminationPolicy, pv/ncc/charAI/caid only — a benign ghost-rig Character and legitimate Item children are deliberately excluded). Census lines gained a trailing items=N. token (the period moved onto it) and a contaminated=N token whenever the body is dirty, so a downstream watchdog can trip on the actual contamination instead of inferring it from position.

  • Fixed: the pet spell-FX one-shot (BodyFx) had the same latent "runs inside an animation event" bug as the tame path. It now strips the WHOLE VFXPlayed rebind family (including the per-frame bone follower and the particle-reparent step, both of which could leak an orphaned GameObject), re-enumerates survivors after stripping, pins the body ONLY when a survivor is actually found (a clean strip keeps the creature's own clip-driven root motion), and defers Play one frame whenever a survivor was found so queued destroys flush before the broadcast goes out.

0.4.4 — 2026-08-02

  • Reaper fail-safe: never judge a body when no consumer has spoken

0.4.3 — 2026-08-02

  • CompanionKit: Unity fake-null bugs, guard ordering, and the logs that were missing
  • CompanionKit: one weapon-neuter, one settings snapshot per sweep
  • CompanionKit.Core: five pure-logic bugs the unit suite could not see
  • Docs sweep: archive, condense, and validate the whole documentation tree

0.4.2 — 2026-08-02

  • E8d/EFFIGYVANISH wave: placement gate, universal agent bind, body census + reaper
  • Pin-session fix wave: recall moves the anchor, replica fuse defused, glide, swing mirror
  • Effigy-pin B-wave: attack mirror + BEV-V1 reconcile hardening + E8d instrumentation
  • Fix the MP session's three critical bugs: cross-machine cast replay, master tame, effigy yaw+speed

0.4.1 — 2026-07-30

  • Cap all-status build-up resistance below the engine's refusal sentinel
  • Hyena/Pearlbird tuning wave: HAO taunt, gifts, bone relic, feed rule