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

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.

Date uploaded 6 days ago
Version 0.2.10
Download link CeruleanCutlass-NetKit-0.2.10.zip
Downloads 99
Dependency string CeruleanCutlass-NetKit-0.2.10

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

README

NetKit

πŸ“– Full documentation: NetKit wiki page

The shared Photon co-op transport layer for the Outward mod kits. It owns the transport shell CompanionKit's NetBus and SpawnKit's SpawnNet used to duplicate (~65–75% line-identical): relay attach, send helpers, the one hello + peer ledger + absence detector, per-channel+verb counters and trace, join/leave hooks, heartbeat, and the PUN diagnostics. Message semantics (verbs, codecs, authorization, flush policies) stay in the consumer mods.

Requires: BepInEx 5 (BepInExPack_Outward) and ForgeKit β€” a hard dependency ([BepInDependency]), so BepInEx refuses to load NetKit if ForgeKit/ isn't in BepInEx/plugins/. No SideLoader; NetKit builds on a clean runner.

Full design + decisions: docs/netkit-plan.md. Background: docs/photon-layer-research-2026-07-18.md.

What it owns

  • Channels, not per-mod buses. One shared NK_Bus wire envelope (channel, verb, seq, extra, payload) carries every channel; the demux is channel β†’ verb.
  • One hello / one absence detector. nk.hello on join/connect/guest-scene-ready carries the proto version + the registered channelβ†’version map + optional per-channel extension strings. Peer state surfaces per channel (OnPeerReady / OnPeerLost / IsPeerReady / ReadyCount). The 10 s "peer appears UNMODDED" warning lives here once.
  • Diagnostics under the owning channel's log tag: counters, ring-buffer trace, heartbeat (all PhotonNetwork.time-stamped), the PUN log-signature watcher + per-id unknown-view table. netdump reports transport/attach/peers/counters; selftest runs the loopback + Core checks. The per-channel heartbeat line carries cumulative tx=/rx=/drops= totals (after pt=, before the consumer fragment) so the passive stream survives when the interactive dumps are unreachable.

Dev verbs (BepInEx/config/NetKit_cmd.txt)

  • netdump β€” co-op census on this machine (transport/attach, per-channel verbs/peers/counters/ ring buffer, hello ledger, PUN-signature + unknown-view tables, helloMuted).
  • selftest β€” [SELFTEST] PASS/FAIL … DONE: hello codec, compat calc, peer-ledger timing, counters, and (in a room) the transport loopback.
  • netmute [on|off|status] (bare = status) β€” ON suppresses OUTGOING nk.hello sends so this box reads as UNMODDED to peers; the staging tool for incompatible-peer testplan rows without renaming a DLL mid-session. Incoming handling unchanged. Session-only (not persisted), default off.
  • Pure half in core/NetKit.Core (unit-tested, zero game refs): hello codec (lossless escaping), channel-version compat, the peer-ledger timing, counters/trace models, heartbeat formatter, unknown-view table.

API sketch (game side)

var ch = Net.RegisterChannel("sk", "0.4.0", new ChannelOptions {
    LogTag = "SKNET", HelloExtension = () => …, HeartbeatFragment = () => … });

ch.Register("spawn", msg => …);          // msg: Verb, Payload, Extra, SenderActor,
                                         //      SenderIsMaster, SenderIsSelf
ch.SendToMaster("hit", payload, extra);  // bool; also SendToOthers/SendToAll/SendToPlayer
ch.SendToAllLoopback("nk.test", …);      // selftest
ch.OnPeerReady += info => …;             // info: Actor, ChannelVersion, Extension, IsMaster
ch.CountDrop("spawn", "no-row");         // consumer-reported drops

Net.Attached / Net.InRoom / Net.IsMaster / Net.IsGuestInRoom

Send guards return false + drop-count (never throw). Unknown verb on receive = warn-once + counter. Handlers run inside try/catch with per-verb error counters.

Two backends (one internal transport interface)

  • Rpc (default): the NK_Bus [PunRPC] relay piggybacked on CharacterManager's PhotonView (viewID 999). RefreshRpcMonoBehaviourCache() after attach is a harmless no-op precaution β€” the shipped UseRpcMonoBehaviourCache is false, so it is not "mandatory".
  • Event (opt-in): PhotonNetwork.RaiseEvent on one configurable code ([Net] EventCode, default 177) β€” no GameObject, no view. Implemented + selftest-covered but config-gated OFF ([Net] Transport = Rpc) until its offline loopback + two-box behavior are live-verified.

Example configuration

BepInEx/config/cobalt.netkit.cfg β€” created on first launch. Excerpt (defaults):

[Net]
## Transport backend: Rpc (default) or Event.
Transport = Rpc
## RaiseEvent code used by the Event backend.
EventCode = 177
## Seconds between per-channel heartbeat lines.
HeartbeatSeconds = 30
## Seconds before a silent peer is warned as "appears UNMODDED".
HelloWarnSeconds = 10
## Per-message send/receive logging under each channel's tag.
VerboseNet = true
## Photon DisconnectTimeout override in ms; 0 = leave the game's default.
DisconnectTimeoutMs = 0

The dev command channel is BepInEx/config/NetKit_cmd.txt (the verbs above). NetKit ships no config-override data tables.

Cutover note

New builds speak NK_Bus only. An old-build peer (CK_Bus/SK_Bus) looks unmodded to the new hello and is refused co-op cooperation by the existing incompatible-peer machinery β€” acceptable because the mods ship as one bundle (Outward-Mods-latest.zip). W2/W3 refactor CompanionKit's NetBus and SpawnKit's SpawnNet onto channels ck / sk.

Verification state (docs/netkit-testplan.md). The core transport surface is live-proven β€” relay attach, the hello/peer ledger, channel demux and the counters all PASSed across real two-box sessions between 2026-07-27 and 07-31. Still open: the RaiseEvent transport backend (V10/V11 β€” which is why [Net] Transport ships as Rpc), plus V9/V9b/V9c and NK-F2b. Don't read "NetKit is verified" as covering the Event backend, and don't read the open items as covering the RPC path.

CHANGELOG

NetKit changelog

0.2.10 β€” 2026-09-05

  • ForgeKit 0.4.13 / DonorKit 0.1.9 / SpawnKit 0.6.1: session-3 fixes
  • Fix release-blocking version skew: NetKit 0.2.10, Beastwhispering 0.2.17
  • NetKit 0.2.10 loopback peers + GhostPeer GH16: the ghost answers kit hellos
  • NetKit 0.2.9: Sim transport (VP1) + virtual-player spike report/education docs
  • NetKit 0.2.8: MP-hardening sweep close-out (NK-F2b, A4-SIZEGUARD, C1)
  • NetKit: A4-guard warn once when a payload nears PUN's 32717-byte practical ceiling
  • merge NK-F2b: per-actor hello-refresh latch (same-tier reviewed, APPROVE)
  • NetKit: NK-F2b per-actor hello-refresh latch so a targeted join-hello can't mask a payload change
  • NetKit/SpawnKit: C1 loss-tolerance comments state the real reason (peer-state transitions, not transport loss)
  • packaging: raise ForgeKit dep pins to 0.4.12 and StoryKit to 0.1.8 (fresh-install floor)
  • Spawn gate abstraction: Lifecycle.IsGameplayLive, StoryKit SpawnPolicy + posture census, WalkSpeed default
  • 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

0.2.3 β€” 2026-08-11

  • 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
  • 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
  • Merge fix/sa-0808-spawn: spawn recovery wave (SA 2026-08-08 Β§8)
  • SA-0808 Wave C code half: spawn recovery β€” census, ghost fix-at-cause, quest-gate clear, TerrainManager guard, abandon quarantine
  • NetKit hardening wave (static-analysis 2026-08-08 Β§7 items 1-7 + P1-6/P2-8)
  • Fix duplicate field/const definitions in NetChannel.cs from the netkit-cloudward merge
  • Merge branch 'cleanup/netkit-cloudward-2026-08-02'
  • ck.proxy.pos: the guest's puppet becomes the pet's one position authority (MP-PETAIMDRIFT)
  • Phase-2 view-lease migration: SpawnKit's view lifecycle moves into NetKit
  • Solo-leg live results: core MP10 fix PASSES (zero refusals, clean census, viewID belt proven live); fix the caid false-positive TRIPWIRE it exposed
  • Docs: MP10 fix wave 2 (root cause + NetKit.Views + contamination watchdog)
  • Review fixes M1 + m1-m4: ghost bars-before-Character, per-view neutralize, self-contained disarm
  • NetKit.Views clone hygiene: neutralize-first, honest tripwires, disarm-not-refuse
  • 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

0.2.1 β€” 2026-07-30

  • Session resilience: unstick verb, [LOADGATE] watchdog, hardened goto
  • Hyena/Pearlbird tuning wave: HAO taunt, gifts, bone relic, feed rule