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.
Beastwhispering 0.2.9: pet evade (hyena back-hop dodge) as the evade species axis + ck.pet.cue MP cue
0.2.10 — 2026-09-03 (built, not live-verified)
Loopback peers (GhostPeer GH16) — Net.RegisterLoopbackPeer(actor) /
UnregisterLoopbackPeer(actor), additive public API, DEV ONLY in intent. A registered fabricated
actor answers as a same-bundle install: one hello delivered at registration and every outgoing
hello echo-answered from it (the Sim transport's echo-hello behavior made transport-independent),
so kit gates (SpawnKit room spawn gate, CompanionKit readiness) stop reading a ghost actor as
UNMODDED and locking the room down. The registrant owns the lifecycle — unregister on leave.
0.2.9 — 2026-08-30 (built, not live-verified)
Sim transport (VP1 of the virtual-player spike) — [Net] Transport = Sim, DEV ONLY, never in a
shipped profile. No Photon traffic at all: sends cross a seeded delay/jitter/drop wire
(Core.SimLink, unit-tested; the seed pins the drop/jitter draw sequence — seed 0 derives one and
logs it — though delivery interleaving still follows frame cadence)
and are answered by ONE fabricated remote actor ([Net] SimActor, default 9). The sim peer answers
an nk.hello with a same-bundle hello of its own, so every registered channel arms readiness toward
it through the normal handshake — consumers exercise their real peer-facing paths on one box.
Ordered mode (default) preserves Photon's reliable-ordered FIFO; SimOrdered=false and
SimDropPct>0 are hostile-wire stress models Photon itself won't produce. SimEchoAll parrots
every envelope back as the sim actor (handler idempotence/authorization fuzz). New sim verb:
status | join | leave | reset | send <channel> <verb> [payload…]. Scope: NetKit-layer traffic
only — the sim actor has no PhotonView and no game character (that is VP3, the Ghost Peer —
docs/virtual-player-spike.md).
0.2.8 — 2026-08-30 (built, not live-verified)
The hello-refresh latch is PER RECIPIENT (NK-F2b). It used to be one global last-sent payload, so
a targeted join-hello — sent to a newly connected peer alone — advanced the refresh diff on behalf of
the peers that never received it. A mid-session payload change (a host-side taming retune, a late
channel registration) could therefore be masked for a pre-existing peer in a room of three or more
actors. Core.HelloResend now keeps a broadcast baseline (a send to Others reaches everyone, so it
supersedes and clears the per-actor entries) plus a per-actor baseline for each targeted greeting; a
refresh is owed while ANY recorded recipient is behind. Per-actor entries are dropped on peer loss and
on room change, so they cannot leak or outlive an actor number (actor numbers restart per room).
2-actor behaviour is unchanged by construction, which is why this was unreachable in vanilla Outward's
2-player co-op and stood as an accepted risk until now. New Core tests cover the 3-actor masking
scenario, broadcast supersession, host-alone → guest-joins baseline arming, peer-loss cleanup and
room-change reset. Retest row NK-F2b-FIX in docs/netkit-testplan.md.
A payload approaching PUN's practical ceiling warns once (A4-SIZEGUARD). RpcRelayTransport.Send
measures the outgoing string and logs ONE LogWarning per (channel, verb) per session when it crosses
80% of 32 717 bytes — vanilla's own ItemManager.CompressDataToSend chunk size, which is what puts the
practical per-RPC limit there. Warn-only: nothing is chunked, nothing is refused, and the check itself
can never throw a send. Threshold and UTF-8 sizing are pure (core/NetKit.Core/PayloadSizeGuard.cs);
row A4-SIZEGUARD in docs/netkit-testplan.md.
Comment-only (C1): the loss-tolerance notes on ReplicatedStore and StateMirror now state the real
reason the periodic re-announce exists. PUN RPCs are reliable, ordered per channel and deduped — the
transport cannot lose messages, and loss means disconnection. The heartbeats are compensating for
PEER-STATE transitions that produce the same missing-state symptom (late join, resync, room change, a
peer not yet scene-ready), and they stay: they are load-bearing for late joiners.
Additive only; COMPAT_SINCE unchanged at 0.2.4.
0.2.7 — 2026-08-29
FireAndForgetLadder.OnCastReceived(..., bool masterOriginated) overload: a MASTER-originated
transient (the pet-evade cue — the host rolls a guest pet's dodge) applies on the owning guest
instead of the own-echo skip, which would otherwise drop the one machine that moves the puppet.
The 5-arg form is unchanged (delegates with false). Additive; COMPAT_SINCE unchanged.
0.2.6 — 2026-08-28
Fix stale dependency pins across the fleet; DonorKit 0.1.7
CompanionKit/NetKit: park+release the anchor's Photon view-ID
AF1-3 review: keep void Flourish, add TryFlourish; sync the version quartets
AF1-8: bound the NetCounters tables
AF1-6: a PeerOwned store must clear on a room change
AF1-4: a disabled master names the guest reports it swallows
Unreleased
NetCounters is bounded (AF1-8, the UnknownViewTable pattern): the per-verb table caps at 64
verbs and each verb's drop-reason set at 32, with refused events counted and named in Summary.
Verbs and drop reasons are compile-time constants in every consumer, so a growing table means
something is inventing them from the wire — and a diagnostic must never become the leak. A full
table stops growing rather than evicting, and a capped reason tag still counts its drop.
ReplicatedStore refuses PeerOwned + ClearOnRoomChange = false at construction (AF1-6), the
same ArgumentException shape as the existing ResolveUidOwner refusal. A PeerOwned row key is
derived from the sender's ACTOR NUMBER, and actor numbers are room-scoped — carrying rows across a
room change leaves ghosts keyed to actors that mean someone else (or nobody) in the new room, and
the presence reap compares actor numbers with no room identity so it cannot see them either.
Latent today (both shipped consumers set it true); documented in docs/wiki/kits/netkit.md.
Fire-and-forget PROXY leg: a master with the feature disabled now DROPS a guest's report as
disabled-on-master instead of skipping it silently (AF1-4, ruling change). The old Skip
blackholed every guest's transient — no apply, no relay, no drop count, no log — so nobody in the
room ever saw that moment and no dump could name why. The CAST leg keeps its silent skip: a
machine that renders nothing locally is not losing anyone else's moment.
0.2.4 — 2026-08-19
Merge branch 'fix/sa-0815-mpnet' into feature/pet-self-feed
SA 2026-08-15 lane F3: MP/net teardown, mirror-race + hello-warn fixes
A10 verb re-homing: photondump body -> NetKit.ViewRegistryDump; waiver docs; V28-V31
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
W4: fixes for everything D1 found, plus the join-race P1
MP fix waves W1/W2/W3, and what Block A found when we ran them
Log levels: a per-mod [Diag] LogLevel, gating at the source
NetKit + CompanionKit: GetComponent ?? AddComponent, without the eager trap (UNT0007)
Cloudward + NetKit: say it when a decision was downgraded
Cloudward + NetKit: five ways a quiet failure became a loud one
Unreleased
New (built, NOT live-verified): ChannelOptions.QuietVerbs — verbs a channel declares quiet
skip the per-send/per-recv LogInfo line AND the 32-entry TraceRing write, while their
COUNTERS still count (sends/recvs/drops stay in netdump). Built for streaming verbs
(CompanionKit's ~5 Hz ck.proxy.pos would wrap the whole trace ring in ~6 s and destroy the
cross-verb forensic window, and would drown a VerboseNet log). Pure membership decision is
NetKit.Core.QuietVerbSet (ordinal, null/empty-tolerant, unit-tested).
New: NetKit.Views — a shared clone/view-hygiene API, positioned as NetKit owning the Photon
half of "clean up a live clone" so consumers stop hand-rolling their own tripwires against it.
Neutralize performs the always-legal PhotonView field writes (removedFromLocalViewList,
viewID=0, sync mode Off, send group 254) BEFORE any destroy is attempted, so even a refused
destroy can no longer let a stray view evict the real replica underneath it. VerifyClean /
CountNetwork / DisarmSurvivors round out an honest report of what actually survived a strip,
rather than assuming a logged destroy call succeeded.
New: core/NetKit.Core/ViewHygiene.cs — the pure compute half (ViewFacts/SurvivorFacts/
Describe/IsClean), including the one shared RefusalNote const for "Unity silently refuses
this class of destroy call here" so the explanation can't drift between call sites that quote it.
New: NetKit.ViewLease — the view LIFECYCLE complement to Views (phase 2 of the hygiene
migration; built, NOT live-verified): Mint/Bind allocate or adopt a viewID onto an inactive
clone's own PhotonView (single-view semantics; no-view is a returned fact, never a throw —
the consumer owns the warn-vs-refuse policy), Release/DeferRelease/SweepPending park a
minted id next to its body and hand it back only once the body is destroyed (PUN warns on a
live-view release), and MuteView + MutedGroup (253) stop a registered view from streaming
WITHOUT deregistering it — deliberately the opposite doctrine of Views.Neutralize (254), the
header explains why both groups coexist. One instance per consumer: the ledger, the log tag and
the once-per-session latches are consumer-scoped, and every emitted line is byte-stable with
the pre-migration SpawnKit implementation; consumer-dialect notices surface through callbacks.
New: core/NetKit.Core/ViewLease.cs — the pure half of the lease (LeasePolicy with the 300s
age-out rule and the aged-out re-check verdict, ViewLedger/LeaseEntry the pending-release
ledger, MuteResult), unit-tested for the first time incl. the byte-stable forensics dump
format (ViewLeaseTests).
0.2.2 — 2026-08-02
Cloudward + NetKit: say it when a decision was downgraded
Cloudward + NetKit: five ways a quiet failure became a loud one
Docs sweep: archive, condense, and validate the whole documentation tree