Files
Project-M/Docs/Vault/06_Roadmap/World_Model_Build_Spec.md
kronic 07a07ec64d Docs: LANTERN — record operator decisions (all parked questions resolved 2026-07-14)
Build Phase 1 gym first; standing ★-review auth; build A0 material foundation now;
delete siege branches. Phase 1: manual aim (auto-target cut), 4 discrete ability buttons,
Harpooner yanks target-to-you, roadmap 5 Sparks. Fresh save epoch (no migration).
Dark-side contact = non-telegraphed chip. Lantern = free-position plant.

Locked into: operator-questions (resolved summary), Phase 1 spec (open forks -> resolved),
currency spec (fresh saves), world-model Build Spec (operator-resolutions block).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 08:33:38 -07:00

43 KiB
Raw Permalink Blame History

title, date, tags, status, permalink
title date tags status permalink
LANTERN — World-Model Build Spec (review-hardened, Phase 0.3 gate) 2026-07-13
roadmap
lantern
netcode
world-model
build-spec
phase-0
review-hardened gamevault/06-roadmap/lantern-world-model-build-spec

Phase 0.3 gate output of Roadmap_Lantern_Slice, adopted under DR-048_Lantern_Adoption_Full_Pivot and recorded in DR-049_Lantern_World_Model_Design. Synthesized by the adversarial design review (wf_aa6fb661-e25, 22 agents, 0 failures — 18 findings, 12 CONFIRMED + 6 PARTIAL, 0 refuted) over Lantern_World_Model_Design_Proposal + the ground-truth code. It supersedes that proposal as the authority for Phase 2 world code.

LANTERN World-Model — Build Spec (review-hardened)

Status — Phase 0.3 gate output. This is the build contract that the ★ adversarial design review (netcode / determinism / reuse lenses, refute-first verified) hardened out of Lantern_World_Model_Design_Proposal.md. It supersedes that proposal as the authority for Phase 2 world code. Eighteen findings surfaced (12 CONFIRMED, 6 PARTIAL, 0 refuted); every one is folded below as either a locked decision or a tracked open risk. No world code is written outside this spec. The falsifiable slice test it serves: does pinning a pocket feel like winning something (Roadmap_Lantern_Slice Phase 3 gate).

Operator resolutions (2026-07-14)

The open forks this spec surfaced are now decided (Lantern_Operator_Questions):

  • NR-3 dark-side contact → non-telegraphed low chip/grab. Enemies in the dark may deal minor chip; a full skillshot-grade burst is only owed for a LIT (telegraphed) strike. The wind-up-START gate on the light-union stands; the dark-bite chip is a separate, non-telegraphed damage event.
  • RS-6 lantern placement → free-position plant. Only the atomic withdraw+persist idiom transfers from BuildPlaceSystem (not CanPlace/occupancy); a lantern plants at any valid pocket position.
  • END-1/EB-1 (RS-2 site-class c) → confirmed mothballed: DELETE the branches. The RegionTag→PocketTag pass removes the Core-fallback + structure-aggro branches from EnemyAISystem etc., not generalizes them.
  • Saves → fresh epoch (no migration; see Lantern_Currency_ReMean). SaveData v7 is additive over a fresh LANTERN save, not a co-op-Hades migration.

Locked decisions

Operator forks (locked pre-review, non-negotiable):

  • F-A — Co-located party, exactly one active pocket at a time. The party never splits across pockets. This is architecturally today's single-active-room + ping-pong two-slot arena (RegionMath, +1000u offset, 500u stride — RegionComponents.cs:39-57), re-keyed from a 2-value region to a per-pocket key.
  • F-B — Party light-union relevancy. Wicks (players) are always relevant to each other; world/enemy/hazard ghosts outside the party's combined lamp radius are never serialized to any client. Hard relevancy (GhostRelevancyMode.SetIsIrrelevant), never importance — "gamma-cranking cannot reveal what was never sent."

Design points the review CONFIRMED as sound (build on these as-is):

  1. Light-as-information = hard relevancy. SetIsIrrelevant never serializes an out-of-set ghost; untagged/global ghosts stay relevant for free (RegionRelevancySystem.cs:9-17). This is the correct and only mechanism.
  2. Global shared state rides the untagged director ghost. RunInfo/GoalProgress/CycleState are already spawned MINUS RegionTag (CycleDirectorSpawnSystem.cs:10-13) → relevant cross-pocket for free. The Lamp meter, descent-info scalars, and pinned-set all belong here.
  3. Pinning = freeze the content salt + save deltas. Generation is a pure function; only player mutation is stored (RoomFieldSystem Hash(seed,nodeId,sub) + BaseRestoreSystem born-correct replay). The tide-line drops out of this cleanly.
  4. RunDirectorSystem stays the sole FSM writer and keeps its single-writer + receiver-sets-command discipline (RunDirectorSystem.cs:11-35). The descent lifecycle is a rename+extend of the existing state machine, not a new one.
  5. Lamp radius as [GhostField] is already server-authoritative[GhostField]s replicate server→client only; no client can author one. The DP-4/NR-4 "client-authored radius exploit" premise is rejected: keep the radius derived from server-only fuel timers and sent to all (teammates must see each other's cones).
  6. GhostImportance is NOT needed for the slice. O(ghosts × partySize) with capped ghost counts (~≤40) is trivial. Held in reserve for bandwidth only.
  7. Layer-B light-union is connection-INDEPENDENT (relevant-to-any-member ⇒ relevant-to-all-party-connections) — compute once per pocket per tick, apply to all party connections. This holds only because of F-A + in-pocket respawn (see NR-2/RS-3 below); if that guarantee breaks, fall back to per-connection keying.

Review verdict

  • Netcode — PASS WITH CHANGES. 6 findings (4 CONFIRMED, 2 PARTIAL). No blocker; two changes are hard requirements before coding: the CombatFeedbackSystem alive-prune fix (NR-1) and the in-pocket death model (NR-2). Enemy engagement must gate on the light-union (NR-3).
  • Determinism — NEEDS REWORK. 6 findings (5 CONFIRMED, 1 PARTIAL); three HIGH. The proposal's seed model is internally contradictory (graph-from-descent-salt vs stable PocketId, DP-1/RS-1) and its persistence lifecycle citation is wrong (DP-2). These are re-specified below and MUST land before any world code — they are save-corruption / soft-lock class.
  • Reuse — NEEDS REWORK (on graph generation) / PASS WITH CHANGES (elsewhere). 6 findings (3 CONFIRMED, 3 PARTIAL); one HIGH (RS-1 — RunMapMath cannot be salvaged as-is). RegionTag→PocketTag is a semantic rewrite over ~23 sites in 15 files, not a rename (RS-2). Respawn/participant path is NOT free salvage (RS-3).

The contract

C1 — The seed model (resolves DP-1, DP-3, DP-6, RS-1)

Three seeds, one stable graph. INVARIANT: the pocket graph topology is a pure function of WorldSeed ONLY, byte-identical across every descent of a save. DescentSalt is NEVER an input to graph generation.

Seed Scope Persistence Feeds
WorldSeed per save (the shelf's identity) NEW SaveData v7 field, distinct from RunsCompleted/RunEpoch lineage RunMapMath.Generate(WorldSeed) → topology: LayerCount, per-layer widths, every node's RoomType/Biome/ShapeId, edges, PocketId space, BossNodeId
DescentSalt per descent (monotonic) derived from NEW SaveData v7 TotalDescents (see DP-6 below) per-dark-pocket CONTENT only: Hash(WorldSeed, PocketId, DescentSalt, subStream) — the RoomFieldSystem.cs:93/117/141/175 fold, re-meaned from RunSeed
PinnedSalt per pinned pocket server-only, frozen in PinnedPocketSave[] (v7) at plant time replaces DescentSalt for that pocket's content ⇒ regenerates identically every descent
PocketId stable graph-node id transient (regenerated) = RunMap.NodeId(layer,col) = layer*MaxWidth+col (RunMap.cs:94)

DP-1 / RS-1 resolution — split the two concerns RunMapMath fuses. RunMapMath.Generate today derives the ENTIRE graph (including L = 6 + Hash(s,0x1A)%5, RunMapMath.cs:52) from one seed, and RunDirectorSystem reseeds it every run (RunSeed = max(1u, Hash(RunEpoch, HostSalt)), RunDirectorSystem.cs:112). A pinned PocketId would therefore point at a structurally-different (or out-of-range) node next descent. Fix:

  • Feed RunMapMath.Generate the WorldSeed, never a per-descent salt. Rewrite proposal lines 46 & 61 accordingly ("the graph is generated from WorldSeed and is stable across descents; only per-pocket content re-knots via DescentSalt").
  • Only per-pocket CONTENT (RoomFieldSystem scatter, enemy roster, harvest) re-knots via DescentSalt; a pinned pocket uses its frozen PinnedSalt instead. Content depends on room type, so this is only sound because the type is now WorldSeed-stable.
  • Harden the resolver: RunMap.NodeAt(pinnedNodeId) (RunMap.cs:100) must bounds/width-check (reject nodeId >= Nodes.Length and col >= Width(layer)) so a future MaxLayers/topology change degrades gracefully instead of throwing or silently returning a default (all-zero ⇒ RoomTypeId.Combat) node.

DP-3 resolution — pinned predicate is ARRAY MEMBERSHIP, never salt != 0. PinnedSalt is a raw Hash output that CAN be 0, and JsonUtility 0-defaults missing v7 fields (SaveData.MinLoadableVersion = 2, additive floor). So:

  • A PocketId is pinned iff it has a row in PinnedPocketSave[] — state this explicitly; PinnedSalt-value is NEVER the predicate.
  • At the pocket-content RNG-seed call site apply the existing | 1u convention (RoomFieldSystem.cs:93 seeds new Random(Hash(...) | 1u)) — or salt == 0u ? 1u : salt if full-space collision-freeness matters — so a frozen salt of exactly 0 can't produce a degenerate Unity.Mathematics.Random. Never mutate the stored salt.
  • Do NOT route PinnedSalt through TickUtil.NonZero — it is a "0 = ready" tick sentinel, not a seed guard; NonZero(0)=1 would collide the salt-0 pocket with the salt-1 pocket's content. TickUtil.NonZero stays scoped to the actual ticks (lamp-fuel timers, descent timer).

DP-6 resolution — DescentSalt lineage must be PERSISTED or the dark repeats across quit+continue. RunEpoch is server-only working state, reset to 0 at restore, and HostSalt refolds from RunsCompleted (CycleDirectorSpawnSystem.cs:136) — so two sessions with the same RunsCompleted regenerate the identical salt sequence, and already-explored dark pockets reappear unchanged (the anti-persistence promise fails across the most common boundary). Fix:

  • Add SaveData v7 TotalDescents — a monotonic descent counter incremented on every Staging→Launching launch (RunDirectorSystem.cs:106-123), NOT gated on boss-clear like RunsCompleted.
  • Persist it from both save paths (the boss-clear autosave in SaveWriteSystem.cs, AND the quit-to-menu writer via the shared MetaSaveScan collector).
  • At restore (CycleDirectorSpawnSystem), seed the descent lineage from TotalDescents (e.g. RunRuntime.RunEpoch = TotalDescents and/or HostSalt = Hash(0x5EED0001u, (uint)TotalDescents)), so the next descent's salt is distinct across sessions.
  • Keep the re-knot DescentSalt fold DISJOINT from any live PinnedSalt (a reroll must never collide with a frozen pocket).
  • Residual gap (accept + note): a hard app-kill with no save write won't advance the persisted counter. If the fiction must survive crash/kill, save-on-launch (persist the incremented counter at the launch edge). Otherwise the dark repeats only after a crash — tolerable for the slice.

C2 — The pocket graph & muster (resolves DP-5, RS-1)

  • RunMapMath.Generate(WorldSeed) produces the stable spine. PocketId = NodeId(layer,col); the station is a permanent home pocket outside the node space (see C3 StationPocketKey).
  • Muster = the deepest pinned PocketId reachable from the station in the WorldSeed graph (BFS via RunMapMath.ReachableOptions/AllNodesReachable, RunMapMath.cs:178-223), not depth-alone. The dark frontier past the deepest pin is fresh each descent (slice target 35 dark pockets past the frontier).
  • DP-5 loss-floor INVARIANT: the station PocketId is a permanent, un-gutterable member of the pinned set and the guaranteed muster fallback. When the set is only the station (fresh save / full gutter), muster at the station. Store the pinned set as an explicit PocketId SET, never a "prefix" (per-lantern independent burn makes a contiguous-prefix assumption unsound). If the "continuous lit road" fiction matters, allow only the current deepest pinned pocket to gutter (frontier-only loss); otherwise accept holes but keep muster keyed on graph-reachability-from-station.
  • Save-time invariant + test: the persisted pinned set is never empty, always contains the station PocketId, and every persisted pinned PocketId is representable (col < Width(layer), layer < LayerCount) in the WorldSeed graph — so an un-pin cascade can never persist a stranded muster.

C3 — Relevancy: two hard-cull layers (resolves NR-1, NR-2, NR-3, NR-4, NR-6, RS-2, RS-3)

Layer A — pocket-occupancy scope (coarse). PocketTag { int PocketKey } replaces RegionTag (server-only, NOT a [GhostField]). Because the party is co-located (F-A), key the coarse cull off the run's single active PocketKey (read from the director ghost), not off each connection's own tag — so any temporarily-divergent participant (a downed/respawning Wick, C4) still sees the active fight. This is the connection-independent form of today's RegionRelevancySystem.cs:31-67.

  • StationPocketKey MUST be a disjoint sentinel. PocketKey = NodeId, and NodeId(0,0) = 0 collides with today's RegionId.Base = 0. Reserve a key outside the node-id space (e.g. -1 / int.MinValue) for the station so the home pocket never aliases the first dark pocket. Introduce IsStation(key) / IsDark(key) helpers.

Layer B — light-union relevancy (fine, party-union). Within the active pocket, a non-player ghost is relevant iff it lies within ANY party member's lamp radius (union). Connection-independent (locked decision 7). Wicks always relevant (added to no irrelevant set).

  • NR-1 / NR-6 — LOCK "once-lit-stays-relevant until pocket exit" (hysteresis), it is a REQUIREMENT, not an open question. A per-ghost server-only enableable latch (LitThisPocket), born unset at spawn, set the first tick the ghost enters any lamp, never re-cleared mid-pocket. This kills per-tick lamp-sweep thrash and sidesteps the relevancy-re-entry false-edge hazard. Because pocket ghosts are destroyed per pocket (not reused), the latch resets for free at teardown — confirm in code review that no ghost is ever carried across a pocket/sub-slot flip (the exact reuse point the codebase has been bitten on). EditMode test: enter pocket A on sub-slot 0, light ghosts, tear down, enter pocket B on the reused sub-slot, assert B's ghosts start unlit/irrelevant.

  • NR-1 — FIX CombatFeedbackSystem alive-prune directly (hysteresis alone only DEFERS the storm to pocket-exit). Today the prune-of-a-cached-alive-enemy path fires the full KILL package — Burst(_deathFx…), KillShake, KillFovKick, TryHold() hit-stop, kill flash, rumble (CombatFeedbackSystem.cs:295-306) — on the assumption "vanished while alive == I killed it." Genuine kills already fire the crunch at the Health 0-crossing (CombatFeedbackSystem.cs:237-247) while the ghost is still relevant, so:

    • An alive-vanish (cached Hp > 0 leaving _seen) must NOT fire the full package. The correct discriminator is "did this enemy's last replicated Health actually reach ~0?" (it won't for a relevancy drop) — a bare proximity gate copied from WorldFeedbackSystem.cs:118-130 is insufficient here because a light-boundary enemy can be inside the local player's own range and still pass it. Gate the crunch on the last-Health-≈0 condition (optionally AND proximity). On an alive-vanish, at most emit a subtle silent despawn puff.
    • At pocket teardown (Ascending/ChooseNext/pocket→next), explicitly suppress the alive-prune crunch entirely — the pocket is being torn down, not cleared.
    • Regression test (tunnelling-style, plain-Entities EditMode): cache an alive enemy, remove it from the Health query to simulate a relevancy drop, tick the presentation system, assert no kill cue (no TryHold/AddShake(KillShake)/PunchFov(KillFovKick)). Also cover the batch pocket-exit drop of N live enemies asserting zero crunches.
  • NR-3 — gate enemy WIND-UP COMMIT on the light-union, not just movement. AttackWindup.WindUpUntilTick is a [GhostField] specifically so the client renders the ~0.3s dodge tell (AttackWindup.cs:6-18); an enemy outside the union is never serialized, so its telegraph is never received — yet EnemyAISystem still commits the wind-up and appends a DamageEvent on server range alone (Grunt strike commit EnemyAISystem.cs:189-206, wind-up starts at :221/:362/:478). That is undodgeable damage from an actor whose fairness contract was culled — it breaks the skillshot pillar. Fix:

    • Reuse the SAME per-pocket-per-tick light-union set computed for Layer-B relevancy (connection-independent — the server AI is world-authoritative, no per-connection branching, no extra cost) and expose it to EnemyAISystem.
    • Require light-membership TRUE before a wind-up may START (:221/:362/:478); the commit-once-started rules (:189-206) then stay fair because the strike began lit.
    • Light-lead margin: an interpolated enemy that becomes relevant exactly at wind-up start renders ~interpolation-buffer late, compressing the tell. Require the enemy to have been lit for ≥ (interpolation delay) before it may commit, or budget the wind-up lead to cover it.
    • Movement stays server-only and unrestricted (enemies seek/loom/approach in the dark — that IS the fiction).
    • Design fork (recommended, satisfies "dimming is quiet-for-blind"): permit dark-side CONTACT only as a NON-telegraphed low chip / grab ("the dark bites"), never a full skillshot-grade burst — so a received telegraph is only ever owed for lit strikes, and blindness costs chip, not undodgeable damage. Hazard NR-1's once-lit hysteresis does NOT address this — it is a separate gate.
  • NR-4 — wire the pinned set as a [GhostField] DynamicBuffer, split from the cheap scalars. The pinned set is variable-length and grows across a campaign; it cannot ride RunInfo's scalar-only pattern. Model it as a bounded [GhostField] IBufferElementData on the untagged director ghost, copying the MetaTierState idiom exactly (CycleDirectorSpawnSystem.cs:123-126): PinnedPocketState { [GhostField] int PocketKey; [GhostField] uint CosmeticKey /*see DP-4*/ }, [InternalBufferCapacity(N)] with an enforced hard cap, no OwnerSendType (ownerless interpolated), restored born-correct at spawn via ecb.SetBuffer from persisted PinnedPocketSave[]. Re-serializes on every pin. Split concerns: the muster point + common HUD only need a single scalar [GhostField] (deepest-pinned PocketKey) which CAN live on the DescentInfo scalar component; only the full lit-MAP needs the buffer.

  • Server interaction guard preserved. Relevancy is client-visibility only; server AI/damage still act on ghosts in the dark (RegionRelevancySystem.cs docstring). Keep an equivalent server-side pocket guard (today's cross-region damage re-check) so cross-pocket interaction can't leak in the shared PhysicsWorld.

C4 — Death & respawn (resolves NR-2, RS-3)

Do NOT port PlayerRespawnSystem's warp-to-base + RegionTag=Base flip unchanged. Today it hard-warps a dead player to the base spawn ring and flips its server-only RegionTag to Base (PlayerRespawnSystem.cs:72-78, itself a soft-brick bugfix). Re-keyed to pockets, a single mid-fight death relocates that Wick into the STATION pocket while the party fights on — a transient two-occupied-pocket state that violates F-A, makes the vision's in-place flame-share revive physically impossible (the suit is 1000u away), and — because Layer-A would cull the base-warped player from the active pocket — hands them a blank fight. RunDirectorSystem does not abort on a single death (expeditionPlayers==0 only, :163), so the split persists for minutes.

Adopt option (a) — in-pocket death:

  • A downed Wick stays in the ACTIVE pocket as a guttering-suit entity with NO PocketTag flip (Dead-gated movement/aim/fire already handled by PlayerDeathStateSystem). It stays relevant to the party; spectating works; the Phase-3 in-place flame-share revive is reachable.
  • Gate PlayerRespawnSystem's Position write + pocket flip behind a party-wipe check (all-participants-down). A full wipe is already the expeditionPlayers==0Returning/Ascending path (RunDirectorSystem.cs:163,190,230,293), which cleanly warps everyone home together in one tick without splitting the party. Give the guttering suit its own respawn/revive timer that resolves in place at the active pocket origin (RegionMath.ExpeditionRoomOrigin(baseCenter, activeSubSlot)), writing position AND PocketTag = activePocketKey together (never the station key).
  • Re-audit the downstream coupling that DEPENDS on dead players leaving the expedition: the RoomReward/BoonOffer exit gate filters region == RegionId.Expedition specifically so a dead-respawned base player can't stall the party (RunDirectorSystem.cs:196-215). In-pocket respawn changes that assumption — the gate must be revisited so a revived in-pocket player is (or isn't) counted deliberately.
  • The co-op death/revive loop is NEW code, not salvage. Delete the proposal §4 "RunParticipant reused verbatim / rejoin via existing conscription path" claim — it is factually wrong (PlayerRespawnSystem sends dead players to the base ring; EnterRoom only re-teleports on the NEXT advance, RunDirectorSystem.cs:457-466; late joiners are never conscripted mid-run, :144-149). RunParticipant conscription/teleport/release is reused only for the launch/return roster.
  • If option (b) were ever preferred instead, Layer-A must be re-specified as connection-independent over the launch roster (the active pocket's ghosts relevant to every RunParticipant connection, including one tagged station). Option (a) is the locked choice.

C5 — The descent FSM (rename + extend RunDirectorSystem)

RunDirectorSystem stays the sole writer of the replicated lifecycle FSM. Append-only byte state remap (RunLifecycle.cs values are append-only — do NOT re-mean):

Today (value) LANTERN Note
Staging (0) AtStation muster/prep/ready at the hub (the permanent home pocket / StationPocketKey)
Launching (1) Descending Bell-down telegraph; pick descent identity (bump TotalDescents, C1/DP-6)
InRoom (2) InPocket active pocket populated
RoomReward (3) PocketClear mutation thresholds fire here (pocket-clear beats, never mid-combat)
Returning (4) Ascending Bell recall (teleport) or lit-road walk home; bank; → AtStation
RouteSelect (5) ChooseNext pick the next pocket edge
RoomExplore (6) PocketExplore loot + plant-a-lantern window
  • Bell transit = teleport (the proven PortalCommandEnterRoom teleport, RunDirectorSystem.cs:236-283,438-469). Lit-road walk-home is a stretch goal.
  • ~3040 min hard session cap = a NEW server descent timer (plain uint tick via TickUtil.NonZero) that forces the Bell recall.
  • EnterPocket = today's EnterRoom: publish the node as single plan authority, flip the ping-pong sub-slot, bump RoomEpoch, teleport participants (Position write in place, never FromPosition). Now also fires the lazy pinned-pocket restore (C6).

C6 — Pinning, session-burn & the Lamp meter (resolves DP-2, RS-5, RS-6)

Plant a lantern (PocketExplore) = a placement RPC reusing only the co-op-atomic PATTERN from BuildPlaceSystem.cs:87-90 (in-place StorageMath.Withdraw + reservation in the RPC foreach; only Instantiate via ECB). It freezes PinnedSalt = Hash(WorldSeed, PocketId, DescentSalt), adds the PocketId row to PinnedPocketSave[] (v7), and persists.

  • RS-6 — BuildPlace/BaseRestore reuse is PATTERN-ONLY; build two NEW server systems. Both existing systems are hardwired to the permanent base: BuildPlaceSystem resolves cells via BaseGridMath on BaseAnchor and bakes RegionTag{Base} (:94,:103); BaseRestoreSystem replays one-shot at load, RequireForUpdate-gated on PendingStructure+StructureCatalog+BaseAnchor (:36-39), hardcodes RegionTag{Base} (:95) and CellToWorld base coords (:77), destroys the carrier (:128). A dark pocket lives at a transient sub-slot origin and doesn't exist at load. The new pieces require: coordinate authority = RegionMath.ExpeditionRoomOrigin(baseCenter, activeSubSlot); the PocketTag{PocketKey} tag, never RegionTag{Base}; replay gated on pocket-ENTRY (re-runnable per entry, NOT a self-destructing boot carrier); and a per-ACTIVE-pocket occupancy scanBuildPlaceSystem's scan over ALL PlacedStructure ghosts (:54-60) would false-collide cells across the two transiently-coexisting sub-slot arenas in the shared PhysicsWorld. Resolve before scoping: whether a waypoint-lantern is a free-position plant (then only the atomic withdraw+persist idiom transfers) or a build-grid cell placement (then the CanPlace/occupancy machinery transfers too).

  • DP-2 — pinned-pocket RESTORE is per-entry LAZY replay, NOT a boot one-shot; do not cite BaseRestoreSystem as the lifecycle template. Reuse only its born-correct MECHANISM: a staging carrier holding PinnedPocketSave[] staged at spawn, and Health.Current set in the SAME EntityCommandBuffer as Instantiate (BaseRestoreSystem.cs:87-94 — a deferred set leaks baked Max to clients for one snapshot; this hazard is STRONGER at runtime, because a pocket's ghosts become relevant to the party over Layer-B the same tick they spawn). Concretely:

    • Restore fires inside EnterPocket (C5), keyed on PocketId. At boot the party is AtStation; pinned pockets are needed only when the descent reaches them via muster.
    • On entry: regenerate the pocket geometry deterministically from the frozen PinnedSalt, THEN instantiate that pocket's structure/harvest deltas into the active sub-slot (ExpeditionRoomOrigin), tag PocketTag{PocketKey}, set Health born-correct same-ECB.
    • The carrier is NOT destroyed after one replay — it survives to re-replay on each subsequent entry. Key delta rows per PocketId so entry N replays only that pocket's rows.
  • Session-only burn: fuel is a runtime server countdown per lit lantern (never persisted — loads full, drains only while the session runs, honoring "no offline punishment"). Guttering telegraphs long/loud; full gutter un-pins per the loss-floor invariant (C2 — station un-gutterable; frontier-only or reachability-preserving).

  • RS-5 — burn adds NO second writer to the replicated FSM. The replicated pinned set ([GhostField] buffer, C3/NR-4) and Lamp meter (GoalProgress-equivalent, its own component) are SEPARATE replicated components on the director ghost — multiple writers to different components on one ghost is already proven safe (RoomEnemyDirectorSystem writes ExpeditionObjective on the same director ghost). Burn maintains ONLY the server-only per-lantern fuel timers and, on full gutter, sets a server-only signal/command (receiver-sets-command idiom, mirroring PortalCommand/RouteCommand). RunDirectorSystem — the FSM, which also performs muster — is the SOLE writer of the replicated pinned set, consuming that signal one-tick-late (like ExpeditionObjective). This puts muster's READ and burn's un-pin WRITE in one system (no cross-system read/write ordering) and needs NO [UpdateBefore/After] edge around RunDirectorSystem or CyclePhaseSystem — dodging the sort-cycle the codebase warns is invisible to EditMode and throws only at Play world creation (RunDirectorSystem.cs:32-35). Play-validate world creation regardless.

  • Lamp meter = banked light deposited at a lit lantern, on the GoalProgress-style replicated component on the untagged director ghost. Proven reuse, low risk.

C7 — The wire contract (replicated vs server-only)

Global untagged director ghost:

  • DescentInfo (≈ RunInfo, all [GhostField] SCALARS): lifecycle byte, active PocketId, DescentSalt-derived descent seed the client regenerates the graph + dark-pocket dressing from, deepest-pinned PocketKey (scalar — muster point + common HUD), Lamp meter charge/target, party Loudness sum.
  • PinnedPocketState [GhostField] DynamicBuffer (NR-4): the full lit-map set. [InternalBufferCapacity(N)], hard-capped, ownerless. Carries PocketKey + a cosmetic key (DP-4 below).

Server-only (never [GhostField] — adding these never re-bakes the ghost): DescentSalt/TotalDescents lineage, per-pocket PinnedSalt, PocketTag, RunParticipant, LitThisPocket hysteresis latch, lamp fuel timers, pocket teardown tags, the light-union set.

Player ghost: ready flag + lamp radius as [GhostField] sent to all (server-authoritative, decision 5 — clients render own + teammates' cones; server computes relevancy) + Loudness pip.

  • DP-4 — replicate a cosmetic key for pinned pockets or the client draws different dressing each descent. RoomDressingSystem.cs:68 keys dressing off ri.RunSeed (the per-descent seed) hashed with room index; for a PINNED pocket that must "regenerate identically," the client would key on the changing descent seed and mismatch the server's frozen-salt gameplay ghosts (cosmetic, not a gameplay desync — ghosts stay authoritative — but it undercuts the pinned fantasy). Fix: carry the frozen PinnedSalt (or a derived cosmetic hash) as the PinnedPocketState.CosmeticKey [GhostField]; client dressing keys on THAT for pinned pockets, falling back to the descent seed for dark pockets. Reconcile §6's "server-only PinnedSalt" with the client's regen role via this replicated derived key (the raw salt stays server-only).

Salvage & rename map (corrected against the reuse lens)

Existing Verdict What actually happens
RunMapMath.Generate REWRITE the seed contract (RS-1, HIGH) Feed WorldSeed (not per-descent RunSeed); topology becomes descent-stable. Content re-key moves to RoomFieldSystem. Bounds-harden NodeAt.
RunMap/RunMapNode/RoomTypeId Reuse PocketId = NodeId, transient regen, byte type ids append-only.
RunDirectorSystem Reuse + extend Rename states (append-only bytes); add descent timer; add lazy pinned-restore call in EnterPocket; sole writer of the replicated pinned set (RS-5).
RegionTag{byte}PocketTag{int PocketKey} SEMANTIC REWRITE, not a rename (RS-2/NR-5, ~23 sites / 15 files) Every == RegionId.Base/Expedition binary must be reclassified — see the per-site table below.
RegionRelevancySystem Rewrite to two layers Layer-A keyed off the run's single active PocketKey (connection-independent); Layer-B light-union + hysteresis latch.
RoomTag/RoomTeardown Rename → PocketTag/teardown Mechanism intact; per-pocket occupancy scope.
RoomFieldSystem Hash(seed,id,sub) Re-key Hash(WorldSeed, PocketId, DescentSalt-or-PinnedSalt, sub); seed | 1u guard stays.
ping-pong two-slot arena (RegionMath) Reuse verbatim ExpeditionRoomOrigin, +1000/500u stride — the pocket transit coordinate authority.
SaveData (+born-correct staging) Extend → v7 New: WorldSeed, TotalDescents, PinnedPocketSave[]. Additive floor keeps v2v6 loadable.
BuildPlaceSystem PATTERN-ONLY (RS-6) New plant system reuses the atomic withdraw+reserve idiom; NOT the base-grid/RegionTag{Base} machinery.
BaseRestoreSystem PATTERN-ONLY (DP-2/RS-6) New per-entry lazy replay reuses the born-correct same-ECB Health mechanism; NOT the boot-once/carrier-destroy/RegionTag{Base} lifecycle.
PlayerRespawnSystem REWRITE the death model (NR-2/RS-3, HIGH) In-pocket respawn; warp-to-station gated behind party-wipe. New guttering-suit + flame-share revive is NEW code.
CombatFeedbackSystem FIX the alive-prune path (NR-1) Gate the kill crunch on last-replicated-Health-≈0, not on the vanish edge.
EnemyAISystem GATE wind-up start on the light-union (NR-3) Movement unrestricted; commit requires lit + light-lead margin.
GoalProgress Reuse Lamp-meter readout on the untagged ghost.
RoomDressingSystem Re-key for pinned pockets (DP-4) Key on replicated CosmeticKey for pinned, descent seed for dark.

RegionTag → PocketTag per-site classification (RS-2/NR-5 — audit each, this is a discrete task with a per-site checklist):

  • (a) station-vs-not-station membership → rewrite as IsStation/IsDark: PlayerRespawnSystem.cs:78 (now the party-wipe branch, C4); BuildPlaceSystem.cs:103, BaseRestoreSystem.cs:95 (place → station key); the RunDirectorSystem.cs:90/203 region == RegionId.Expedition counts + EnterRoom :460 region = RegionId.Expedition write become "in any active pocket" (!= StationPocketKey) / "write the specific entered pocket's key."
  • (b) same-pocket equality → keep as pure key equality, widen the type only: EnemyProjectileDamageSystem.cs:96, EnemyAIMath.PickWeightedNearest (EnemyAISystem.cs:149), harvest→ledger routing (MeleeComboSystem, ResourceHarvestSystem), boss/geyser/hazard "in expedition" guards.
  • (c) siege-era branch to DELETE, not port: the Core-fallback (huskCoreAlive, EnemyAISystem.cs:126/242/385/596, END-1) and structure-aggro (EB-1) branches inside otherwise-live systems. Decide the END-1/EB-1 mothball cut BEFORE porting so these branches are deleted, not generalized across N pockets.

Build order

Each step ends with refresh_unity scope=scriptsread_console (no Burst/source-gen errors) before proceeding. Wire/bake-churn classified per step; L1 = EditMode determinism/invariant tests, L2 = Play world-creation (sort-cycle is Play-only), L3 = focused two-client Play smoke (relevancy/gamma test). Scope note (RS-4): this first spec is the "pinning feels like winning" half — Layer-A + seed-pin persistence + muster + FSM rename. Layer-B light-union is deferred to the roadmap's separate Phase-1 relevancy-light ★review (Roadmap_Lantern_Slice.md:40,45); the steps below carry it as a distinct phase so it isn't confounded with pinning. The Lamp meter (proven GoalProgress reuse) and session-cap timer (plain server timer) ride along at low risk — do NOT split them into their own gates.

  1. Seed model + SaveData v7 (no ghost churn; save-format additive). Add WorldSeed, TotalDescents, PinnedPocketSave[] to SaveData (v7, MinLoadableVersion unchanged). Split RunMapMath.Generate onto WorldSeed; move content re-key into RoomFieldSystem; bounds-harden RunMap.NodeAt. L1: DP-1 test (Generate(WorldSeed) byte-identical across a DescentSalt sweep; pinned PocketId same RoomType/Biome across the sweep; BossNodeId invariant); DP-3 test (pinned predicate = array membership, salt-0 handled); DP-6 test (two loads of the same on-disk save yield different first-descent DescentSalt once TotalDescents advanced; consecutive descents differ); DP-5 save-time invariant test (pinned set non-empty, contains station, all representable).
  2. RegionTag → PocketTag semantic pass (server-only component — NO ghost re-bake; per-site checklist RS-2/NR-5). Introduce PocketTag{int PocketKey} + IsStation/IsDark + StationPocketKey sentinel. Reclassify all ~23 sites (a/b/c). Delete END-1/EB-1 (c) branches. L1: existing region tests (RunRosterRegressionTests, PlayerRespawnSystemTests, damage/aggro) re-pass under the widened type. L2: Play world-creation clean (no sort-cycle).
  3. Descent FSM rename + session-cap timer (ghost-field VALUE churn only — byte state values are append-only, no re-mean → no re-bake). Rename RunLifecycle/RunInfo semantics; add descent timer. L1: RunDirectorTraversalTests/RouteSelectSystemTests re-pass. L2: Play.
  4. In-pocket death model (NR-2/RS-3 — behavior change, no wire change). Gate PlayerRespawnSystem warp behind party-wipe; guttering-suit in-place respawn; revisit the BoonOffer region==Expedition gate. L1: new respawn test (single death keeps PocketTag = active key, position at pocket origin; full wipe warps all together). L3: two-client — one dies mid-fight, confirm they stay in the fight and the party never splits.
  5. Pinning + persistence: plant RPC + lazy per-entry restore (RPC-COLLECTION churn: new IRpcCommand wire type must be unconditional; ghost re-bake: new PinnedPocketState [GhostField] buffer + Lamp-meter component + scalar [GhostField]s on the director ghost — server+client bake the same prefab so hash matches). New plant system (pattern from BuildPlaceSystem), new per-entry restore system (born-correct mechanism from BaseRestoreSystem, carrier survives), muster-at-deepest-reachable, RunDirector sole writer of the pinned set consuming the burn signal one-tick-late. L1: DP-2 born-correct test (pocket-entry Health set same-ECB); RS-5 (pinned set has exactly one writer). L2: Play world-creation clean (RS-5 sort-cycle guard — mandatory). L3: plant → quit → continue → re-descend → pinned pocket regenerates identically with deltas; unpinned dark pocket re-knots.
  6. Session-only burn + Lamp meter (server-only fuel timers + one replicated component). Burn drains fuel, signals un-pin on full gutter honoring the loss-floor invariant. L1: un-pin never persists a stranded muster (DP-5); burn never fires offline. L3: gutter a frontier lantern, confirm station stays lit and muster resolves.
  7. [Deferred to Phase-1 relevancy-light ★review] Layer-B light-union + hysteresis + CombatFeedbackSystem fix + EnemyAISystem wind-up gate (NR-1/NR-3/NR-6). Ghost re-bake only if the lamp-radius [GhostField] is added here. L1: NR-1 alive-prune regression (no crunch on relevancy drop; batch pocket-exit zero crunches); NR-6 latch reset across sub-slot reuse. L3: gamma test (nothing outside the union is on the wire); no phantom kill-crunch during a lamp sweep; no undodgeable dark strike.

Open risks (§implementer-watch)

  • [NR-4 · PARTIAL] Pinned-set buffer capacity vs campaign growth. The [GhostField] DynamicBuffer cap N must cover a persistent campaign's lit map. If pocket ids are provably small/stable within the WorldSeed graph a fixed bitmask is cheaper, but the campaign-growth concern makes the capped buffer the safer default. Watch: PinnedPocketState [InternalBufferCapacity] and the enforced cap; the MetaTierState precedent at CycleDirectorSpawnSystem.cs:123-126. Re-serializes on every pin — profile if pin frequency is high.
  • [NR-6 · PARTIAL] Hysteresis latch reset depends on "no ghost reused across pockets." The LitThisPocket latch resets for free ONLY if pocket ghosts are destroyed per pocket. Watch: the sub-slot flip in RunDirectorSystem.EnterRoom (:442, run.ActiveSubSlot = layer & 1) and RoomTeardown.DestroyRoom — confirm no ghost survives a teardown onto the reused slot. EditMode test in step 7.
  • [DP-3 · PARTIAL] Salt-0 seed coercion choice. | 1u (matches RoomFieldSystem.cs:93) collapses even→odd pairs; salt == 0u ? 1u : salt preserves the full 32-bit space. Watch: the pocket-content RNG-seed call site in the new restore/field path — pick one and document; do not mutate the stored PinnedSalt.
  • [RS-4 · PARTIAL] Scope discipline. The falsifiable test ("pinning feels like winning") needs only steps 16; Layer-B (step 7) is the roadmap's separate ★review (Roadmap_Lantern_Slice.md:40). Watch: keep session-burn behind a toggle during the pinning playtest so un-pin churn doesn't confound it; do NOT fold Layer-B into the pinning spec.
  • [RS-5 · PARTIAL] Sort-cycle is Play-only. The one-tick-late burn signal avoids an [UpdateBefore/After] edge, but ANY new cross-system ordering around RunDirectorSystem/CyclePhaseSystem is invisible to EditMode. Watch: RunDirectorSystem.cs:32-35 — Play-validate world creation after steps 3, 5, 6 regardless of green tests.
  • [RS-6 · PARTIAL] Lantern placement granularity is unresolved. Free-position plant vs build-grid cell decides how much of BuildPlaceSystem transfers. Watch: resolve BEFORE scoping step 5; if free-position, only the atomic withdraw+persist idiom (BuildPlaceSystem.cs:87-90) transfers, not CanPlace/occupancy.
  • [NR-3 open fork] Dark-side contact policy. Whether the dark deals non-telegraphed chip or nothing at all is a feel call. Watch: EnemyAISystem.cs:189-206 (commit) and :221/:362/:478 (wind-up starts) — the gate goes on wind-up START; the chip path (if adopted) is a separate non-telegraphed damage event.
  • [C1/DP-6 residual] Crash before save loses descent-counter advance. Accepted for the slice (dark repeats only after a hard kill). Watch: if the fiction must survive crash, add save-on-launch persisting the incremented TotalDescents at the launch edge (RunDirectorSystem.cs:106-123).

Findings ledger

ID Lens Sev Verdict Resolution (spec section)
NR-1 netcode MED CONFIRMED LOCK once-lit hysteresis + FIX CombatFeedbackSystem alive-prune on last-Health-≈0 + regression test — C3
NR-2 netcode HIGH CONFIRMED In-pocket death; warp-to-station gated behind party-wipe; flame-share revive is new code — C4
NR-3 netcode MED CONFIRMED Gate enemy wind-up START on the light-union + light-lead margin; movement free; dark = chip fork — C3
NR-4 netcode MED PARTIAL Pinned set = [GhostField] buffer (MetaTierState idiom) + scalar deepest-pin; lamp radius already server-authoritative — C3/C7
NR-5 netcode MED CONFIRMED RegionTag→PocketTag = audited semantic pass, disjoint StationPocketKey, per-site a/b/c — Salvage map
NR-6 netcode LOW PARTIAL Server-only LitThisPocket latch, resets on per-pocket ghost destroy; confirm no reuse + test — C3/Open risks
DP-1 determinism HIGH CONFIRMED Generate seeded by WorldSeed ONLY; DescentSalt never a graph input; bounds-harden NodeAt — C1
DP-2 determinism HIGH CONFIRMED Per-entry lazy restore (not boot-once); reuse born-correct same-ECB Health mechanism, carrier survives — C6
DP-3 determinism HIGH PARTIAL Pinned predicate = array membership; |1u/==0?1u at seed site; never TickUtil.NonZero the salt — C1
DP-4 determinism MED CONFIRMED Replicate a CosmeticKey per pinned pocket; client dressing keys on it (fallback descent seed) — C7
DP-5 determinism MED CONFIRMED Station un-gutterable + muster = deepest reachable-from-station; save-time invariant + test — C2
DP-6 determinism MED CONFIRMED Persist TotalDescents (per-launch, both save paths); seed lineage from it at restore — C1
RS-1 reuse HIGH CONFIRMED RunMapMath NOT salvageable as-is; split spine (WorldSeed) from content (DescentSalt/PinnedSalt) — C1/Salvage map
RS-2 reuse MED CONFIRMED ~23 binary RegionId sites in 15 files = semantic rewrite w/ per-site checklist — Salvage map
RS-3 reuse MED CONFIRMED Respawn/participant path NOT free salvage; in-pocket respawn + revisit BoonOffer gate — C4
RS-4 reuse MED PARTIAL Scope first spec to pinning (A + persistence + muster + FSM); defer Layer-B to Phase-1 review — Build order
RS-5 reuse MED PARTIAL Pinned set/Lamp = separate components; burn signals server-only; RunDirector sole writer, one-tick-late, no ordering edge — C6
RS-6 reuse LOW PARTIAL BuildPlace/BaseRestore = pattern-only; new systems, pocket coords, per-pocket occupancy; resolve plant granularity — C6/Open risks