You are viewing a potentially older version of this package. View all versions.
CeruleanCutlass-SpawnKit-0.6.2 icon

SpawnKit

[ALPHA] Runtime enemy spawner for Outward: spawn any donor-scene creature beside the player as a real vanilla hostile, with full AI, aggro, combat, and vanilla death/loot. Co-op supported. Early software β€” see README for known issues.

Date uploaded 2 weeks ago
Version 0.6.2
Download link CeruleanCutlass-SpawnKit-0.6.2.zip
Downloads 97
Dependency string CeruleanCutlass-SpawnKit-0.6.2

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.13 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.13
CeruleanCutlass-DonorKit-0.1.9 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.9
CeruleanCutlass-CompanionKit-0.4.29 icon
CeruleanCutlass-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.

Preferred version: 0.4.29
CeruleanCutlass-NetKit-0.2.10 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.10

README

SpawnKit β€” spawn any creature, anywhere

πŸ“– Full documentation: SpawnKit wiki page

⚠️ Alpha. This is early software: the core spawn/despawn/AI loop is solid and well-tested, but you will hit rough edges (see Known issues below) β€” roughly a quarter of the creature roster doesn't spawn yet, co-op is live-proven except that guest-side creature bodies don't locomote, and there's an occasional cosmetic terrain glitch. Back up saves you care about before experimenting.

Press F11, pick a creature, spawn it beside you.

SpawnKit gives you a real vanilla hostile β€” not a dummy or a stand-in. It wanders, it aggros, it fights, it drops its normal loot, and it dies like the wild version, because it is the wild version. Spawn a pack of hyenas to train on. Drop a Tuanosaur next to a bandit camp and watch them fight each other. Restock a region you've already cleared out.

Spawns are ephemeral: they never enter your save file, and they vanish when you leave the area.

Requires: BepInEx 5 (Outward's Mono branch β€” see Compatibility), plus ForgeKit, NetKit, DonorKit and CompanionKit β€” all four declared [BepInDependency] hard dependencies. If you install through a mod manager these come along automatically.

Installing

Install from Thunderstore and the dependencies come with it. Installing by hand: drop all five folders β€” ForgeKit/, NetKit/, DonorKit/, CompanionKit/, SpawnKit/ β€” side by side into BepInEx/plugins/. If BepInEx logs a dependency error and refuses to load SpawnKit, one of the other four is missing; the log line names which.

Using it

Launch the game, load a save, and press F11 for a draggable window listing every creature you can spawn.

  • ● means the creature is ready β€” it spawns instantly.
  • β—‹ means it's cold: the first spawn pauses 1–6 seconds while SpawnKit fetches its body (see How it works). Click Prewarm to pay that cost up front instead.
  • Set a count, hit Spawn, and use Despawn All / Kill All to clean up.

The key is rebindable ([Menu] MenuKey; Backslash is the classic debug-menu choice), and the menu closes on Esc, on F11 again, or when you open a vanilla menu.

Settings

BepInEx/config/cobalt.spawnkit.cfg, created on first launch:

Setting Default What it does
[Spawner] Enabled true Master kill-switch. Off = no new spawns (cleanup still works).
AllowSpawnInRoom false Only consulted when a peer doesn't handshake a compatible SpawnKit (unmodded peer, or EnableCoopSpawns=false) β€” with every peer modded, in-room spawns just work. true = spawn anyway: modded peers still mirror, but un-handshaken ones see a ghost that spams their log and locks the host in unwinnable combat against their invulnerable replicas. Solo/offline play never consults it.
MaxActiveSpawns 8 Cap on live spawns at once.
SpawnDistance 5 How far from you they appear, in meters.
MaxCachedTemplates 12 How many creature bodies stay in memory. Higher = more instant spawns, more RAM.
DefaultCorpsePolicy Vanilla Vanilla leaves the corpse and its loot. NoBody deletes the corpse (and its loot) after death.
[Menu] EnableMenu true The F11 menu.

Example configuration

BepInEx/config/cobalt.spawnkit.cfg β€” created on first launch. Excerpt (defaults, all four sections):

[Spawner]
Enabled = true
AllowSpawnInRoom = false
MaxActiveSpawns = 8
SpawnDistance = 5
MaxCachedTemplates = 12
## Vanilla leaves the corpse + loot; NoBody deletes both after death.
DefaultCorpsePolicy = Vanilla
DefaultCorpseLingerSeconds = 0
ForceLootableEnabled = true

[Menu]
EnableMenu = true
## The spawn-menu key; Backslash is the classic debug-menu choice.
MenuKey = F11

[Expedition]
EnableExpeditions = true
ConfirmMenuExpedition = true

[Coop]
EnableCoopSpawns = true
DespawnOnMirrorFailure = true
MirrorQueueTimeoutSeconds = 20
VerboseNet = true
HeartbeatSeconds = 30

The command channel is BepInEx/config/SpawnKit_cmd.txt (see Without the menu below). The species→scene roster comes from DonorKit and is overridable by dropping a BepInEx/config/DonorScenes.txt (see How it works). The workspace's pinned-key overlay is config/shared/cobalt.spawnkit.cfg.overlay, applied by scripts/sync-config.py.

Without the menu

Everything is also driven by writing a line into BepInEx/config/SpawnKit_cmd.txt β€” it runs on the next poll, even while the game is paused. Useful for scripting or if a keybind conflicts.

Verb What it does
spawn <species> [count] Spawn 1 or more beside you. Names may contain spaces: spawn Armored Hyena 3.
despawnall [kill] Remove every spawn. kill gives them a real death (animation + loot) instead of deleting them.
spawnex <species> [dist=N] [life=N] [faction=Name] [owner=tag] [body=vanilla|none] [linger=N] [keepquest] One spawn with the full option surface.
spawnlist / spawndump List spawnable creatures / show what's live right now.
spawnprewarm <species> / spawnprep <scene> Pay the body-fetch cost now so later spawns are instant.
spawnclearcache Drop cached bodies (frees memory; next spawn re-fetches).
skcoopdump Co-op state on this machine: handshakes, mirrored spawns, message counters (SpawnKit's mirror ledger + NetKit's netdump census). (More co-op dev verbs under help: skinject/skfail/skdrop/skresync/skgone/skstream.)
despawnowner <tag> Remove only the spawns carrying that owner= tag.
spawnmenu Toggle the spawn menu window from the channel (same as F11).
spawnexpedition <species|scene> / expeditionreset Round-trip to an oversized donor scene to cache templates / clear this launch's expedition state.
skfollow <uid> Make a live spawn follow you β€” movement/AI debugging.
lootprobe / bonedump Inspect a spawn's loot table / its skeleton rig.
postrace <name|uid:xxx> [seconds=20] [hz=5] / postrace off Per-sample [POSTRACE] line on one live spawn β€” y, CC grounded, Falling, totem push, agent on/onMesh/next.y, ragdoll, clip, colliders overlapping its CharacterController (the BUG-SPAWNLAUNCH discriminator).
selftest Sanity-check the install; look for [SELFTEST] … DONE in the log.

⚠️ Known issues β€” read this bit

Some creatures don't work. Roughly a quarter of the rows in the list fail to spawn β€” their body can't be found in the scene the table expects. You'll get a clear message in the log rather than anything harmful. This is being chipped away at.

Co-op works when everyone has the mod β€” with one live-confirmed defect. The host spawns; every guest running SpawnKit mirrors the creature as a real networked enemy. The handshake, the host-only refusal, the mirror, and death/loot were live-PASSed on a real two-box session (2026-07-18). ⚠ But on the guest the rendered body does not locomote: the mesh sits still β€” typically stuck on terrain β€” while the health bar tracks the host's live position (docs/spawnkit-testplan.md V22, live FAIL 2026-07-18). Two fix waves have been built and the third session read "nearly closed", but the close signal has never been observed, so treat guest creature movement as broken until V22 is PASSed. Guests still can't initiate spawns (host-only, refused cleanly). The handshake protects you: if any player in the room doesn't have SpawnKit, in-room spawns refuse and the log names who β€” because to them the spawn would be an invisible ghost (log spam for an entity they can't see, and it wails forever on their invulnerable replicas, locking the host in combat). [Spawner] AllowSpawnInRoom=true overrides that refusal if you accept those players seeing nothing; [Coop] EnableCoopSpawns=false turns the whole co-op layer off (back to refuse-in-room). skcoopdump shows the co-op state on either machine.

Transport (NetKit cutover, 2026-07-18). The co-op wire is no longer SpawnKit's own relay: SpawnNet is now a thin facade over NetKit's shared sk channel. NetKit owns the one Photon relay (NK_Bus on CharacterManager's view), the single handshake (nk.hello) + peer/absence detection, the join hooks, the heartbeat ([Net] HeartbeatSeconds now drives the [SKNET] hb line), and the PUN diagnostics β€” SpawnKit keeps only the spawn semantics (the sk.spawn/sk.gone/ sk.ack/sk.fail/sk.resync verbs, their codecs, and the mirror-gate policy). Everything above is behaviour-preserving; the visible change is that per-message logging now rides [Net] VerboseNet (NetKit's .cfg) and netdump (or skcoopdump, which folds it in) is the transport census. Cutover: new builds speak NK_Bus only β€” an old SpawnKit build (SK_Bus) looks unmodded to the new handshake and is refused co-op, which is fine because the kits ship as one bundle.

A rare visual glitch. Very occasionally, after a lot of spawning in one session, a patch of ground can go invisible (you can still walk on it) β€” changing zone and coming back repairs it.

How it works

Outward has no way to create a creature from nothing β€” every creature is baked into the scene it lives in. So SpawnKit borrows one: it quietly loads the scene a species lives in, copies a live creature out of it, gives the copy a fresh identity, drops it on solid ground near you, and unloads the scene again. That's the 1–6 second pause on a cold spawn, and it's why the result behaves perfectly β€” it's the same creature the game itself would have spawned, not an imitation.

The creature roster comes from DonorKit (DonorSceneTable + the embedded DonorScenes.txt), which owns the table mapping each species to the scenes it can be found in. spawnlist prints it. You can add or fix an entry without rebuilding anything by dropping a DonorScenes.txt into BepInEx/config/:

Hyena=Emercar_Dungeon5,ChersoneseDungeonsSmall
Alpha Tuanosaur=Hallowed_Dungeon3,HallowedMarshNewTerrain

Region-only creatures need an extra step. Open-world regions and towns are too big to load in the background β€” trying it crashes the game, so SpawnKit refuses to. Creatures that live only out in a region (Pearlbird, Torcrab, Coralhorn and friends) therefore can't be fetched the usual way; their rows are labelled (expedition-only) and a cold spawn fails with a clear message. To stock them, write spawnexpedition <species> into BepInEx/config/SpawnKit_cmd.txt (or expedition <species> into DonorKit's own channel): DonorKit takes you through the loading screen and back, and from then on that creature spawns like any other.

Troubleshooting

Symptom What it means What to do
No spawnable species matches '<x>' Not in the table Check spawnlist; add a line to BepInEx/config/DonorScenes.txt
has NO viable donor (all candidates oversized) A region-only creature, not stocked yet Run expedition <species> (see above)
First spawn of a creature pauses, later ones don't Normal β€” that's the body fetch Use spawnprewarm to pay it early
No mods load at all, no crash log Wrong Steam branch See Compatibility

Room-wide warm mirror (co-op)

A guest that has no body template for a spawned species pays a full additive donor load to mirror it β€” 3.6 s, 12.1 s and 14.7 s measured in the field, against Photon's 10 s disconnect timeout. The guest-side backstop can refuse such a spawn; only the host can avoid choosing it. So every peer now publishes its own warm set and the host spawns from what the room can actually mint.

WarmMirror.cs registers one NetKit ReplicatedStore β€” warm, on the sk channel, PeerOwned authority, one row per peer keyed by the unforgeable sender actor, RefreshSeconds=30, FlushOnPeerReady, ClearOnRoomChange, ReapAbsentActors. The payload is WarmSetWire's canonical bytes (warm species Β· dead ends Β· remaining harvest budget), which the store change-latches on, so MarkDirty can be called from anywhere and the wire stays quiet. Dirty sources are events (TemplateKind<T>.Changed, a finished warm, scene-ready, room change) plus a 2 s verification poll β€” the events are latency, the poll is correctness, because the expedition cache has no change seam. There is no sk.warmed: the row is the confirm. The local set is computed over a bounded candidate universe (spawn-store keys + expedition cache + anything RequestRoomWarm named), never the whole species table.

Decisions are pure: SpeciesRoomPolicy (Decide / Intersection; Unmodded and HelloedNoRow never veto, dead-end or zero budget is terminal) and WantBook (per-(actor, species) deadline, attempts, doubling backoff). Spawner exposes CanMintRoomWide / RoomWarmDecision / RoomWarmSnapshot / RequestRoomWarm; EnemySpawner.Preflight vetoes with FailReason.SpeciesColdOnPeer and re-asks at the broadcast point, because a mint takes seconds. SpawnOptions.IgnoreRoomWarm is the per-call escape. A cold-unsafe arriving despite a satisfied gate is a lost eviction race: the first one for a uid is forgiven ([SKNET] cold-unsafe DESPITE room gate), an sk.want goes out, and the spawn is re-flushed once the guest's set grows ([SKNET] re-flushed …) under a ~60 s despawn safety net β€” see SpawnNet.OnFail.

Wire: sk version 0.5.0, exact ordinal match. New verbs sk.warmset (announce), sk.warmclr (registered, never sent β€” an empty set is a valid set) and sk.want (master β†’ one actor, SenderMustBeMaster).

Verbs: skwarmdump β€” local set / dead ends / budget / last publish, every peer row with status and age, the room-wide intersection, and the want book. force as a trailing token on spawn and spawnex sets IgnoreRoomWarm for that one call (deliberately not a config).

Config lives in BepInEx/config/cobalt.spawnkit.cfg:

[Coop]
RoomWarmMode = RoomDegraded          ## Local | RoomDegraded | RoomStrict
WarmSetPublishSeconds = 1
RoomWarmRequestCap = 3
RoomWarmRequestTimeoutSeconds = 30
RoomWarmRequestMaxTimeoutSeconds = 120
RoomWarmRequestAttempts = 3

Built 2026-08-20, not live-verified β€” docs/guest-mirror-harvest-testplan.md GM10–GM19.

Compatibility

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

For modders

SpawnKit is also a library. Reference it and declare the dependency:

[BepInDependency(SpawnKit.Plugin.GUID)]

Then spawning is one line:

SpawnHandle h = Spawner.Spawn("Hyena");

Everything else is optional β€” per-spawn customization rides a SpawnOptions, and the returned SpawnHandle is your window onto the creature's lifecycle:

Spawner.Spawn("Alpha Tuanosaur",
    new SpawnOptions
    {
        Faction = Character.Factions.Tuanosaurs, // null = the donor's own faction
        Distance = 12f,                          // ring-probe radius (clamped 1–50m)
        // Position = somewhereExact,            // skip the ring probe entirely
        LifetimeSeconds = 120f,                  // auto-despawn clock (null = forever)
        Corpse = Core.CorpsePolicy.NoBody,       // destroy the corpse after death (null = the
                                                 //   [Spawner] DefaultCorpsePolicy config;
                                                 //   NoBody takes the LOOT with it β€” see traps)
        CorpseLingerSeconds = 8f,                // death-anim time before the NoBody destroy
        OwnerTag = "mymod",                      // namespace your spawns (see DespawnAll)
        // StripQuestEvents = false,             // default true β€” leave it unless you KNOW
    },
    onReady: handle =>
    {
        if (!handle.IsAlive) { /* handle.FailReason says why */ return; }
        handle.OnDied += _ => DropReward();
        handle.OnDespawned += _ => CleanupMarker();
    });

The full surface (SpawnKit.Spawner, all static):

Call What it does
Spawn(species, options?, onReady?) Spawn ONE creature; returns a SpawnHandle immediately (Pending β†’ Alive/Failed; sync refusals return already-Failed with a FailReason). One call = one creature β€” loop for more; a cold-species loop still costs one fetch (in-flight acquires dedup).
Despawn(handle, kill=false) Remove one spawn: kill = real damage pipeline (death anim + loot, β†’ Died); silent = destroy (β†’ Despawned). Cancels a still-Pending handle.
DespawnAll(ownerTag=null, kill=false) Sweep by owner tag. null = EVERYTHING β€” that's the operator verb; mods should pass their own tag.
Prewarm(species, onDone?) / IsPrewarmed(species) Pay/check the body fetch off your hot path.
IsExpeditionOnly(species) True when a species can only be stocked by an expedition (see How it works) β€” check before promising a boss fight.
Active(ownerTag=null) Snapshot of tracked handles (Pending + Alive).
SpeciesKeys() The spawnable species roster.

Trap table β€” read before shipping on this

Trap Rule
handle.Character can go Unity-null under you (scene unload, despawn) Check handle.IsAlive before every use; never cache the Character across scene loads.
Spawns are ephemeral by design Scene unload = OnDespawned, and saves skip SK_ UIDs both directions. Don't build persistence on top.
A cold species blocks ~1–6s before onReady Prewarm on your init path if the spawn must feel instant.
MaxActiveSpawns is global across all consumers (pending spawns count too) Tag your spawns (OwnerTag) and never call DespawnAll(null) from a mod β€” you'd sweep other mods' spawns.
Callbacks run on the main thread inside the watch tick Keep them cheap. A throwing callback is isolated (swallowed + logged) β€” it won't break SpawnKit, but you won't get a second chance either.
Co-op clients always get Failed/NotMaster β€” guests never initiate Design your feature so only the host drives spawning. Host-side in-room spawns work (Phase 3) when every peer handshakes; otherwise Failed/PeersNotReady names the unmodded peer, or Failed/RoomWithGuests with [Coop] EnableCoopSpawns=false.
An unmodded guest in the room blocks in-room spawns (PeersNotReady) That's the feature: a spawn would be an invisible ghost to them. AllowSpawnInRoom=true overrides, knowingly. NB their game logs one red PUN "has no method" line per handshake message we send β€” harmless, bounded.
A guest's first mirror of a species runs its own ~1s donor harvest Until the mint lands, the guest logs bounded PUN "no such view" warnings (~10/s, counted in skcoopdump) β€” [Coop] MirrorQueueTimeoutSeconds bounds the worst case. Prewarm on both machines if it matters.
Flipping EnableCoopSpawns off mid-session with live mirrors stops sk.gone Guests keep their replicas until their next scene change. Despawn everything first, then flip.
Late-join / scene-return flush covers Alive spawns only A guest joining (or re-entering a scene) never sees pre-existing corpses; and a NoBody corpse removal can destroy a loot container a guest has OPEN (same shape as a scene unload mid-loot β€” vanilla tolerates it).
A dead spawn's viewID release is deferred until its corpse GameObject is destroyed photondump listing manually-allocated ids with no registered view right after a kill is NORMAL while the corpse persists β€” they clear on the watch tick after the corpse (or its scene) goes. Only ids that survive the corpse are leaks.
StripQuestEvents = false on a quest-flagged creature fires real quest events on death The default (true) exists because live donors really do carry QuestEventOnDeath β€” leaving it on is what keeps spawns from mutating quest state.
CorpsePolicy.NoBody destroys the loot bag with the body The corpse GameObject IS the loot container β€” never combine NoBody with a drop-loot expectation.
Ring geometry (step angles, elevation band, navmesh snap) is not configurable By design. If you need exact placement, pass SpawnOptions.Position and own the spot yourself.
The species roster and creature supply are DonorKit's, not SpawnKit's An expedition-only species fails cold spawns until an expedition stocks it.

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

SpawnKit changelog

0.6.2 β€” 2026-09-05

  • Static-analysis wave 5: release prep for Beastwhispering 0.2.18 + DangerousRoads 0.1.13
  • SpawnKit: materialize a clone's unspent StartingPouchItems weapons at mint (Case B upstream, measured)
  • SpawnKit: MeleeSidearm review fixes β€” right-hand-only, verified swaps, lifetime cap, kill switch
  • SpawnKit: MeleeSidearm β€” a bow-holder stuck in the melee AI state gets its sidearm forced
  • SpawnKit: restamp WeaponType after equip and every animator Rebind
  • ForgeKit 0.4.13 / DonorKit 0.1.9 / SpawnKit 0.6.1: session-3 fixes
  • Merge branch 'worktree-agent-ad0ca166327fb8804' into integrate/session3-fixes
  • Merge branch 'worktree-agent-a92939b0a3b7c1d30' into integrate/session3-fixes
  • Fix release-blocking version skew: NetKit 0.2.10, Beastwhispering 0.2.17
  • SpawnKit: N2 review fixes β€” mint gates need both authority terms
  • DonorKit/ForgeKit: PRE-1/PRE-2 harvest resource census + liveness heartbeat
  • SpawnKit: N2 authority reads use raw room-host, not flipped master
  • SpawnKit 0.6.0 / DangerousRoads 0.1.10: pre-session follow-up close-out (B1 knobs, quiet-roads visibility, batched warm asks, mode-faithful draw condition)
  • Merge branch 'main' into worktree-agent-a5a0898794ba1be15
  • SpawnKit+docs: PR3-mode review nits β€” single mode read in CanEventuallyShowRoomWide; PR3-5 FAIL wording excludes the draw-to-spawn race
  • SpawnKit: B1-nopark SpawnOptions.NeverFarPark, the event-owned mark
  • SpawnKit+DangerousRoads: PR3-mode draw-time predicate mirrors the spawn gate per RoomWarmMode (dead-end sits out at defaults)
  • SpawnKit 0.5.5: MP-hardening sweep close-out (L3b, B5, B8, B12, PR1, PR5, L3c, C2, C1)
  • Merge branch 'main' into worktree-agent-a54a97473c5bb76f6
  • SpawnKit: PR5 swap leg β€” the placeholder hot-swaps to the real body, strictly serialized
  • … and 23 more (see git history)

0.6.2 β€” 2026-09-04 (live-tested on legion)

Two live-found P0s off the 2026-09-04 session.

  • WeaponStamp: spawned armed humanoids swing again. Every SpawnKit-spawned armed humanoid played the unarmed punch clip and dealt zero damage β€” Cobalt, live: "the bandit was doing the punch animation with the axe drawn and he did no damage to my pet", across multiple species. Outward has no animator override controllers: one humanoid controller picks punch-vs-weapon clip sets from a single animator int WeaponType, written ONLY by Character.WeaponChanged(), which is triple-guarded and silently no-ops on guard failure with no retry. Weapon damage rides HitStarted animation events on the ARMED clips, and the unarmed fist detector is assigned only when CurrentWeapon == null β€” so weapon-bound + unarmed-clip means the blade sweep never arms AND the fist path is locked out. One cause, both symptoms, and every game-state field read correct because WeaponType is not game state. grep WeaponChanged src/SpawnKit was zero hits while SpawnKit called Animator.Rebind() (which resets every parameter) in three places after equipping. We had already fixed this once β€” GhostPeer's Ghost.Restamp (GH19, same verbatim symptom); the knowledge never crossed kits. Now re-stamped after the equip and after every Rebind, with a deferral queue so an early stamp waits for the animator instead of being discarded. Boot assertion proves both DonorKit guards' prefixes actually attached β€” a mismatched Harmony patch neither applies nor errors. Live: punch clips 13 -> 0, armed attack clips 0 -> 13, beasts unregressed.
  • MeleeSidearm: a bow-holder stuck in the melee AI state. AISCombatMelee.UpdateAttackCheck and TryStartAttackPattern are both gated on CurrentWeapon is MeleeWeapon; a ProjectileWeapon is not one, so a bound bow in the melee state is frozen forever. Positioning, facing and dodge carry no weapon-class gate β€” which is exactly why three observed raiders evaded while never swinging, with the merchant's HP flat for minutes. Now detected and swapped to a right-hand melee sidearm, keyed on the observable end state rather than on why the bow is bound (the upstream β€” a failed EquipRequiredWeapons swap on a clone β€” remains inferred and is instrumented, not assumed). Post-equip verification before an attempt is spent, a sticky lifetime cap so an equip tug-of-war cannot recur per episode, vanilla's own InLocomotion && !IsCasting guard, and inactive candidates skipped (a bag deactivated by the ItemUidGuard leaves inactive weapons visible to the scan).
  • MirrorAuthority (N2): a SplitScene authority flip made GuestReplicas read the getter SplitScene postfixes and silently clear the host's warm wants; the host then retired the species for the session. Authority reads now use the raw room-host β€” EXCEPT the two mint gates, which require the conjunction (raw says guest AND vanilla will convert the replica), because raw alone there would mint a live AI copy of the master's enemy on its own viewID.
  • New: [Spawner] MeleeSidearmFix (default true, live) gates both the sidearm poll and the mint-time pouch re-registration. spawndump gains wpnType= offHandType= wpnClass= pouchWpn= pouchReg= sidearm= swapState= tries= lifeswaps= β€” wpnType is the field that had never been read and which root-caused the punch bug.
  • Retests: spawnkit-testplan.md (BUG-HUMANOIDPUNCH PASS, CB1-CB8), guest-mirror-harvest-testplan.md.

0.6.0 β€” 2026-08-30 (built, not live-verified)

The pre-session follow-up wave: three additive surface pieces (hence the MINOR bump).

  • SpawnOptions.NeverFarPark, surfaced on the handle (public setter β€” ownership is a phase; the owning event releases the mark in Teardown and on the torn-down early-outs): an event can mark a creature it owns so a consumer's own distance-based park pass leaves it alone. SpawnKit does not park anything itself β€” it carries the flag for consumers that do (DangerousRoads sets it on patrol troglodytes and stranger mobs; a parked member counted as neither alive nor dead, which is how a patrol could report alive=0 dead=0). Retest: dr-farcache-testplan.md FC15.
  • Spawner.CanEventuallyShowRoomWide(key) + pure SpeciesRoomPolicy.CanEventuallyShow: the draw-time question ("could a spawn ever get past the spawn gate under the operator's RoomWarmMode?") as distinct from CanMintRoomWide's "cleanly, now". Under the default RoomDegraded it refuses only a TERMINAL dead end β€” exactly what the B12 spawn gate refuses β€” so a card can no longer be drawn eligible and then refused at spawn. Retest: dangerous-roads-testplan.md PR3-5.
  • Batched warm asks bind RoomWarmRequestCap once. The cap is per-peer PER CALL, and DR's old per-key loops multiplied it (11 asks past a cap of 3); DR now sends one list per refusal/tick. The truncated tail moves up once the head resolves on that peer. Retest: guest-mirror-harvest-testplan.md PR2-BATCH1.

0.5.5 β€” 2026-08-30 (built, not live-verified)

The co-op guest-mirror half of the MP-hardening sweep: degrade, never abort. A guest that cannot yet show a species gets something harmless to stand in for it and a route back to the real body, instead of a refusal that either wedges or deletes.

  • The destroyed-cache re-harvest is capped, and the mint says where the body really came from (L3b). The guest mirror's donor-chain cap was passed as wasCached ? 0 : cap, and 0 means UNCAPPED in DonorHarvest β€” so precisely the case that needed the cap most (a cached template destroyed under us, forcing a re-harvest) ran without one. The cap now always applies, and the src= label on the mint is decided from the donor cycles actually spent: a re-harvest reports the new cache-lost-reharvest instead of falsely claiming src=cache. Policy in Core.MirrorAcquirePolicy; row GM29.
  • An sk.gone inside the mint dwell no longer fires a bogus sk.fail(mint-inactive) (B5). The 2-frame dwell that lets a freshly minted replica settle would report the mint inactive when the master had simply retired the spawn mid-dwell β€” the very reason this machine had removed the row itself. The resume now checks the registry row first and ends silently in that case (Core.MintResumePolicy). The remaining inactive-within-dwell warnings (GuestReplicas and both EnemySpawner siblings) now state what was actually observed and offer the duplicate-UID guard as a checkable hypothesis rather than a verdict. Row GM30. Audited alongside it: SpawnNet.OnFail carries no retry or blacklist keyed on failure reason, so the field harm here was log noise and mirror-ledger pollution, never a double-despawn.
  • A kept cold-unsafe spawn records its re-flush obligation (B8). SpawnNet.OnFail now writes the Forgiven-ledger re-flush obligation for EVERY cold-unsafe report that leaves the spawn Alive β€” degraded and forced spawns included β€” paired with a RequestRoomWarm. RoomGateOk no longer gates the plumbing, only the forgiveness verdict. The despawn decision is untouched by this change. Row GM31.
  • The warm-request deadlock: terminal dead ends refuse, and guests replay their own refused spawns (B12). RoomDegraded no longer spawns past a TERMINAL DeadEndOnPeer β€” the degraded branch reads warm.Reason through SpeciesRoomPolicy.IsDeadEnd (the verdict is downgraded by design, so the reason is what still carries the truth) and refuses both at preflight and at the broadcast-time re-check. And the new bounded per-uid Core.ColdPendingBook (16 rows) keeps a cold refusal's (uid, species, wire message), so the species' warm completion replays the guest's OWN spawns into the queue with no dependence on the host's Forgiven ledger. A gone forgets its pending uid, a room change clears the book, and IsKnown counts pending rows so the host's re-flush dedups against them. Rows GM32/GM33.
  • cold-busy β€” a refusal that means "not right now", not "never" (PR1). New benign wire reason covering RefuseCombat + RefuseBudget + RefuseCeiling (Core.MirrorFailPolicy.ColdRefusalReason, unit-pinned); RefuseAdmission and anything unknown stay cold-unsafe and destructive. The master's benign leg records the Forgiven obligation and requests the warm for a cold-busy report. In ShouldDespawnColdUnsafe the !roomGateOk clause is GONE: it is now one grace per (uid, actor) for every spawn, degraded included β€” degraded is precisely the mode that expects cold guests. The 27-line Phase-B rationale beside it was rewritten in the same change to carry that ratified override and to say why once-only still stands; the roomGateOk parameter is retained for the log line and call-site compatibility. Rows GM34/GM35.
  • Placeholder bodies: a cold refusal mints a stand-in bound to the leased viewID (PR5, degrade leg). New [Coop] PlaceholderBodies (default true). Instead of nothing standing where the master says a creature is, the guest mints a CompanionKit ghost stand-in on the leased viewID (Views.Neutralize β†’ pv.viewID = m.ViewId, group restored to 0 so it receives), with the AI stripped inside the INACTIVE window β€” EditorCharacterAILoadAI destroyed before IsAI's first latching read β€” a SKPH_ UID (so SK_ stays free for the swap), the master's faction stamped after activation (ChangeFaction sync:false), and sk.ack(Source="placeholder"). It is harmless by construction: CensusAndEnforce early-returns on Placeholder rows so EnsureEquipped never fires, and it is NOT Invincible. Requires CompanionKit 0.4.29 (the GhostStandIn access widening). Row GM36.
  • …and it hot-swaps to the real body, strictly serialized (PR5, swap leg). Core.PlaceholderSwap is the pure sequencer (Standing / Swapping / Retired, with a DeferGone latch where Removed outranks Died; the exhaustive table and every length-4 sequence are pinned in PlaceholderSwapTests). The executor GuestReplicas.SwapRoutine is yield-free and its order is strict β€” identity-checked deregister β†’ Views.Neutralize β†’ destroy the placeholder β†’ Mint(template, m, "swap") β†’ row takeover β€” because a same-UID newcomer deactivates its predecessor and IsAI latches on first read. The fresh row keeps Placeholder=true through its 2-frame dwell so a mid-dwell gone routes through the machine; completion drops the flag, acks Source="swap" and runs the normal census/visual tail. A template evicted before the destroy rewinds to Standing and re-wants the species, so the swap is retriable. Row GM37.
  • The cold-busy and keep-configured deferrals are BOUNDED. Worth saying out loud, because it deliberately overrides an operator setting: the Forgiven expiry window now bounds β€” and after ~60 s despawns β€” spawns kept under DespawnOnMirrorFailure=false as well as cold-busy deferrals whose warm never lands. The ledger backstop wins over "keep" on purpose, to bound the B10 ghost/scan hazard. Related, under DespawnOnMirrorFailure=false plus LRU thrash (a non-default combination): a warmβ†’evictβ†’re-flushβ†’refuse ping-pong can cycle for as long as the peer's row keeps genuinely changing. It is paced by real warm completions rather than by a timer; noted beside B11's cache-coupling doc.
  • Guest scene-batch harvest: k species out of ONE additive load (L3c β€” the resolution of B12's budget deadlock). A master-instructed warm on a guest now carries every other wanted-cold species that shares a donor scene with the one being warmed, charged as ONE donor cycle: the LightProbes crash resource degrades per load/unload, not per species, and billing needed no change because WarmRoutine already charges DonorHarvest.CyclesThisSession deltas. The DangerousRoads patrol roster (~11 species across ~3 scene families) now fits the shipped MirrorHarvestBudget=4, unit-pinned. Batch bound (the B11 coupling): companions per batch = min(wanted, free template-cache slots after the primary, SceneBatchPlan.DefaultMaxBatchSpecies = 12 including the primary) β€” never build a template the LRU evicts in the same Insert. The serial warm latch, the safety re-checks and per-species failure isolation are all unchanged, and a companion that is not live in the settled scene keeps its own queue entry and chain. New pure policy core/SpawnKit.Core/SceneBatchPlan.cs + SceneBatchPlanTests. Rows L3C1–L3C3.
  • ADDITIVE: SpawnTemplates.AcquireScene gains an allowNonMaster overload mirroring Acquire's, honored for master-instructed warms and carrying the same attribution log; the 2-argument form is unchanged. COMPAT_SINCE stays 0.5.0.
  • skwarmdump tells a quiet peer from a frozen one (C2). WarmMirror tracks a HOST-LOCAL lastHeardAt per actor, updated on ANY message received whether or not it decodes, using Time.unscaledTime on this machine β€” never a wire field. PeerRow.At only advances on a message we could actually READ, so a peer sending garbled bytes every tick used to read as "frozen" by age alone. The dump now prints landed= beside heard= for Participating peers and a last-heard age for Unmodded / HelloedNoRow rows too; the table is cleared on a room change, because actor ids restart and a stale timestamp would name a stranger. Row C2-LASTHEARD in docs/guest-mirror-harvest-testplan.md.
  • Comment-only (C1): the warm-set heartbeat's loss-tolerance note now names the real reason it exists β€” peer-state transitions (late join, resync, room change, a peer not yet scene-ready), not transport loss. PUN RPCs are reliable, ordered and deduped; loss means disconnection. The heartbeat stays.
  • Comment-only (L3c review follow-up): the batch trigger comment states the true condition β€” mirror mints batch too, not only the harvest path.
  • Dependency pins raised to the sweep floors (CompanionKit 0.4.29, DonorKit 0.1.8, NetKit 0.2.8).

0.5.4 β€” 2026-08-29 (built, not live-verified)

  • Preflight refuses while the world is not live β€” FailReason.GameplayNotLive (14, appended; COMPAT_SINCE stays 0.5.0). Reads ForgeKit.Lifecycle.IsGameplayLive(): a level load in flight OR the loader still holding gameplay paused after it (the "press any key" window), where a spawned body sits in CharacterAI.Update's paused branch and comes up T-posed with an inactive AIStatesRoot (education/who-poses-the-body.md #8). Distinct from NoPlayer β€” the player exists in that window. DescribeAiLiveness's own paused readback is unchanged (it is a readback on an existing body, not a gate).

0.5.3 β€” 2026-08-29

  • postrace <name|uid:xxx> [seconds=20] [hz=5] dev verb (PosTrace.cs): the BUG-SPAWNLAUNCH discriminator. Coroutine-sampled [POSTRACE] line per tick on one tracked spawn β€” y, grounded=<cc.isGrounded>, falling=<m_falling>, totem=<m_charTotemMove>, agent=<on|off>/onMesh/next.y, ragdoll, clip, and overlaps= every collider inside its CharacterController (self excluded, layer named). Stops on despawn/death/timeout or postrace off. Requires CompanionKit 0.4.23 (pin only).

0.5.2 β€” 2026-08-28

  • Fix stale dependency pins across the fleet; DonorKit 0.1.7
  • release prep: sync version numbers across all four files
  • Cross-area pet orphan fix + three combat-staging diagnostics
  • Merge feat/guest-voluntary-warm: guest self-warm opt-in + DR late-join re-push
  • SpawnKit: guest voluntary warm β€” review fixes (M1/L1/M2/L2)
  • SpawnKit 0.5.1: review nits β€” MintHeal.Step/SettleState shared by coroutine + replay test, max==base warning, startHp on the settle exit line
  • SpawnKit + DR: guest voluntary warm (GM22–GM28, built, not verified)
  • SpawnKit 0.5.1: co-op mint heal β€” refresh the max stat + settle watch (MP-HALFHP)
  • F2-V2: raw-gate the PLAN half of the want duty, the forgiven re-flush and skresync
  • F2-V1: stamp the guest warm routine with its room, so a stale one cannot free the latch
  • AF2-13: additive LruView instead of retyping LruKeys (review CHANGES, ABI removal)
  • AF3-2: gate SpawnKit's cross-peer host duties on RAW authority, not the flipped getter
  • AF2-13: TemplateStore.LruKeys hands out a read-only view of the live LRU order
  • AF2-9: cap the species count a warm row may decode, and refuse an over-long one
  • AF2-8: say "sent this sweep" β€” the count is the whole want book, not my species
  • AF2-7: the warm mirror stops mirroring its own row
  • AF2-11: an abandoned template acquire still charges the donor cycles it burned
  • AF2-10: reset the guest's harvest standing above OnRoomChanged's early-out
  • AF2-5: the sk.want handler logs what actually happened, not "queued at head"
  • AF2-3: an abandoned (actor, species) pair neither plans nor spends the per-peer cap
  • … and 2 more (see git history)
  • aiLive= on the [SPAWN] mint line and every spawndump row β€” the one field that answers "can this body engage on its own?". Verdicts, most-global first: NoAiGraph, WorldPaused, AiBehaviourDisabled, NotStartInitialized, DistanceCulled, Ticking; anything but Ticking prints its own remedy sentence. Classification is pure and unit-tested (core/SpawnKit.Core/AiLiveness.cs, tests/SpawnKit.Tests/AiLivenessTests.cs).
  • charAIDisable= is documented at the call site as PRESENCE, not state. A headless session read charAIDisable=True as "SpawnKit disabled the clone's AI" and concluded, for a whole stretch, that spawned wilds can never engage. CharAIDisable is a DISTANCE CULLER (CharAIDisable. AICloseToPlayerCheck) that only ever acts when no player sits inside DistanceToEnable; it says nothing about liveness. The field key and value are unchanged (grep contract) β€” aiLive= is the additive answer.
  • Why it matters: the decisive term is GLOBAL. NetworkLevelLoader.IsGameplayPaused gates AICEnemyDetection.Update, AISWander.Update and CharAIDisable.AICloseToPlayerCheck alike, so a wedged world-sim pause (which a loadsave can land in, with every dev verb still answering normally) freezes VANILLA scene creatures exactly as hard as SpawnKit clones. aiLive=WorldPaused now names it and points at unstick fix.
  • Additive only β€” COMPAT_SINCE stays 0.5.0.

0.5.1 β€” 2026-08-24

  • Co-op mint heal: spawns in a 2+ player room no longer arrive at half health. ApplyCoopStats throws on a fresh clone before vanilla's ratio heal, and the host read a stale max stat; the mint heal now forces RefreshVitalMaxStat first and runs a ≀1 s settle watch that carries the max delta onto current health (damage taken inside the window is kept). Host-only; solo path unchanged (SpawnKit.Core.MintHeal)
  • New [SPAWN] mint-heal … / mint-heal settle … log lines; the mint header and spawndump print hp=cur/max base=B
  • Guest voluntary warm: a guest may Prewarm for itself (menu button / spawnprewarm) under [Coop] GuestPrewarm; the ask rides the existing safe want queue (out of combat, loader idle) on its own per-room bucket (GuestPrewarmBudget) under a per-session ceiling (GuestDonorCycleCeiling); Core.GuestWarmPolicy + class-tagged GuestHarvestStanding (built, not live-verified β€” GM22–GM28)
  • Published warm-row budget is now min(mirror-left, ceiling-left) (WarmSetWire.PublishedBudget); the voluntary bucket stays local; no wire change
  • skwarmdump / spawndump print both buckets, the session ceiling and the class-tagged queue
  • New public seam Spawner.OnPeerSceneReady (master-only) β€” DangerousRoads re-pushes its room-warm list for late joiners

0.5.0 β€” 2026-08-22

  • Room-wide warm mirror: host-side WantBook ledger, PeerOwned mirror of every peer's mintable species, host gate on party-wide mintability (Phase A observe + Phase B enforce)
  • Wire-protocol bump: sk hello announces 0.5.0; the compare is exact-ordinal, so a mixed-build room refuses co-op loudly (same-bundle rule)
  • Version reconciliation: Plugin.cs already announced 0.5.0 since the warm-mirror wire bump; manifest.json / thunderstore.toml / csproj now agree

0.4.4 β€” 2026-08-19

  • SpawnKit: casters attack β€” BUG-CASTERNOSKILL skill-knowledge repair (live-verified)
  • Merge branch 'fix/sa-0815-mpnet' into feature/pet-self-feed
  • Review fixes for the SA lane-F3 MP/net wave (H1, M1, M2, L1-L3)
  • SpawnKit: review fixes β€” refusal memo must not shadow a later-valid template
  • SA 2026-08-15 lane F3: MP/net teardown, mirror-race + hello-warn fixes
  • SpawnKit: TemplateAiGate cluster fixes (SA-0815 F2b, A4-1/2/3/5/6/7/8 + A9-4)
  • SpawnKit: refuse brainless templates β€” BUG-PREBUILTADOPT structural AI-graph gate
  • SpawnKit: fix Character.Start never running on far-mint spawns
  • spawn <TAB> completes the species roster β€” SpawnKit annotates spawn/spawnex/spawnprep; explicit choices win in the engine
  • 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

0.4.3 β€” 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
  • Merge BUG-CONTROLLERCLOSE fix: controller B via vanilla ControlsInput.MenuCancelSystem() ORed into the same two-stage Esc gate (CI-safe, no Rewired_Core ref; retest owed)
  • Merge Ghost root-scale fix (GV-SCALE1): donor's authored (0.02,1,0.02) root localScale rode Instantiate into every mint; RootScaleFix restores degenerate axes at mint time, SK_-uid + UseLegacyVisual=false scoped; retest GV-SCALE1b owed
  • BUG-CONTROLLERCLOSE: controller B closes the SpawnKit menu via the same two-stage gate as Esc
  • Invisible-Ghost fix: un-crush the donor's root localScale at mint (GV-SCALE1b)
  • DangerousRoads relay whitelist gap + ghostdiag DEGENERATE threshold widen
  • Fable-review fixes: viewID watermark reads outstanding ids (latched), corpse-release fallback never parks a neutralized view, FarCache InFlight can't leak, visual-pass retries count as busy, Notify header placement
  • SpawnKit perf wave: hot-loop allocs removed, adaptive replica enforce, prune throttle, viewID watermark + opt-in corpse release, AI sleep radius, caps 8->12
  • SpawnMenu search bar: pure SpeciesFilter (key + resolved donor name), per-frame latched row set, two-stage Esc
  • Slope-tilt review wave: pure smoother + hysteresis deadband + per-species axis
  • SA-0808 Wave C code half: spawn recovery β€” census, ghost fix-at-cause, quest-gate clear, TerrainManager guard, abandon quarantine
  • Static-analysis 2026-08-08 wave 0: nine P1/P2 fixes
  • Merge branch 'feat/pet-health-orb'
  • Fix BUG-RAGDOLLJOINTLOSS: the rig re-init pass ate healthy ragdoll joints
  • Pet health orb: replace the IMGUI bar with a green Outward-style vitals circle
  • Fix BUG-GHOSTINERTSPAWN: re-run the init chain Character.Start already spent
  • Merge Lane B: SpawnKit view lifecycle migrated into NetKit.ViewLease (phase 2)
  • Phase-2 view-lease migration: SpawnKit's view lifecycle moves into NetKit
  • Review fixes: strip the whole VFXPlayed rebind family; pin only when it defends something
  • … and 8 more (see git history)

Unreleased

  • Refactor (behavior-preserving; built, NOT live-verified): the PhotonView LIFECYCLE (mint/bind/deferred release/age-out sweep/corpse mute) migrated into NetKit.ViewLease (phase 2 of the view-hygiene migration). MintNormalize and EnemySpawner are thin delegations now; every [SPAWN] log line is byte-stable β€” though the lease-mechanics lines (deferred/released/aged-out) now carry the NetKit BepInEx source label and follow NetKit's [Diag] LogLevel rather than SpawnKit's (defaults identical; warnings un-gated on both) β€” and SpawnKit keeps its policy β€” the master-warn vs mirror-throw no-view asymmetry, the Vanilla-corpse may-persist declaration, and the corpse-worded once-per-entry aged-out notices (supplied to the lease as callbacks). The parked-release ledger + the 300s age-out rule gained their first unit tests (NetKit.Tests/ViewLeaseTests). Template normalization deliberately keeps its bare viewID=0 (a dormant template must stay mintable β€” see the new warning note in SpawnTemplates.Normalize).

  • DangerousRoads 0.1.1 + SpawnKit 0.4.2: re-pin off CompanionKit 0.4.2

  • DonorKit/SpawnKit: name the two silent paths

  • DonorKit/SpawnKit: five static-analysis fixes

  • DonorKit/SpawnKit cleanup: one rig gate, one Safe() reader

  • Docs sweep: archive, condense, and validate the whole documentation tree

0.4.1 β€” 2026-08-02

  • Hyena/Pearlbird tuning wave: HAO taunt, gifts, bone relic, feed rule