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.TierMax4 → 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 theckannounce 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_SINCEstays 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.Decidecomputes the owner-keyed zone re-place trigger aszone = max(ZoneReplaceDistance (50), ownerLeash). Every shippedLeashDistancehad always sat BELOW 50, so out of combatzonewas always exactly 50 and the coupling was invisible. BW's[Pet] LeashDistance42 → 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 atgoalDist≈0and 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 usesf.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 shipsLeashDistance30, so its trigger resolves to exactly the old 50. New pin:FollowPolicyTests.CoverageGapBackstop_PinnedGoal_KeepsTheFlatFloor. Owed live:docs/follow-polish-testplan.mdF22. -
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 warpline 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 = 28live), and from outside the code a logging bug and a leash that correctly does not apply look identical. The code answer: inCompanionAnchor.Upkeepthe whole leash block sits behindif (!recalled && !glued && dist > leash), andgluedcomes fromAnchorGlue.Engaged(GlueMode, hasBody: _body != null, …), which returns true unconditionally forAnchorGlueMode.Always+ a body. Every local companion has a body wheneverUpkeepruns, so!gluedis 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, undrivenProxyPetsanchor and for the legacyGlueMode=Off/Combat-out-of-combat topologies). It is also the CORRECT suppression:AnchorLeashDistance(28) sits below the body'sLeashDistance(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 additiveCore.AnchorGlue.LeashApplies(mode, hasBody, inCombat, externallyDriven)(unit-tested intests/CompanionKit.Tests/AnchorGlueTests.cs),gluedis 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.0every two seconds — no follow, no leash, no way home short of a forced resummon. Root cause is rung ORDER inFollowPolicy.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, aSceneSpot, a combat target): a pinned goal keeps its OLD AREA's coordinates across the load, the new scene has no navmesh there,GoalOnMeshreads false forever, and the zone re-place two rungs down was unreachable — soOnWarpedToOwnernever fired either, and a Stay order was never ended. The coverage-gap rung now sets the samePreReplaceOwnerthe 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, sameSuppressLeashWarpopt-out for a mount rider.CompanionBodyapplies it through the new sharedAgentFreeReplaceOntoOwnerhelper (the latched path's text and behaviour are byte-identical); the coverage-gap path raisesOnWarpedToOwner("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 > zoneandGoalOnMesh == falsecannot both hold in any healthy frame. This is player-facing, not a dev-verb artefact — thegotoverb reachesNetworkLevelLoader.LoadLevel(scene, spawn), and a door / region fast-travel reaches the SAME method:CharacterManager->NetworkLevelLoader.RequestSwitchArea-> RPCSendLoadLevel->LoadLevel(_levelName, _spawnPoint, _spawnOffset)->BaseLoadLevel. One funnel, onesceneLoaded. Pinned byFollowPolicyTests.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.mdF18 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 cachedCurrentValueon the spot. Additive; nothing existing changes shape. The instanceClear()can only remove what_appliedremembers, and a Beastwhispering pouch share was found standing onPouchCapacitywith 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_rawStackis keyed by SourceID, so the removal is exact and idempotent. TheUpdate()matters too:CurrentValueis 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 wasmax(ZoneReplaceDistance, LeashDistance)unconditionally, andLeashDistanceis the OUT-OF-COMBAT value only (CompanionBody.EffectiveLeashnever relaxes it) — so a body allowed out toCombatLeashDistanceby the leash leg (and byCompanionAnchor's ownfighting ? 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 nowmax(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
zonefor itsPreReplaceOwner, 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
FollowFactsfill site of its own (it runs throughCompanionBody), so a hireling follower WITH a combat target now gets its zone trigger relaxed to Hireling's ownCombatLeashDistance(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_SINCEstays 0.4.14. Live-verify owed: F15/F16 (BW pet, live + latched) and F17 (HIRELING) indocs/follow-polish-testplan.md. - the latched (agent-abandoned) backstop consumes the same
-
0.4.14 —
ICompanionSettingsgrewStationEnemyFastMetersPerSecond(era 3);COMPAT_SINCE-> 0.4.14. Fast hold on the combat station'sfarrule: 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 pureSpeedEstimate(m/s from consecutive positions). Consumers implementing the interface directly (BW) must add the member;CompanionSettingsDefaultsgets 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.LocoFrameprojects the per-frame displacement against the smoothed visual forward into a signedmoveForward+moveSide(vanilla Lerp→MoveTowards smoothing);LocoRiggainsDrive(moving, forward, side),HasSideandReadSide(the 2-argDriveis unchanged).Core.StrafeCapabilitygates it: side is fed only when the rig declaresmoveSideAND the species opted in (CompanionBody.AllowsBackpedal, host-stamped; the effigy-sideCompanionSettingsDefaults.AllowsBackpedalvirtual, default false — deliberately NOT anICompanionSettingsmember, 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.EffigyPinapplies the same projection from pin displacement. - One look target, one turn law.
Core.LookTarget+LookTargetPolicy.Choosereplace the two divergent inline ladders (agent drive + DirectDrive);Core.TurnLawis 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,MinHeadingSqrForFacingand theIdleFacingPolicyre-aim hand-over are retired fromCompanionBody(the Core class stays);FacingPolicy.*survive as forwarders onto the ladder.FollowPolicy.ApproachDestinationgains a hysteresis overload (wasRefused/refused,ApproachRefuseEnterMeters1.5 /ApproachRefuseExitMeters2.0); the old overloads forward with no hysteresis. - Instrumentation:
mS=+look=on the[PUPPET]heartbeat,facedumpprints 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).
- Body-frame animator.
-
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 — theplayer-engagedtier is gated onAggroRange(12 m, measured pet-to-enemy), and vanilla does not even list a target you shot at 30 m inplayer.EngagedCharactersuntil it aggros back and closes to 25 m (Character.UpdateCombatStatus/CheckIfCombatWorthy). The new tier reads a signal that did not previously exist:OwnerFocusTrackerpostfixesCharacter.HasHit(melee) andProjectile.OnProjectileHit(ranged — required, since the melee seam excludes bows), holds the struck enemy forCore.OwnerFocus.DefaultHoldSeconds(8 s, re-stamped by every landed hit), and is range-gated by its ownOwnerFocusRangerather thanAggroRange. A DISENGAGE order forgets the focus outright. NewICompanionSettingsmembersAssistOnOwnerHit(default true) andOwnerFocusRange(default 60 m) — an added interface member is an ABI break for a consumer implementing the interface directly, hence theVERSIONbump; see the era-3 note besideCOMPAT_SINCE. -
CombatLeashDistancedefault 40 -> 60 m. Same change, same reason: this value isCore.FollowPolicy's leash limit whenever a body has a combat target, andAnchorTargeting.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 theconfig/overlay (scripts/sync-config.py), not just this build. -
COMPAT_SINCEmoved 0.4.7 -> 0.4.10 by hand (AF1-7). The release train's ABI gate only ever priced REMOVED surface, so bothICompanionSettingsgrowth eras published with the floor untouched. Both are priced now, against what SHIPPED (the Thunderstore tag) rather than whichever dev commit carried aVERSIONstring, and the floor lands at the EARLIER era: era 1 is the 0.4.8-0.4.10 dev window, 33 -> 40 members — theStation*block (StationArriveMeters,StationLineAngleDeg,StationMaxRestations,StationRestationMeters,StationRestationSeconds,StationRingFraction) andStationFarMeters, all of which first reached players in the 0.4.10 tag; era 2 isStationProgressMetersalone (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.pynow treats an abstract member ADDED to a public interface as a break (added_interface_signatures/abi_break_signatures, with aselftestcase), so the next one moves the floor by itself. Consumer[BepInDependency]floors need no edit — they inlineCompanionKit.Plugin.VERSIONat the consumer's own build — and the Thunderstore dep pins in each consumer'smanifest.json/thunderstore.tomlare regenerated from code truth by the train's ownregen_dependencieson its next run, so they are deliberately NOT hand-edited. Retest row:docs/kit-versioning-testplan.mdSA-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 avirtualdefault onCompanionSettingsDefaultsso ordinary consumers recompile unchanged. Perdocs/wiki/kits/versioning.mdan 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 grewICompanionSettingstoo. Whoever movesCOMPAT_SINCE(AF1-7, branchfix/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
farre-station wanted while the pet is still walking, whose fresh ideal is no closer (byStationProgressMeters) than the walk it was handed last time, is a STALL; two consecutive stalls latchCapFallbackwithLastWhy = "stalled"and the caller chases for the rest of the engagement.reachgains half a metre of hysteresis (CombatStation.ReachSlackMeters) —settledis deliberately NOT widened.CombatStationStategainsLastStationDist,Stallsand an uncountedReachestally (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.FollowFactsgainsAgentEnabled+AgentSuspendedExternally;FollowPolicy.ShouldReArmAgentis the new pure rung. -
New Core types (additive):
AnimParamLatch(when an Animator's parameter census may be believed — AF5-7) andStationPlantEdge(the[STATION] plantedrising edge — AF5-6).LocoRig.ReadForwardreads 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.ApplyLocaltake the wire verb and countno-body/no-recipe/play-failedunder it instead of hardcoding the cast verb — a master-side failure on a GUEST'sck.proxy.*.castreport used to be filed under the master's own relay verb, so netdump named the wrong leg and the report leg read clean. Pinned inFireAndForgetRelaySeamTests. -
Flourish reports whether it fired (AF1-3, 0.4.11, strictly ADDITIVE): new
CompanionPetFx.TryFlourish/CompanionPlayerFx.TryFlourishreturnboolbeside the unchangedvoid Flourish(re-signing a shipped public member is a break — versioning.md), andFireAndForgetChannelgainsTryFirebesideFire. A master with[PetFx] EnablePetFx=falseate every flourish with NO send and NO drop, so a caller could report "flash fired" with zero evidence anywhere in a dump. NewCompanionPetFx.PetFxEnabledexposes the kill-switch so a consumer's dev verb can name the gate that ate it (BW'ssynergyflashnow 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.ChooseRendererreturns null with no weapon equipped,RefreshVFXOnCharonly 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 byBodyFxSelfPlayFallbackTests); the three docs carrying the old expectation are corrected. -
Transient-flourish SUCCESS line (AF1-1, additive):
CompanionPetFx/CompanionPlayerFxnow 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. NewBodyFx.DescribeBindings(GameObject)+Core.BodyFxDescribe(pure, pinned byBodyFxBindingDescribeTests) and aPlayOneShot(..., 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 readBodyFx.LastPlayBindingsrather than describing by hand: that latch is sampled AFTER the rebind, and on the one-frame-deferred path it readsbindings pending …while the deferred play logs the real[BODYFX] deferred one-shot on '<body>' bound: …line itself. -
ProxyPets.AuthorizePlayerOwner/ProxyPets.ResolvePlayerUidOwnerare 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'sbw.ftk.leap/bw.ftk.stompmigration ontoNetBus.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.CorridorBlockAngleDegInCorridor: angle at the owner <LineAngleDegAND the pet more thanringcloser to the owner than the enemy) — the enemy-vantageCorridorAngleDegstays as a diagnostic;LineAngleDegdefault 35 → 20 (≈ 3.5 m lateral at 10 m). H1CombatStationTuning.FarMeters(3): the enemy further thanattackRange + FarMetersfrom the pet breaks the deadband (LastWhy="far"); the body binds it toStationRestationMeters. New"reach"reason: a planted pet whose enemy stands pastattackRangere-stations.RingFractiondefault 0.8 → 0.6 (ring + the 0.6 m agent stop was pastAttackRange, 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 theRoofSanityMetersheight check; a station whose path stays partial/invalid > 2 s is given up for the engagement. M4PlantedOnStation/ the station-active flag are re-derived every frame (a Hold/Direct/Replace frame can no longer arm the pin).CombatStationTestsrewritten 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 toCombatStyle.Oppositestations 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;CapFallbackreturns it to plain chase for that engagement.CompanionBody.Station/PlantedOnStationexpose the state;[STATION]log lines on first/recompute/cap.ICompanionSettingsgains the sixStation*knobs (defaults =CombatStationTuning's). DefaultCombatStyle.Chaseis byte-identical to before.COMPAT_SINCEstays 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 relayscastVerbto Others / a guest reportsproxyVerbto 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 andCompanionPlayerFxride it now — same verbs, same payload bytes, same drop reasons; their hand-writtenOnCast/MasterOnCastand ProxyPets'OnPetFxCast/OnPlayerFxCast/ReportFxCast/ReportPlayerFxCast(allinternal) are gone.ProxyPets.AuthorizePetOwner/AuthorizePlayerOwner(internal) are the two gates as reusable authorizers.ck.effigy.swinguntouched.COMPAT_SINCEstays 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.castM→Others,ck.proxy.playerfx.castG→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) andnot-owner(a binding rung refused); playerfx rung refusals warn once under their ownbind|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 …) andUnifyLocknever asserts one;CompanionCombatfilters every ladder candidate (commanded / anchor-defend / player-engaged) through oneRefuseProtectedchoke point ([PETCOMBAT] refusing PROTECTED …), whichSpecialAttackTargetand the proxy mirror inherit. The 2026-08-22 road-merchant bite (combat ENTER … locked=Travelling Merchant→ anchor-defend). -
Command STAY:
CommandStancegainsStay/Mode/Changed;Core.CommandMode+CommandCycle(Engaged → Follow → Stay);CompanionBody.StaySpot+OnWarpedToOwner;CompanionCombatplants 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.hitdrop reasonno-targetis split intotarget-unknown(the UID did not resolve on the master) vstarget-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), sotarget-unknownis now the signal worth watching. Both stay silent drop-counts; ladder order unchanged. The[ANCHOR] hp drift OUT OF COMBATprobe line gains appendedlocked=(the tick-start AI lock — the attacker evidence the calm paths erase),ai=(state=… locked=…)(the current AI view via DescribeAI) andcalmedThisTick=(whether the stale-lock sweep or the B6 re-calm suppressedfightingin 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 offCompanionBody.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-frameProxyDriveplaces the proxy anchor on the stream through the weld's one engine apply path (AnchorWeld.Apply, extracted fromGlueTick— snap past 3 m, else MoveTowards at a speed cap, the vanillaNetworkCharacterControlAI-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.ExternallyDrivenrequires fresh receive + recent apply, so a drive that stops writing reverts to normal recovery; the leash teleport and passive recall stand down;AISWander.FollowTransformreleased); 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). CodecNetProtocol.BuildPos/TryParsePosis refusing (drop reasonsunparseablevsinsane-pos— the decode-timeWorldPosition.IsSanegate). The mirror re-seeds on every announce via the bond-levelCompanion.ResetProxyPosMirror(the pos mirror exists whether or not combat does). Observability:proxydump/effigydump/[PROXY] poslines gaindrive=(live|stale|none) driveAge= applyAge= lastPos=and apt=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 asno-pin, because the attack vocabulary lived on the pin-mode-onlyEffigyPin. The vocabulary is now the sharedEffigySwingMirror, 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.swingand the vanilla-attack-RPC belt) route through it; theApplySwingdrop reasonno-pinis renamedno-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;effigydumpsurfaces theswings=/attackParams=counters for agent-follow bodies too. Also closes the latent latch bug:[Effigy] PinToAnchoris 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 isCore.SwingVocabulary(unit-pinned). -
Refactor (no behavior change):
BodyFactory's two census sites read their PhotonView / NetworkCharacterControl counts fromNetKit.Views.CountNetworkinstead of the localCountAll(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 aPhotonViewsurvives 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-inCompanionBody.StuckDetection, one-shotSTUCK-WATCHwarning at the trip edge) andCompanion.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 astuck=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 recognizedinstead of claiming it is foreign; all foreign-case lines are byte-unchanged, and the dressing behavior itself is deliberately identical for both (Core.AnchorReplicaPolicydocuments why every pass applies to every replica). -
New: spell FX on companion bodies.
BodyFxplays 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:
CompanionPetFxreplicates those visuals across a co-op session on theckchannel (apetfxrecord 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:
petfxdumponck_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] EnablePetFxincobalt.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 readpv=1right after the tripwire "ran". Clone hygiene now delegates the Photon half toNetKit.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:VerifySurvivorsreports "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/caidonly — a benign ghost-rigCharacterand legitimate Item children are deliberately excluded). Census lines gained a trailingitems=N.token (the period moved onto it) and acontaminated=Ntoken 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 WHOLEVFXPlayedrebind 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 defersPlayone 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