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
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.13CeruleanCutlass-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.9CeruleanCutlass-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.29CeruleanCutlass-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.10README
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 intWeaponType, written ONLY byCharacter.WeaponChanged(), which is triple-guarded and silently no-ops on guard failure with no retry. Weapon damage ridesHitStartedanimation events on the ARMED clips, and the unarmed fist detector is assigned only whenCurrentWeapon == 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 becauseWeaponTypeis not game state.grep WeaponChanged src/SpawnKitwas zero hits while SpawnKit calledAnimator.Rebind()(which resets every parameter) in three places after equipping. We had already fixed this once β GhostPeer'sGhost.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.UpdateAttackCheckandTryStartAttackPatternare both gated onCurrentWeapon is MeleeWeapon; aProjectileWeaponis 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 failedEquipRequiredWeaponsswap 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 ownInLocomotion && !IsCastingguard, and inactive candidates skipped (a bag deactivated by the ItemUidGuard leaves inactive weapons visible to the scan).MirrorAuthority(N2): a SplitScene authority flip madeGuestReplicasread 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.spawndumpgainswpnType= offHandType= wpnClass= pouchWpn= pouchReg= sidearm= swapState= tries= lifeswaps=βwpnTypeis 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 reportalive=0 dead=0). Retest:dr-farcache-testplan.mdFC15.Spawner.CanEventuallyShowRoomWide(key)+ pureSpeciesRoomPolicy.CanEventuallyShow: the draw-time question ("could a spawn ever get past the spawn gate under the operator's RoomWarmMode?") as distinct fromCanMintRoomWide's "cleanly, now". Under the defaultRoomDegradedit 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.mdPR3-5.- Batched warm asks bind
RoomWarmRequestCaponce. 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.mdPR2-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 inDonorHarvestβ 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 thesrc=label on the mint is decided from the donor cycles actually spent: a re-harvest reports the newcache-lost-reharvestinstead of falsely claimingsrc=cache. Policy inCore.MirrorAcquirePolicy; rowGM29. - An
sk.goneinside the mint dwell no longer fires a bogussk.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. RowGM30. Audited alongside it:SpawnNet.OnFailcarries 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.OnFailnow writes the Forgiven-ledger re-flush obligation for EVERY cold-unsafe report that leaves the spawn Alive β degraded and forced spawns included β paired with aRequestRoomWarm.RoomGateOkno longer gates the plumbing, only the forgiveness verdict. The despawn decision is untouched by this change. RowGM31. - The warm-request deadlock: terminal dead ends refuse, and guests replay their own refused spawns
(B12).
RoomDegradedno longer spawns past a TERMINALDeadEndOnPeerβ the degraded branch readswarm.ReasonthroughSpeciesRoomPolicy.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-uidCore.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, andIsKnowncounts pending rows so the host's re-flush dedups against them. RowsGM32/GM33. cold-busyβ a refusal that means "not right now", not "never" (PR1). New benign wire reason coveringRefuseCombat+RefuseBudget+RefuseCeiling(Core.MirrorFailPolicy.ColdRefusalReason, unit-pinned);RefuseAdmissionand 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. InShouldDespawnColdUnsafethe!roomGateOkclause 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; theroomGateOkparameter is retained for the log line and call-site compatibility. RowsGM34/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 βEditorCharacterAILoadAIdestroyed beforeIsAI's first latching read β aSKPH_UID (soSK_stays free for the swap), the master's faction stamped after activation (ChangeFactionsync:false), andsk.ack(Source="placeholder"). It is harmless by construction:CensusAndEnforceearly-returns on Placeholder rows soEnsureEquippednever fires, and it is NOT Invincible. Requires CompanionKit 0.4.29 (theGhostStandInaccess widening). RowGM36. - β¦and it hot-swaps to the real body, strictly serialized (PR5, swap leg).
Core.PlaceholderSwapis the pure sequencer (Standing / Swapping / Retired, with aDeferGonelatch where Removed outranks Died; the exhaustive table and every length-4 sequence are pinned inPlaceholderSwapTests). The executorGuestReplicas.SwapRoutineis 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 andIsAIlatches on first read. The fresh row keepsPlaceholder=truethrough its 2-frame dwell so a mid-dwell gone routes through the machine; completion drops the flag, acksSource="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. RowGM37. - The
cold-busyand 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 underDespawnOnMirrorFailure=falseas 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, underDespawnOnMirrorFailure=falseplus 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
WarmRoutinealready chargesDonorHarvest.CyclesThisSessiondeltas. The DangerousRoads patrol roster (~11 species across ~3 scene families) now fits the shippedMirrorHarvestBudget=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 policycore/SpawnKit.Core/SceneBatchPlan.cs+SceneBatchPlanTests. RowsL3C1βL3C3. - ADDITIVE:
SpawnTemplates.AcquireScenegains anallowNonMasteroverload mirroringAcquire's, honored for master-instructed warms and carrying the same attribution log; the 2-argument form is unchanged.COMPAT_SINCEstays 0.5.0. skwarmdumptells a quiet peer from a frozen one (C2).WarmMirrortracks a HOST-LOCALlastHeardAtper actor, updated on ANY message received whether or not it decodes, usingTime.unscaledTimeon this machine β never a wire field.PeerRow.Atonly 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 printslanded=besideheard=for Participating peers and a last-heard age forUnmodded/HelloedNoRowrows too; the table is cleared on a room change, because actor ids restart and a stale timestamp would name a stranger. RowC2-LASTHEARDindocs/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_SINCEstays 0.5.0). ReadsForgeKit.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 inCharacterAI.Update's paused branch and comes up T-posed with an inactiveAIStatesRoot(education/who-poses-the-body.md#8). Distinct fromNoPlayerβ the player exists in that window.DescribeAiLiveness's ownpausedreadback 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, andoverlaps=every collider inside its CharacterController (self excluded, layer named). Stops on despawn/death/timeout orpostrace 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 everyspawndumprow β the one field that answers "can this body engage on its own?". Verdicts, most-global first:NoAiGraph,WorldPaused,AiBehaviourDisabled,NotStartInitialized,DistanceCulled,Ticking; anything butTickingprints 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 readcharAIDisable=Trueas "SpawnKit disabled the clone's AI" and concluded, for a whole stretch, that spawned wilds can never engage.CharAIDisableis a DISTANCE CULLER (CharAIDisable. AICloseToPlayerCheck) that only ever acts when no player sits insideDistanceToEnable; 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.IsGameplayPausedgatesAICEnemyDetection.Update,AISWander.UpdateandCharAIDisable.AICloseToPlayerCheckalike, so a wedged world-sim pause (which aloadsavecan land in, with every dev verb still answering normally) freezes VANILLA scene creatures exactly as hard as SpawnKit clones.aiLive=WorldPausednow names it and points atunstick fix. - Additive only β
COMPAT_SINCEstays0.5.0.
0.5.1 β 2026-08-24
- Co-op mint heal: spawns in a 2+ player room no longer arrive at half health.
ApplyCoopStatsthrows on a fresh clone before vanilla's ratio heal, and the host read a stale max stat; the mint heal now forcesRefreshVitalMaxStatfirst 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 andspawndumpprinthp=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-taggedGuestHarvestStanding(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/spawndumpprint 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 helloannounces 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).MintNormalizeandEnemySpawnerare thin delegations now; every[SPAWN]log line is byte-stable β though the lease-mechanics lines (deferred/released/aged-out) now carry theNetKitBepInEx source label and follow NetKit's[Diag] LogLevelrather 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 bareviewID=0(a dormant template must stay mintable β see the new warning note inSpawnTemplates.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