You are viewing a potentially older version of this package. View all versions.
CeruleanCutlass-CompanionKit-0.4.20 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.

Date uploaded a week ago
Version 0.4.20
Download link CeruleanCutlass-CompanionKit-0.4.20.zip
Downloads 103
Dependency string CeruleanCutlass-CompanionKit-0.4.20

This mod requires the following mods to function

BepInEx-BepInExPack_Outward-5.4.19 icon
BepInEx-BepInExPack_Outward

BepInEx pack for Outward.

Preferred version: 5.4.19
CeruleanCutlass-ForgeKit-0.4.10 icon
CeruleanCutlass-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).

Preferred version: 0.4.10
CeruleanCutlass-AggroKit-0.1.5 icon
CeruleanCutlass-AggroKit

Reusable aggro/threat control tooling for Outward BepInEx mods: per-pair targetability gates, detection veto/redirect, taunt and force-target primitives. Inert until a mod uses it β€” a library dependency, installed automatically.

Preferred version: 0.1.5
CeruleanCutlass-DonorKit-0.1.7 icon
CeruleanCutlass-DonorKit

The donor-scene harvest engine for Outward BepInEx mods: additively load any area, clone a live creature into a dormant template, and unload cleanly. Library dependency, installed automatically when another mod (SpawnKit, CompanionKit) needs it.

Preferred version: 0.1.7
CeruleanCutlass-NetKit-0.2.6 icon
CeruleanCutlass-NetKit

Shared Photon co-op transport layer for Outward BepInEx mods: channels over one relay, a hello/peer ledger, per-channel counters/heartbeat, PUN diagnostics, and a replicated-record store for consumer state mirroring.

Preferred version: 0.2.6

README

CompanionKit β€” persistent-companion mechanics for Outward mods

πŸ“– Full documentation: CompanionKit wiki page

A BepInEx library for modders. It doesn't add anything a player sees on its own β€” install it because a mod you're using (a follower, a summon, a spawner) declares it as a dependency.

It exists because Outward has no way to spawn a fresh creature at runtime: every creature is baked into the scene it lives in. CompanionKit's answer is donor-scene harvesting β€” any area is an ordinary scene you can load additively, clone a live creature out of, and unload again in about a second. DonorHarvest wraps that whole recipe, plus the audio/Photon/save guards that make it safe, behind one call. On top of that sits everything needed to turn a cloned body into a companion that follows, fights, and survives zone changes.

Requires: BepInEx 5 (Outward's Mono branch β€” see Compatibility) plus ForgeKit, DonorKit, AggroKit and NetKit β€” all four declared [BepInDependency] hard dependencies. No SideLoader.

Installing (for players)

Drop the CompanionKit folder into BepInEx/plugins/, alongside ForgeKit/, DonorKit/, AggroKit/ and NetKit/ β€” BepInEx refuses to load CompanionKit if any one of them is absent, and names the missing GUID in the log. A mod manager does this for you. On its own the kit does nothing except expose the expedition command channel and config described below.

What's in the kit

Piece What it gives you
BodyFactory Clone a live creature into a usable body: consume vs no-consume modes, the deep brain-strip that makes a clone inert and safe (its AI, colliders and networking are the things that bite), a ghost stand-in path for humanoid rigs, and visibility diagnostics/repair.
CompanionBody NavMeshAgent-driven follow: distance-banded catch-up speed (the vanilla cosmetic-pet feel), zone re-placement, leash/warp with a run-home grace window and a behind-the-owner landing, root-bone auto-calibration (the fix for creatures that drift and snap as they walk), per-rig yaw offsets, animator driving. Exposes OnAfterMove so subscribers run after the frame's pose is final, and OnWarpedToOwner after every owner-keyed reposition (reason string: Leash / CantReach / Resummon / ZoneReplace / Latched / CoverageGap). StaySpot pins the goal for a Stay order.
CompanionAnchor The invisible vanilla ally that acts as the companion's real combat body β€” a stripped clone is untargetable, so the anchor absorbs enemy detection, aggro and damage, welded to the body every frame. Owns HP and stat seams.
CompanionCombat Manual combat for a brain-stripped body: target selection (commanded > defend-the-anchor > assist-the-owner), a damage pipeline that shows up in CombatHUD, species-typed damage profiles, engage/disengage.
AttributeCapture Reads a live Character's real stats (resistances, health, natural weapon damage, movement) into a portable Core.CreatureAttributes.
DonorHarvest + its guards "Body anywhere": additively load any donor scene, clone, unload — with the audio-registry, PhotonView-collision and mid-harvest-save guards that make it survivable. The species→scene table ships embedded (DonorScenes.txt) and is overridable from BepInEx/config/.
Companion / CommandStance The aggregate whose lifetime is the bond, not the body β€” bodies get destroyed and rebuilt, standing orders and anchor state must not. CommandStance holds the Engage / Follow / Stay order (Core.CommandMode, cycled by Core.CommandCycle) and raises Changed on every transition.
ExpeditionHarvest / BodyTemplateCache Creatures from oversized scenes (region terrains, which cannot be loaded additively β€” it crashes): a real round trip through the vanilla loading screen that batch-caches dormant body templates for the rest of the session. Now DonorKit's (lane 5E-2 β€” store: DonorKit.BodyTemplateStore); these names forward, and BodyTemplateCache keeps the puppet-shaped PuppetFrom/ClearAllAndForgetMisses that are genuinely this kit's.
CompanionPetFx / CompanionPlayerFx Replicated cosmetic FX. CompanionPetFx.Flourish / its petfx store put a consumer-blessed FX on the pet body (real pet here, effigy elsewhere); CompanionPlayerFx.Flourish(ownerUid, spellKey) is the transient-only PLAYER-body twin — it plays on the owning player's own Character on every machine (ck.playerfx.cast M→Others, ck.proxy.playerfx.cast G→M, owner-bound, no pet row needed). Both resolve through a Resolver seam (Func<PetFxRequest, FxRecipe>; null = no-recipe drop) so a peer can never make this machine render an FX its own table does not bless. Their transient relay is NetBus.RegisterFireAndForget (below). TryFlourish is the same call reporting whether it fired at all (false = the kit-wide [PetFx] EnablePetFx kill-switch ate it, with no send and no drop to find in a dump); CompanionPetFx.PetFxEnabled reads that gate.
NetBus.RegisterFireAndForget / FireAndForgetChannel A TRANSIENT replicated cosmetic as one registration: NetBus.RegisterFireAndForget(castVerb, proxyVerb, authorize, apply, enabled) registers the master→others cast verb (SenderMustBeMaster, no self-echo, owner's own returning cast is a silent skip) and the guest→master proxy verb (RunOnMasterOnly; authorize(msg) returns null or the drop reason — ProxyPets.AuthorizePetOwner for a pet-row verb, AuthorizePlayerOwner for a player-bound one); channel.Fire(uid, payload) applies locally and relays/reports in a room. Payload codec and parsing stay yours (apply(uid, payload, verb, out relayPayload) returns the parse verdict — count unparseable under verb, return false so the master never relays refused bytes, and hand back the canonical relay payload). The ladder itself is pure: NetKit.Core.FireAndForgetLadder. Persistent state (a glow that must survive a late join) is the ReplicatedStore path instead — see the wiki's "Adding a replicated cosmetic".
CompanionPersistence<TState> / CompanionSaveStore<TState> / BodyAcquisition The save/re-form spine (2026-07-26): per-character files with crash-safe atomic writes, the sceneLoaded→player-ready state machine (owner guard, session-end teardown, the loud cold-load identity funnel), and the body-acquisition ladder (nearby → cache → donor harvest → ghost stand-in) with its unit-tested retry budget. Your state schema and codec stay YOURS — the kit never sees a field.

CompanionKit.Core is a game-reference-free, unit-tested compute layer: weld policy, target selection, the attribute codec, the donor table, the expedition manifest and warm policy, the persistence flow rules (ReformFlow) and harvest retry budget (HarvestPacing), plus scene/species helpers.

Expeditions β€” the creature-supply chain

Most creatures can be fetched on demand by loading their dungeon scene in the background. But open-world regions and towns are too large to load that way (it crashes the game), so creatures that live only out in a region need a different route: an expedition β€” a real round trip through the vanilla loading screen that caches body templates for every species that scene can donate. Pay it once, and those creatures are available for the rest of the session.

The tier is DonorKit's since lane 5E-2 (2026-07-26) β€” ExpeditionHarvest/ExpeditionOrchestrator/ ExpeditionManifest/StorySense and the [Expedition] config all live in DonorKit (cobalt.donorkit.cfg; see src/DonorKit/README.md + docs/wiki/kits/donorkit.md), and the template cache is DonorKit's BodyTemplateStore (the companion-body kind of its K2 store). CompanionKit remains the reference CONSUMER: CompanionKit.ExpeditionOrchestrator/ ExpeditionHarvest/BodyTemplateCache forward (ExpeditionShims.cs, plus the BodyTemplateCache facade that keeps the genuinely companion-shaped PuppetFrom and ClearAllAndForgetMisses), and the verbs below still answer on this kit's channel.

Verbs β€” CompanionKit polls BepInEx/config/ck_cmd.txt (write a line, it runs on the next poll; works while paused):

Verb What it does
expedition Status: harvest state, template-cache census, manifest, config, this launch's auto-warm decision
expedition <scene|species> Round trip to an oversized donor scene, caching a body template for every species it donates. Hands-off β€” the loading-screen gates are passed automatically. Species with an ordinary dungeon donor are refused toward that cheaper path.
templateclear / templateprobe Free the session template cache / probe a cached template
expeditionreset Clear this launch's expedition state and auto-warm decision
bodycensus Every companion body this machine currently has, and who claims it
effigydump / effigyrebind Effigy census / re-bind an effigy to its anchor
proxydump / proxykill Remote-companion proxy census / destroy one
netbusdump The ck channel's NetKit state
ckreload Re-read cobalt.companionkit.cfg without relaunching

Config β€” [Expedition] in cobalt.donorkit.cfg (moved 5E-2; entries left in this kit's cfg are inert, defaults identical): CaptureOnSceneEntry, AutoWarmAtBoot, AlwaysWarmSpecies, AutoWarmRetrySeconds, ReturnRetrySeconds, AllowCoop, GuestAutoContinue β€” the full table lives in DonorKit's docs.

Manifest β€” BepInEx/config/ck_expeditions.txt is intended to record every scene whose capture completed (the historical filename is kept across the move). Safe to edit or delete. ⚠ Known bug (docs/expedition-harvest-testplan.md V42, confirmed live 2026-07-25, open): the in-memory manifest reports captured scenes but the file is never written, so the record does not survive a relaunch.

Consumer seams (reachable through the CompanionKit.ExpeditionOrchestrator forwarders or directly on DonorKit.ExpeditionOrchestrator): ActiveSpeciesProvider (a Func<string> naming the active companion's species; feeds needed-mode warm), AutoWarmDeferred (set true in your Awake to fire AutoWarm() yourself once your own save state is loaded), and RegisterAlwaysWarmSpecies("…") (your mod's boot-warm default β€” applies while the AlwaysWarmSpecies cfg key is empty).

Example configuration

BepInEx/config/cobalt.companionkit.cfg β€” created on first launch. It has two sections; here they are in full, at their shipped defaults:

[Effigy]
## Show other players' pets on this machine as local cosmetic bodies.
EnableCompanionEffigies = true
## Upper bound on simultaneous effigy bodies on this machine.
MaxBodies = 4
## Master-side sourceless harvest when an effigy species has no cached template.
EnableEffigyHarvest = true
HarvestRetryMinutes = 10
## De-risk spikes, both default off.
PinToAnchor = false
AnchorAnimSpy = false
## Effigy weld/motion tuning.
PinLerp = 1
PinCatchUpSpeed = 12
PinSnapDistance = 12
AnchorNetLerpSpeed = 4
AnchorNetMoveSpeed = 1

[Diag]
## What to do with a companion body nobody claims (see Known issues).
OrphanBodyPolicy = Reap
OrphanBodyGraceSeconds = 90
OrphanBodyCondemnSeconds = 20

Run ckreload on the command channel to re-read this file without relaunching. Values shown are the defaults as bound in CkConfig.cs; the generated file carries each key's own # Default value: comment, and BepInEx never migrates a changed default into an existing cfg β€” if behaviour doesn't match this table, compare against those comments first.

(The [Rig] and [Expedition] sections moved to cobalt.donorkit.cfg with the 5E/5E-2 extractions β€” see DonorKit's README/wiki for their tables and example.)

Combat style + combat station (host-configured)

CompanionBody.CombatStyle (CompanionKit.Core.CombatStyle: Chase = the default, close to melee on the target; Opposite = station on the FAR side of the target so the enemy sits between the owner and the companion, then plant there). CombatStyle is stamped by the HOST mod per body (Beastwhispering reads it off a per-species table); CompanionBody.Station (a read-only CombatStationState copy) and CompanionBody.PlantedOnStation expose the live state, and CompanionKit.Core.CombatStation.CorridorAngleDeg(enemy, owner, pet) is the pure geometry (180Β° = perfectly opposite).

A body with a station KEEPS it (the deadband) while the pet is OUT of the corridor β€” in corridor = seen from the OWNER the enemy↔pet angle is < StationLineAngleDeg AND the pet is closer to the owner than the enemy β€” OR the enemy has moved < StationRestationMeters from where the station was computed; once settled (enemy within ring + 0.5 m of the pet) only the corridor test applies. The far rule breaks the deadband regardless (enemy farther than AttackRange + StationFarMeters from the pet). A recompute whose ideal is within StationArriveMeters of where the pet stands is neither committed nor counted (near rule); re-stations are rate-limited by StationRestationSeconds and capped by StationMaxRestations per engagement (one [STATION] cap fallback β€” chasing for this engagement line, then it chases). The knobs are the host's, not this kit's cfg: Beastwhispering binds them in BepInEx/config/cobalt.beastwhispering.cfg under [Combat] β€” these are the real defaults:

[Combat]
## Opposite stance: the station sits AttackRange x this past the enemy, on the line from you through the enemy.
StationRingFraction = 0.6
## Opposite stance: the pet is "in the corridor" (and the station is re-picked) when, seen from you, the enemy<->pet angle is under this.
StationLineAngleDeg = 20
## Opposite stance: the enemy must also have moved at least this far (m) from where it stood when the station was picked.
StationRestationMeters = 3
## Opposite stance: minimum seconds between re-stations.
StationRestationSeconds = 2
## Opposite stance: the pet counts as on-station (plants and holds) inside this radius (m).
StationArriveMeters = 0.8
## Opposite stance: after this many re-stations in one engagement the pet gives up the stance and plainly chases.
StationMaxRestations = 6
## Opposite stance: an enemy farther than AttackRange + this (m) from the pet forces a re-station regardless.
StationFarMeters = 3
## Opposite stance (converge or chase): a "the enemy left me" re-station must close at least this much ground (m) on the station. Two in a row that do not = the pet gives up the stance and plainly chases.
StationProgressMeters = 1

Converge or chase. The far rule assumes a re-station brings the pet to the fight; against a mob that keeps repositioning it does the opposite, because the station sits a ring PAST the enemy and so recedes as fast as the pet closes. So a far recompute wanted while the pet is still WALKING, whose fresh ideal is no closer (by StationProgressMeters) than the walk it was handed last time, counts as a stall β€” two consecutive stalls latch the cap fallback with why=stalled and the pet chases for the rest of the engagement. Larger StationProgressMeters = gives up sooner. Separately, reach carries half a metre of hysteresis (CombatStation.ReachSlackMeters, a const rather than a knob): it fires past AttackRange + 0.5, not at AttackRange.

Log contract (byte-stable [STATION], one line per transition): [STATION] set: why=first … (first station) Β· [STATION] restation #n why=<corridor|far|…> … (each committed recompute) Β· [STATION] planted … (edge: arrived) Β· [STATION] cap fallback β€” chasing for this engagement (once, also for why=stalled) Β· set(OFF-MESH: chasing): / set(UNREACHABLE: chasing): (no station this engagement). A reach re-close is a committed recompute that does NOT count toward the cap, so it prints [STATION] restation(reach) #n why=reach … and carries its own tally: every edge line and stationdump report restations=n reaches=m.

Facing + body-frame locomotion (the 2026-08-24 movement rework)

Facing is chosen by ONE ladder for both drive paths, CompanionKit.Core.LookTargetPolicy.Choose (Travel / Owner / Enemy / FacePoint; every rung documented in core/CompanionKit.Core/LookTarget.cs), and turned under vanilla's law, Core.TurnLaw: (remainingDeg + 15) Γ— TurnSpeed rad/s, clamped by the kit's deg/s cap (FollowTuning.FollowAngularSpeed 240 following / CombatAngularSpeed 540 in combat) and refused past the Β±0.7 pitch guard. FollowTuning.TurnSpeed = 0.08 (the Pearlbird's authored CharacterAI.TurnSpeed.x, applied to EVERY body, not just the bird β€” above ~37Β° remaining the deg/s cap dominates anyway, so other species mostly feel the cap) is a code tuning constant β€” there is no [Follow] section in cobalt.companionkit.cfg to bind it to yet. The old 2 m heading freeze and 0.5 m floor are gone; the only hold left is "a Travel target while the move gate says stationary" (distance-independent).

The animator is fed in the body frame when the species allows it: the per-frame displacement is projected against the smoothed visual forward (Core.LocoFrame β†’ signed moveForward + moveSide, vanilla-smoothed), so a body walking backward plays its WalkBack clip instead of a forward walk over a body sliding the other way. Core.StrafeCapability gates it: moveSide is fed only when the rig declares the parameter (LocoRig.HasSide) AND CompanionBody.AllowsBackpedal is true (stamped by the host mod per species; CompanionSettingsDefaults.AllowsBackpedal is the effigy-side virtual, default false). Every other rig is forward-only, byte-identical to before, and is forced to face its travel direction past a 60Β°/25Β° hysteresis band. Beastwhispering opts species in through the allowsBackpedal manifest axis β†’ embedded SpeciesBackpedal.txt; the player-facing override is BepInEx/config/SpeciesBackpedal.txt (same format; reloadbackpedal re-reads it live):

# BepInEx/config/SpeciesBackpedal.txt β€” per-species override of the embedded table
Pearlbird=true

Instrumentation: the [PUPPET] heartbeat carries mF=… mS=…@<animator> look=<target>; facedump and the automatic [FACE] approach began/ended: lines print the look target, the strafe-force state and both blend values; [FACE] approach spot refused: marks the (now hysteretic, 1.5 m arm / 2.0 m release) "owner between pet and spot" refusal.

Other config surfaces:

  • Command channel β€” BepInEx/config/ck_cmd.txt (write a verb, it runs on the next poll).
  • Config-override data table β€” BepInEx/config/DonorScenes.txt overrides the embedded speciesβ†’scene table (Species=Scene1,Scene2,…, repeated keys append).
  • Session manifest β€” BepInEx/config/ck_expeditions.txt (records completed captures; safe to edit/delete).
  • Committed shared overlay β€” config/shared/cobalt.companionkit.cfg.overlay (the workspace's pinned-key overlay applied by scripts/sync-config.py).

Consuming the kit

<!-- your .csproj β€” both kit DLLs ship from the kit's own folder, never copy them into yours -->
<ProjectReference Include="..\CompanionKit\CompanionKit.csproj" Private="false" />
<ProjectReference Include="..\..\core\CompanionKit.Core\CompanionKit.Core.csproj" Private="false" />
[BepInPlugin(GUID, NAME, VERSION)]
[BepInDependency(CompanionKit.Plugin.GUID, CompanionKit.Plugin.VERSION)]   // VERSION = the min-version floor, see kits/versioning.md
public class Plugin : BaseUnityPlugin
{
    CompanionHost _host;

    void Awake()
    {
        // ONE host handle per consumer β€” it carries your logger + your ICompanionSettings and
        // derives your log-tag suffix from the GUID ("cobalt.hireling" β†’ "[ANCHOR/HIRE]" etc.;
        // pass tagSuffix: "" for plain tags, combatTag: "…" to rename the combat log noun).
        // The old CompanionRuntime.Log / DefaultSettings globals are obsolete one-wave shims:
        // they had no ownership rule, so with two consumers installed BepInEx load order decided
        // whose logger got all kit output. Don't set them.
        _host = CompanionHost.Create(GUID, NAME, Logger, new MySettings());

        // ⚠ MANDATORY since 2026-08-01 (E8d-A): register a claim source for every body your mod
        // owns. The kit runs a convergent orphan-body reaper ([Diag] OrphanBodyPolicy, default
        // Reap): any live CompanionBody NO claim source owns is warned about and then DESTROYED
        // (~110s: 90s grace + 20s condemn) β€” intended as the self-heal for the confirmed
        // leaked-body class. ⚠ The reaper itself is NOT live-verified: the fix wave landed
        // 2026-08-01 and its retest (guest-pets-testplan E8d-A, T1-player) has not been run, so a
        // destructive default is shipping unproven.
        //
        // ⚠ UPDATE 2026-08-02 (HYENAVANISH): a consumer that skips this line no longer gets its
        // body reaped β€” the reaper now FAILS SAFE and disables itself entirely when no consumer
        // has registered (that exact pairing shipped and destroyed a player's pet: Beastwhispering
        // 0.2.1 predated the census while CompanionKit 0.4.2 carried the reaper). But skipping it
        // still costs you the census, the reaper's self-heal for genuine leaks, and every
        // `[REAPER]`/`[CENSUS]` diagnostic β€” so it remains MANDATORY.
        BodyCensus.RegisterClaimSource(() => new[] { _myCompanion?.Body });
        // ⚠ MANDATORY since 2026-08-02 (HYENAVANISH): declare which CompanionKit consumer contract
        // you were BUILT against. CONSUMER_CONTRACT is a const, so it inlines here at YOUR build
        // time β€” that is what makes a you-and-CompanionKit-published-from-different-commits skew
        // visible in the logs instead of as a destroyed companion. See ConsumerContract.cs.
        ConsumerContract.Declare(NAME, CompanionKit.Plugin.CONSUMER_CONTRACT);
    }
}

Lifecycle:

var companion = new Companion(_host);     // settings ride the host; per-companion override = 2nd arg
companion.Anchor.OnAnchorDeath          += () => { /* your downed policy */ };
companion.Anchor.OnAnchorCriticallyHurt += () => { /* warn the player */ };
// (wire these BEFORE AdoptBody, or accept the one-time "[COMPANION] note: no anchor consequence
//  events wired" line β€” a downed companion silently respawns after AnchorRespawnSeconds otherwise)

// acquire a body (any path):
CompanionBody body = BodyFactory.BuildPuppet(src, owner, consume: true);   // consumes the wild one
CompanionBody body = BodyFactory.BuildPuppet(src, owner, consume: false);  // the wild one survives
// or DonorHarvest.HarvestChain(scenes, species, owner, b => ...)          // no wild source needed
// or the ghost stand-in trio: SpawnGhostActive / GhostVisualReady / FinishGhostPuppet

// weld the anchor to the body and (optionally) enable combat; humanoids may ask for the
// draw/sheathe flourish here (an AdoptBody parameter, not a field to poke β€” Wire() is internal):
CompanionCombat combat = companion.AdoptBody(body, enableCombat: true, weaponPosture: false);
combat.OnEnemyDefeated += ...;            // your kill-credit policy

// on your tick (Companion.Tick includes the bodiless branch β€”
// ICompanionSettings.BodilessAnchor decides destroy-vs-upkeep while no body exists):
companion.Tick(owner, host);

// stat seam (idempotent, re-push any time):
companion.Anchor.ApplyVitals(maxHealth);
companion.Anchor.ApplyCreatureStats(effectiveAttributes); // null = clear back to defaults
combat.SetAttackProfile(effectiveAttributes);

// orders:
companion.Stance.CommandEngage(target);   // priority 0, any range
companion.Stance.CommandDisengage();      // = Follow: passive + run-home grace
companion.Stance.CommandStay();           // passive + hold the spot (CompanionBody.StaySpot, owned by
                                          //   CompanionCombat); ANY warp to the owner β€” leash, zone
                                          //   re-place, Resummon β€” ends it back to Follow
                                          //   (CompanionBody.OnWarpedToOwner β†’ Stance.Changed)

// persistence: the LIFECYCLE is the kit's (CompanionPersistence<TState> + CompanionSaveStore<TState>
// + the BodyAcquisition ladder β€” see the wiki's Persistence section), the STATE is yours: your
// schema/codec (CreatureAttributes.Flatten/Parse is one save-safe string), your save triggers.

Scope and caveats

  • The API is instance-based, but one companion per owner is what's actually been proven in play. Several at once should work and is unexercised β€” the vanilla summon slot is one-per-player, and multi-anchor aggro is untested.
  • The kit ships the attribute codec, every body-acquisition path and (since 2026-07-26) the persistence lifecycle β€” but no save schema: consumers own their format via the store's encode/decode hooks (BW's bw_pets_<uid>.txt, Hireling's hl_folk_<uid>.txt).
  • Special attacks stay consumer-side; CompanionCombat.DealDamage is the public seam.
  • Beast bodies are the well-trodden path. Humanoid bodies use a different (ghost stand-in) route that should work but has had far less mileage.

Compatibility

Outward must be on its Mono Steam beta branch, not the default IL2CPP build. If your game runs but no BepInEx mods load and there's no crash log, this is almost always why (Properties β†’ Betas β†’ select mono in Steam).

License

Apache License 2.0 β€” see LICENSE. You may use, modify, and redistribute this kit (including in commercial mods) provided you keep the copyright/license notice; see the license text for the full terms.

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