--- title: LANTERN Phase 1 — Combat Gym Build Spec (review-hardened) date: 2026-07-14 tags: [roadmap, lantern, combat, phase-1, build-spec, netcode] status: review-hardened permalink: gamevault/06-roadmap/phase1-combat-gym-build-spec --- > Pre-code gate output for Phase 1, adopted under [[DR-050_Lantern_Phase1_Combat_Design]]. Synthesized by the > adversarial design review (`wf_c7575828-934`, 23 agents, 0 failures — 19 findings, 8 CONFIRMED + 11 PARTIAL, > 0 refuted) over [[Lantern_Phase1_Combat_Gym_Build_Spec]] (the pre-review design) + the ground-truth combat > code. It **supersedes that design spec** as the implementation contract. No Phase 1 code is written outside it. # LANTERN Phase 1 — Combat Gym Build Spec (review-hardened) > **Status: pre-code gate output.** This is the synthesized product of the adversarial design review (netcode / reuse / determinism lenses) over `Lantern_Phase1_Combat_Gym_Build_Spec.md`. It supersedes the pre-review design spec as the implementation contract. 19 findings (8 CONFIRMED, 11 PARTIAL, 0 REFUTED) are folded in below. No Phase 1 code is written outside this document. ## Locked decisions **Operator forks (2026-07-14, `Lantern_Operator_Questions`) — unchanged, all validated sound by the review:** - **Socket input = 4 discrete ability buttons.** `PlayerInput` gains four independent `Socket0..3` `InputEvent`s (not a single `Fire` + `ActiveSocket` byte). The review confirms this is the correct feel choice and that it *forces* the `AbilityFireSystem` 7-type-query restructure — accepted as the single biggest code change of Phase 1. - **Aim = manual; soft auto-target CUT.** Every socket fires along raw `PlayerInput.Aim` (`PlayerFacing.Direction`). Keep the reticle (`AimReticleSystem`) and keep the plumbing. Default per-socket `AutoTargetRange = 0`. The existing server-only gamepad assist cone (`AbilityFireSystem.cs:206-215`) survives as an *optional* per-Spark in-arc assist only. - **Harpooner reel = pull the target to you.** The signature verb yanks the hit enemy toward the caster. The review CONFIRMED (RS-6) the salvaged pull flag does **not** implement this — see the reel contract below; it becomes new code, not a salvage. - **5 Sparks** = decoy-wisp, hook/pull, vortex, blink, one zone. **Design points the review CONFIRMED sound (build on these as-is):** - The combat substrate ports almost whole. The predicted-spawn projectile pipeline, `AbilityDatabase`/`AbilityDefBlob`/`AbilityArchetype` blob dispatch, `MeleeComboSystem`, `DashSystem`, and the client feel systems are the correct chassis. - **`DashSystem` is the correct Blink template** — and the correct template *because* it drives `CharacterControl.MoveVelocity` every predicted pass and never writes `LocalTransform` (`DashSystem.cs:77-93`). "DashSystem-style teleport" in the pre-review spec is a wording bug; Blink is a velocity blink, not a transform write (DB-5, NP-3). - **The Cone archetype is the correct precedent for every non-projectile effect** — predict the cooldown on both worlds, gate the effect behind `if (isServer)` (`AbilityFireSystem.cs:143-172`). Aoe/Hitscan/decoy follow it, NOT the projectile predict-spawn path (NP-6, RS-4). - **`AbilitySocket` modelled on `EquipmentSlot`** (cold, server-sole-writer, socketed in the hub) is correct. `SocketCooldown` is **not** an `EquipmentSlot`-shaped cold buffer — it is a hot owner-predicted cooldown; see contract (NP-4). - **Mutations stay Phase 4.** Sparks are `AbilityDefBlob` rows; the `BoonEffects` substrate warps them later with no re-bake. Phase 1 ships sockets + Spark defs only. ## Review verdict - **Netcode — pass-with-changes** (3 CONFIRMED / 3 PARTIAL). The pipeline salvages; two HIGH mandatory changes: the `SpawnId` bit budget must reserve socket bits (NP-1), and the "KEEP as-is" client feel layer actually depends on the reworked components (NP-5). - **Reuse — pass-with-changes** (3 CONFIRMED / 4 PARTIAL). Three HIGH: the same `SpawnId` collision (RS-1), the Aoe/spawn archetype must not predict-spawn on the client (RS-4), and the "KEEP as-is" feel layer is a real migration (RS-2). Two salvages are overstated (Harpooner reel RS-6, player-owned geyser chassis RS-5) — both become new code. - **Determinism — pass-with-changes** (2 CONFIRMED / 4 PARTIAL). HIGH: `SpawnId` budget (DB-3) and the single-ghost-type classifier (DB-4). The 7-arg cap has a proven in-file fix (DB-1); Blink and windup need rollback-safe idempotent resolution (DB-5, DB-6). No lens requires a full redesign; the substrate is sound and the socket grammar reworks on top of it. Every HIGH clusters on two coupled issues: **the `SpawnId` key** and **the "keep vs rework" mislabel of the client feel layer.** ## The contract ### 1. `SpawnId` repack — the coupled netcode core (NP-1, NP-2, RS-1, DB-3, DB-4) The current key is fully consumed and carries no socket discriminator: ``` // AbilityFireSystem.cs:232 (current — obsolete under 4 sockets) uint spawnId = (owner << 16) | ((absoluteFireCount & 0x0FFF) << 4) | (s & 0xF); // 16 owner 12 fireCount 4 fork ``` With 4 discrete sockets each carrying an **independent** `InputEvent.Count`, two sockets firing the same-prefab projectile on one tick at equal per-socket counts (near-guaranteed early: both `0→1`) produce **identical `SpawnId`s** → `ProjectileClassificationSystem` (matches purely on `incoming.SpawnId == ProjectileLookup[predictedEntity].SpawnId`, `:171`, first hit `RemoveAtSwapBack`, `:177`) mis-binds one predicted entity and orphans the other → visible mis-prediction snap. **LOCKED — repack to a 32-bit layout that reserves socket bits without touching the fork bits:** ``` owner(14) | socket(2) | fireCount(12) | fork(4) = 32 bits exact ``` - `socket(2)` = the firing socket index 0..3. - `fireCount(12)` sourced from the **firing socket's own** `InputEvent.Count` (`applied.InternalInput.Socket{n}.Count`), NOT a shared `Fire.Count`. Wraps at 4096 shots — consistent with the existing 12-bit wrap philosophy. - `fork(4)` **untouched** — Fork mutations are Phase 4 (`AbilityFireSystem.cs:222` caps forks at 8) and still need all 4 bits. Do **not** steal them for socket. - `owner(14)` = 16383 range, ample for co-op. - Update the two doc comments that still describe the obsolete `(ownerNetId << 16) | absoluteFireCount` form: `Projectile.cs:21-24` and `ProjectileClassificationSystem.cs:14-16`. A single monotonic per-player counter alone does **not** fix this — two shots on the same tick read the same counter value; per-shot socket bits are required regardless. **Projectile ghost-prefab model — LOCKED to the single shared prefab (option A):** every Projectile-archetype Spark (including hook/pull — its pull is a `ProjectileEffectState` flag, not a distinct prefab) shares the one `ProjectileSpawner.Prefab` ghost type, differing only by snapshotted `Projectile` stats + `ProjectileEffectState` seeds (`AbilityFireSystem.cs:244-259` already sets these at spawn). This keeps `ProjectileClassificationSystem`'s single resolved `m_GhostType` (`:82-98`) valid. **Bake-time guard:** warn/fail if any `AbilityPrefabElement.Prefab` for an `Archetype == Projectile` ability `!= ProjectileSpawner.Prefab`. Generalizing the classifier to N ghost types is an open risk (deferred; see §implementer-watch DB-4) — only if art requires distinct projectile meshes. **Regression test (L2):** two sockets fire the same-prefab projectile on one tick with equal per-socket counts → assert two **distinct** `SpawnId`s and that each predicted entity classifies to its own matching server ghost (assert adopted-entity identity, not ghost count — the failure mode is a cross-adopt swap, not a double-spawn). ### 2. `AbilityFireSystem` socket restructure + the 7-type-cap fix (DB-1) The fire query is at exactly 7 type args today (`AbilityFireSystem.cs:108-113`). Adding the socket buffers as query args = 9 args → `SystemAPI.Query` source-gen (`*.g.cs`) fails per the CLAUDE.md 7-arg rule. The proven in-file fix: - **DROP `AbilityRef` and `AbilityCooldown` from the query** (both are replaced by the socket buffers). New query = **4 type args**: `RefRO`, `RefRO`, `RefRO`, `RefRO` + `.WithEntityAccess()`, filters `.WithAll().WithDisabled()`. - Read inside the main-thread `foreach`, keyed by the player entity, via lookups (mirroring the existing `m_BoonEffectsLookup` `ComponentLookup` at `:131` and `GetBuffer>(entity)` at `:192`): - `AbilitySocket` — read-only `BufferLookup`. - `SocketCooldown` — read-**write** `BufferLookup` (per-socket `NextFireTick` write). - per-socket `EffectiveAbilityStats` — read-only `BufferLookup` (see §7). - `BoonEffects` — existing `ComponentLookup`. - These are `DynamicBuffer`s → **`BufferLookup`, not `ComponentLookup`.** Declare/`.Update()` in `OnCreate`/`OnUpdate` like `m_BoonEffectsLookup`. - **Loop sockets 0..3 per player** inside the `foreach`. Per-socket RW cooldown write stays main-thread (no parallel-buffer aliasing) → **no `IJobEntity` rewrite required.** - **Hard rule:** never add `AbilitySocket`/`SocketCooldown`/`EffectiveAbilityStats`-buffer as `SystemAPI.Query` type args. - Keep `Unity.Transforms` + `Unity.Physics` as DIRECT `ProjectM.Simulation` asmdef refs (already true) so `LocalTransform` source-gen in `*.g.cs` doesn't regress to CS0246/CS8377. - Step 1 need not literally keep `AbilityRef` in the query — bake the new buffers alongside the untouched legacy query, then step 2 swaps the query wholesale. There is no intermediate state needing both. ### 3. Per-socket data model - **`AbilitySocket`** — `[GhostComponent(SendToOwnerType.All)] [InternalBufferCapacity(4)] IBufferElementData`, one row per socket = `byte SparkId` (0 = empty). Server-sole-writer, socketed in the hub, **not predicted**. Cold — this is the genuine `EquipmentSlot` analog (`EquipSystem` writes it). - **`SocketCooldown`** (NP-4) — a **HOT, owner-PREDICTED** cooldown. Its proven shape is the scalar `AbilityCooldown` `[GhostField]`, **NOT** the cold `EquipmentSlot` buffer — do not cite `EquipmentSlot` as its precedent. Preferred shape: a single `IComponentData` holding a fixed 4-slot cooldown that stays index-addressable — `unsafe fixed uint Next[4]` or a 4-`[GhostField]` fixed-array wrapper with an indexer — scoped `[GhostComponent(SendToOwnerType.All)]` (an improvement over today's `AbilityCooldown`, which has no `[GhostComponent]` and implicitly sends to all). Rationale: firing is by socket index (Burst can't index named scalar fields without a switch), and a scalar rollback path is lighter/more proven than a per-tick-mutating `[GhostField] IBufferElementData`. If a `[GhostField]` buffer is kept for indexing convenience, do **not** lean on `EquipmentSlot` — it is a predicted buffer, and the mandatory L3 rollback validation below applies. Route every stored tick through `TickUtil.NonZero`, compare via `NetworkTick.IsNewerThan` (never raw `uint`). - **4 `Socket0..3` `InputEvent`s** on `PlayerInput` (`IInputComponentData`). Each carries its own `.Count`. Touches the command serializer → command-collection hash must match across peers (wire churn below). ### 4. Manual-aim grammar + windup resolve tick (DB-6) Every socket fires along raw `PlayerFacing.Direction`. Add a static `WindupTicks` to the ability def so player abilities telegraph (reuse the enemy telegraph vocabulary client-side). `AbilityFireSystem` today fires on the same tick as the press (`Fire.IsSet`, `:116`) and holds no cast state. Deferring the spawn by `WindupTicks` **must** resolve on a tick that is a pure function of replicated data on both worlds. A non-replicated local `fireAtTick` (like `DashState`, which is not restored on rollback — `DashSystem.cs:78-80`) would resolve on different ticks under reprediction → the projectile spawns at different positions, and the `fireCount`-keyed `SpawnId` **cannot detect** the divergence (it silently classifies as a match then snaps). **LOCKED (stateless, minimal delta):** each predicted tick, resolve socket S if the historical command at `(serverTick − WindupTicks)` had socket S's event set — reuse the `GetDataAtTick` pattern (`:193` already does `GetDataAtTick(serverTick, …)`; change to `serverTick − WindupTicks`). Take the `fireCount` for the `SpawnId` from **that** historical command; spawn using the **current** tick's player position/aim; keep the existing `IsFirstTimeFullyPredictingTick` spawn gate (`:72`) and the per-socket `SocketCooldown` gate. Treat `GetDataAtTick == false` (history gap, e.g. `WindupTicks` older than the command buffer window) as no-fire. Alternative (fallback): store a per-socket resolve tick as a `[GhostField]` routed through `TickUtil.NonZero` so rollback restores it. **Never** store a plain non-replicated `fireAtTick`. **L2/L3 test:** fire with a windup that straddles a server correction (a mid-windup mispredicted knockback) and assert the predicted + server projectiles spawn on the same `serverTick` so `SpawnId → position` reconciliation is a no-op. ### 5. Archetype-dispatch invariant (NP-6, RS-4) — the predict-spawn boundary **Predict-spawn + `SpawnId` classification is reserved STRICTLY for the Projectile archetype.** The current fall-through at `AbilityFireSystem.cs:174-175` does **not** advance a cooldown — the new branches must **add** the both-worlds cooldown advance, not merely relax the `continue`. | Archetype | Cooldown | Effect / spawn | |---|---|---| | **Projectile** (hook/pull) | predict both worlds | predict-spawn ghost on both worlds; `SpawnId`-classified. The ONLY predict-spawned archetype. | | **Cone** (Bathynaut secondary) | predict both worlds (`:170-171`) | effect server-only (`if (isServer)`, `:143-172`). Existing precedent — verbatim template. | | **Aoe/spawn** (decoy-wisp) | advance per-socket `SocketCooldown` via `TickUtil.NonZero(serverTick + cd)` on **both** worlds (outside `isServer`) | `ecb.Instantiate(...)` **only inside `if (isServer)`**. The client must NEVER instantiate the ghost. | | **Aoe/zone** (vortex, zone) | same — both worlds | same — server-only spawn/effect. | | **Hitscan** | predict both worlds | **no ghost on either world**. Swept-ray damage server-only; client-only VFX beam (presentation layer). | | **movement** (Blink) | own path (§6) | NOT dispatched by `AbilityFireSystem` — falls through; `BlinkSystem` handles it. | The decoy/zone/vortex entity = an **interpolated ownerless ghost** (`GhostAuthoring`: interpolated, no `GhostOwner` as a predicting owner, stock `LocalTransform` replication), instantiated server-only, with **no `SpawnId` and no classifier** — deliberately nothing to reconcile (mirrors enemy/pickup + the server-only geyser/barrel chassis). Predicting these gains no responsiveness (their trajectory is not input-deterministic like a projectile's) and would risk unclassified-predicted-spawn flicker/leak. "player-owned zone" means **server-attributed-to-owner** (a source `NetworkId` field on the effect component for aggro/damage credit), NOT an owner-predicted ghost. **Build-check (L2/L3):** assert the client world never instantiates a decoy/zone/hitscan prefab (the Aoe/spawn `Instantiate` call is reachable only when `state.WorldUnmanaged.IsServer()`). ### 6. Blink — the second dash (NP-3, RS-3, DB-5) Blink is a `DashSystem` clone, **not** a dispatch archetype inside `AbilityFireSystem` and **not** a `LocalTransform` write. - **Own system:** a `[BurstCompile]` predicted `ISystem` in `PredictedSimulationSystemGroup`, `[UpdateAfter(PlayerControlSystem)]` and `[UpdateAfter(DashSystem)]`, sibling of `DashSystem`. **No `IsFirstTimeFullyPredictingTick` guard.** - **Own state:** non-replicated `BlinkState` (own `StartTick` + half-open window). Do **not** reuse `DashState` (a same-tick dash+blink would clobber the single window). - **START** = idempotent pure function of the replicated Blink-socket `InputEvent` + tick (mirror `DashSystem.cs:57-75`). - **Drive `CharacterControl.MoveVelocity`** (large speed + high `GroundedMovementSharpness`), re-applied on **every** predicted pass, lower-bounded on the half-open `[StartTick, …)` window (mirror `DashSystem.cs:82-93`). **Never write `LocalTransform`** — a raw write bypasses the CC collide-and-slide processor that owns `LocalTransform` integration (blink through subscene-only Environment colliders → the no-player-pathfinding soft-lock class), fights per-tick integration, and resets `Scale=1` (`LocalTransform.FromPosition`). - **Cover distance with an N-tick window**, not one huge single-tick velocity, so the CC validates each collide-and-slide step (no wall tunnel). - **MoveVelocity precedence:** `BlinkSystem` early-outs its override while a `DashState` window is active this tick (dash wins), evaluated every pass so rollback re-simulation converges. (Equivalently, blink-start refuses while a dash window is open and vice-versa.) - **Cooldown:** `BlinkSystem` is the **sole writer of its socket's `SocketCooldown` row** (`AbilityFireSystem` skips movement-archetype sockets via fall-through, so no row contention) — keeps the HUD's per-socket bar uniform. This is a disjoint-row shared-buffer write; order the two systems and treat it as a watched dependency (§implementer-watch). Keep "Blink occupies an `AbilitySocket` row" purely as HUD/socketing data; its firing is a separate predicted code path. - **Out of scope unless requested:** a *true* instant/wall-crossing MOBA-Flash reposition. If ever wanted, it must set the character body position via the **CC API** (not a raw transform write) **and** sweep the segment against `CollisionWorld` to clamp short of geometry (reuse the swept-hit pattern). Phase 1 Blink is the dash clone. ### 7. Per-socket effective stats (RS-7, DB-2) `StatRecomputeSystem` today folds ONE `AbilityRef` into ONE `EffectiveAbilityStats` per player every predicted tick (`StatRecomputeSystem.cs:37-53`), and `AbilityFireSystem` reads that single struct for Damage/Range/Cooldown/ProjectileSpeed/AutoTarget. Four sockets each holding a different Spark cannot share one struct. **LOCKED — option (a): a 4-wide `EffectiveAbilityStats` `[InternalBufferCapacity(4)]` buffer**, one row per socket, folded every tick in `StatRecomputeSystem` from each socket's `SparkId` blob base + the shared per-player `StatModifier` band. Chosen over the fold-inline option (b) because the HUD/anim/feedback readers all need per-socket data anyway. - **Scope:** only `EffectiveAbilityStats` goes per-socket. `EffectiveCharacterStats` (MoveSpeed/MaxHealth/TurnRate) is per-player and stays single. - **Only tunable scalars** need per-socket resolution: Damage, ProjectileSpeed, Range, AutoTargetRange, AutoTargetConeRadians, CooldownTicks. Archetype + projectile-prefab lookup already dispatch off the blob per `SparkId` (`AbilityFireSystem.cs:137-138, 179-186`), NOT off `EffectiveAbilityStats`. - **Band semantics (LOCKED):** the single per-player `StatModifier` band (class traits + boons) applies **uniformly** to all 4 sockets — `StatMath.Apply` folds it against each socket's blob base. Per-socket/per-Spark warping is Phase 4 (`BoonEffects`). - **Keep the fold unconditional every tick** — no dirty flag / change filter (`StatRecomputeSystem.cs:14-18`: `Effective*` is not in the snapshot, so a filter goes stale across reprediction). - `StatRecomputeSystem` stays at **5 query args** (`:37-40`); the 7-arg-cap work is scoped to `AbilityFireSystem` only. - Re-point the three presentation readers at per-socket data: `HudSystem.cs:543`, `CombatFeedbackSystem.cs:442`, `PlayerAnimationDriveSystem.cs:161/216`. ### 8. The client feel layer + replicated class signal (NP-5, RS-2) **This is a real migration, not "KEEP as-is."** Four client systems hard-depend on the single `AbilityRef`/`AbilityCooldown` being reworked, and one loses its only class signal. - **New replicated class source (REQUIRED):** add a `[GhostField] byte FrameId` (`SendToOwner`) on the player, **or** promote the server-only `PlayerClass` (`MetaComponents.cs:67-75`, currently NOT replicated) to a `SendToOwner` `[GhostField]`. Required because in the socket model no single ability id maps 1:1 to a class. Re-point `ClassPrepPortalHudSystem.cs:88` and `MetaShopHudSystem.cs:81` from `ClassTraits.ClassForAbility(AbilityRef.Id)` to the new field. - **`CombatFeedbackSystem`** (move OUT of KEEP): the muzzle-flash edge (`:333-343`, `AbilityCooldown.NextFireTick`) and cone-cue edge (`:434`) must iterate the 4 `SocketCooldown` rows (per-socket `NextFireTick` edge with a last-tick-per-socket cache). The `localIsCone` gate (`:321-327`) and cone `Archetype` gate (`:439`) must resolve from the **fired socket's `SparkId`** via the `AbilityDatabase` blob, not `AbilityRef.Id`. - **`PlayerAnimationDriveSystem`** (move OUT of KEEP): `FireActive` (`:131-138`, read at `:170-171`/`:231-232`) reads `AbilityCooldown.NextFireTick` + `EffectiveAbilityStats.CooldownTicks` → must become per-socket (which socket fired + its cooldown row + its per-socket effective `CooldownTicks`). `IsCone` (`:182-188`/`:235-241`) from `AbilityRef` → from the fired socket's `SparkId` blob. Note the `Execute` in-args are `IJobEntity` params (`:153-164`), not `SystemAPI.Query` args, so the 7-arg cap does not bind — but `AbilityCooldown`→`SocketCooldown` and `AbilityRef`→`AbilitySocket` become buffer params. - **KEEP genuinely as-is:** `EnemyDangerTelegraphSystem`, `AimReticleSystem`, `DynamicLightSystem`, the ambient/underwater stack — none reads `AbilityRef`/`AbilityCooldown`. ### 9. Harpooner reel — new homing state (RS-6) The pre-review "reuses the `ProjectileEffectState` pull flag" is a scaffold, not the finished reel. - **Gym must spawn LIVE Husk-type enemies** (`EnemyTag` + baked `KnockbackState` per `EnemyAuthoring.cs:53` + under `EnemyAISystem` control) as the reel/knockback targets. An inert dummy carries neither `KnockbackState` nor a mover → pull/knockback silently no-ops (`ProjectileDamageSystem.cs:169` guard; `EnemyAISystem` is the sole applier, `:129-142`). Plain damage + client hit-flash **do** work on any `Health`+`HitRadius` dummy (damage is unconditional at `ProjectileDamageSystem.cs:161`; flash edge-detects replicated `Health`) — dummies are fine for damage-feel, not reel-feel. - **Do NOT ship the pull flag as the signature reel.** It stamps `KnockbackState.Dir = -Projectile.Direction` (back toward the firing *origin*, not the caster's current position) at fixed `Tuning.KnockbackSpeed` for a fixed window (`ProjectileDamageSystem.cs:169-178`), and `EnemyAISystem.cs:135` drives a constant-velocity nudge along that **frozen** `Dir` — it neither homes on the caster nor lands the target at the caster. - **Implement the reel as a NEW server-only homing state** — a sibling of `LungeState`/`KnockbackState` so it doesn't fight the seek and preserves `EnemyAISystem` as the sole mover. Store a hooked-target→caster link; each server tick recompute `Dir` toward the caster's **current** `LocalTransform.Position`; drive until within melee/leash, then release. Route scheduled ticks through `TickUtil.NonZero`; gate movement with `IsNewerThan`. - **Bosses are knockback-immune (A4)** — the gym's feelability of the reel rests entirely on non-boss Husks. ### 10. Player zone / vortex — new server-only periodic-AoE (RS-5) "Reuse the geyser/hazard AoE chassis, player-owned" means borrow the pattern, not the systems. `GeyserEruptSystem`/`HazardExplosionSystem` are environment hazards: they damage living **players** too (`GeyserEruptSystem.cs:69-98`), stamp `DamageEvent.SourceNetworkId = -1` (`:79,:95` — deliberately unattributed), and gate players on `RegionTag == Expedition` (`:73`). **A NEW server-only periodic-AoE system that:** (1) borrows ONLY the scheduling/sweep skeleton (`= now + period`; the **inverted** invalid-tick guard so a born-0 `[GhostField]` tick never storm-fires, `GeyserEruptSystem.cs:54-57`; `NativeList` gather + radius-sq sweep); (2) stamps `DamageEvent.SourceNetworkId = casterId` (**not -1**) so kills credit the caster and feed `KillRewardSystem`'s Siphon heal / Frenzy row / whiff-punish; (3) **drops the player-damage loop entirely** (no friendly fire) — keep only the `EnemyTag` + `Health > 0` loop, which is already region-agnostic (`GeyserEruptSystem.cs:84-98` has no region check), so **no `RegionId` gate** for a no-world gym. Net reuse: the ~15-line scheduling/sweep skeleton; attribution + enemy-only filter are new code. ### 11. Underwater feel pass (client-only, zero netcode) Unchanged from the pre-review spec — reuse `AmbientMotionSystem`/`AmbientLifeSystem`/`WorldAtmosphereSystem`: buoyant knockback decay tuning, slow debris drift, murk fog, procedural `AudioClip.Create` hydrophone bed. No sim change. ## Salvage & touch map **KEEP as-is (verified — no `AbilityRef`/`AbilityCooldown` dependency):** - `EnemyDangerTelegraphSystem`, `AimReticleSystem`, `DynamicLightSystem`, `DashSystem`, `MeleeComboSystem`, the ambient/underwater stack. - `StatMath.Apply` / the `StatModifier` band mechanism (band stays single-per-player; only the *fold target* goes per-socket). **KEEP the pipeline, REWORK the read/grammar:** - `AbilityFireSystem` — query restructure (§2), archetype dispatch (§5), `SpawnId` repack (§1), windup resolve (§4). - `ProjectileClassificationSystem` — `SpawnId` doc update (`:14-16`); single `m_GhostType` stays valid under the shared-prefab lock; generalization deferred (open risk DB-4). - `StatRecomputeSystem` → 4-wide `EffectiveAbilityStats` fold (§7); stays at 5 query args. - `Projectile` — `SpawnId` doc comment update (`:21-24`). **REWORK — per-socket data model:** - `AbilityRef{[GhostField] byte Id}` → `AbilitySocket` 4-wide buffer (`EquipmentSlot`-modelled, cold). - `AbilityCooldown` → `SocketCooldown` 4-wide owner-predicted cooldown (scalar-shaped, NOT `EquipmentSlot`-shaped). - `PlayerInput.Fire` → `Socket0..3` `InputEvent`s. - `EffectiveAbilityStats` single → 4-wide buffer. - `EquipSystem` — writes `AbilitySocket` (the socketing action) instead of / alongside `AbilityRef`. **REWORK — per-socket feedback + replicated class signal (new build step 2.5):** - `CombatFeedbackSystem` (muzzle `:335`, cone `:434`, archetype gates `:325`/`:439`) → per-socket. - `PlayerAnimationDriveSystem` (`FireActive` `:170-171`/`:231-232`, `IsCone` `:186`/`:239-241`) → per-socket + fired-socket blob. - `ClassPrepPortalHudSystem:88`, `MetaShopHudSystem:81` → read the new `FrameId`/`PlayerClass` `[GhostField]` instead of `ClassForAbility(AbilityRef.Id)`. - `HudSystem.cs:543` (ability-bar `EffectiveAbilityStats` read) → per-socket. **NEW code (not a salvage):** - `BlinkSystem` (§6). - Harpooner reel homing state + its driver in `EnemyAISystem` (§9). - Player zone/vortex server-only periodic-AoE (§10). - Aoe/Hitscan archetype branches in `AbilityFireSystem` (§5) — implement the declared-but-unhandled archetypes. - Decoy-wisp server-spawned interpolated ghost + a decoy aggro tag (reuse enemy target-selection). **Prefabs/data:** 5 `AbilityDefBlob` rows; the decoy/zone/vortex interpolated ghost prefabs (greybox via the "duplicate a configured ownerless ghost" recipe); the shared projectile prefab stays single. ## Build order Each step ends **"compile + `read_console` clean"** (L1: source-gen + Burst entry points verified — a Burst ICE corrupts the cache, so a clean compile + green L2 confirms the code). Wire/bake churn classified per step. 1. **Socket data model, no behaviour.** Add `AbilitySocket` + `SocketCooldown` (+ `BlinkCooldown`) baked 4-wide; add the class-signal `[GhostField]` (`FrameId` or promoted `PlayerClass`). Leave the legacy `AbilityRef`/`AbilityCooldown` query untouched. **Wire/bake: player-ghost re-bake (new `[GhostField]`s).** L1 only. - **1b. Per-socket effective-stat resolution.** 4-wide `EffectiveAbilityStats` buffer + `StatRecomputeSystem` per-socket fold (§7). Uniform band. **Bake: none (Effective\* not in snapshot).** L1 + an L2 fold test. 2. **`AbilityFireSystem` socket restructure.** Query drop to 4 args + `BufferLookup`s (§2); `SpawnId` repack (§1); per-socket cooldown; Cone + Projectile paths first. **Wire/bake: command-serializer hash change (`Socket0..3` `InputEvent`s) + player-ghost re-bake — must match across peers; re-mean bytes where possible.** L1 + the `SpawnId` uniqueness L2 test. - **2.5. Client feel + class signal migration (§8).** `CombatFeedbackSystem`, `PlayerAnimationDriveSystem`, `ClassPrepPortalHudSystem`, `MetaShopHudSystem`, `HudSystem` → per-socket + new class field. **Bake: consumes the step-1 class `[GhostField]` re-bake; no new bake.** L1 + L3 visual smoke. 3. **Aoe / Hitscan archetype branches** (§5) — both-worlds cooldown advance + server-only spawn/effect; the decoy/zone/vortex interpolated ghosts. **Bake: new ghost prefabs (additive).** L1 + the "client never instantiates" L2/L3 check. 4. **The 5 Spark defs + prefabs** (greybox) + **Blink** (`BlinkSystem`, §6) + **Harpooner reel** homing state (§9) + **player zone** periodic-AoE (§10). **Bake: `AbilityDefBlob` rows (additive config); reel/zone are server-only, no ghost re-bake beyond step 3.** L2 (reel homing, zone attribution) + L3 (feel). 5. **Two frames** = class-chassis reskin + per-frame socket defaults + movement-feel knobs. **Bake: config only.** 6. **Manual-aim grammar:** `WindupTicks` telegraphs (§4); auto-target off by default. **Bake: config only.** L2 windup resolve-tick determinism test. 7. **Underwater feel pass** (client-only, §11). No sim/bake change. 8. **Light-relevancy prototype** — ONLY after the world-model Build Spec lands; validated by the gamma test. Shares that review; not coded here. **Wire/bake churn summary:** steps 1–2 + 2.5 re-bake the player ghost (socket/cooldown/class `[GhostField]`s) and change the command-collection hash (`Socket0..3` `InputEvent`s) — the RPC/command collection hash must match across all peers. Step 3 adds interpolated-ghost prefabs (additive). Everything else is additive config + client-only. **L1–L3 verification approach:** - **L1** — `refresh_unity scope=scripts` then `read_console` after every code-touch step; Burst entry points verified (no "not a known Burst entry point"); a focused editor for Burst-affecting edits. - **L2** — plain-Entities EditMode tests (`ProjectM.Tests.EditMode`): `SpawnId` cross-socket uniqueness (§1), per-socket cooldown gate, per-socket stat fold, windup resolve-tick determinism (§4), "client world never instantiates decoy/zone/hitscan" (§5), reel homing convergence. - **L3** — focused-editor two-player MPPM Play: fire all 4 sockets same tick (no flicker/desync), **induced rollback / forced packet loss** to validate `SocketCooldown` predicted-buffer restore (no double-fire/desync — mandatory before relying on predicted-buffer restore, NP-4), reel against LIVE Husks, dash+blink same-tick precedence, windup-across-correction no-pop. ## Open risks (§implementer-watch) - **NP-2 / DB-4 — classifier hardwired to one ghost type.** `ProjectileClassificationSystem.cs:82-98` resolves a single `m_GhostType`; `:154` filters on it; `:167`/`:171` match on it. The shared-prefab lock (§1) keeps this valid for Phase 1. **If** distinct projectile meshes are ever needed: build a membership set of ghost-type indices from `AbilityPrefabElement`, change the OUTER filter (`:154`) to a set-membership test, but change the INNER scan (`:167`) to `!= newSpawn.GhostType` (the *specific* incoming type, not the set) — else two projectile types cross-match on a colliding `SpawnId`. **Watch:** `ProjectileClassificationSystem.cs:154, 167, 171`. - **NP-4 — `SocketCooldown` predicted-buffer restore.** If a `[GhostField] IBufferElementData` buffer shape is chosen over the scalar fixed-array, its per-tick rollback restore is unproven here. **Gate:** the L3 induced-rollback test is mandatory before relying on it. **Watch:** the `SocketCooldown` definition + `AbilityFireSystem` cooldown write. - **NP-3 / RS-3 — dash+blink MoveVelocity precedence.** Two systems write `CharacterControl.MoveVelocity` in the predicted group; precedence must be evaluated **every** pass so rollback converges. **Watch:** `BlinkSystem` `[UpdateAfter(DashSystem)]` + the dash-window early-out; `DashSystem.cs:88-93`. - **RS-3 — Blink `SocketCooldown` shared-row write.** `BlinkSystem` writes its socket's row while `AbilityFireSystem` writes the others — disjoint rows, but a shared-buffer write dependency across two systems; order them and verify no aliasing throw. **Watch:** both systems' `SocketCooldown` `BufferLookup` RW. - **DB-6 — windup resolve-tick divergence is undetectable by `SpawnId`.** A `fireCount`-keyed `SpawnId` classifies a client/server windup-tick disagreement as a "match" then snaps. **Gate:** the reprediction test (§4). **Watch:** `AbilityFireSystem.cs:193` (`GetDataAtTick` offset) + `:232` (`SpawnId` pack). - **RS-5 — player zone attribution/friendly-fire regression.** Copying `GeyserEruptSystem`/`HazardExplosionSystem` verbatim would friendly-fire allies (`GeyserEruptSystem.cs:69-82`), fail to credit the caster (`SourceNetworkId = -1`, `:79/:95`), and region-lock to Expedition (`:73`). **Watch:** the new system must stamp `casterId`, drop the player loop, add no region gate. - **RS-6 — reel unfeelable against inert dummies.** The pull no-ops without baked `KnockbackState` + an `EnemyAISystem` mover. **Watch:** gym spawns LIVE Husks (`EnemyAuthoring.cs:53`); `ProjectileDamageSystem.cs:169` guard; `EnemyAISystem.cs:135` frozen-`Dir` nudge is *not* a homing reel. - **7-arg cap regression on new systems.** `BlinkSystem` (needs socket buffers + CC + input) and the reworked `PlayerAnimationDriveSystem` can approach the cap. **Watch:** read socket buffers via `BufferLookup`, never as `SystemAPI.Query` type args. - **System-ordering cycle is invisible to EditMode tests.** Adding `[UpdateAfter]` edges (BlinkSystem, reel) — re-audit existing `[Update*]` attributes and **Play-validate** (a cycle only throws at world creation). ## Findings ledger | ID | Lens | Sev | Verdict | Resolution | |---|---|---|---|---| | NP-1 | netcode | HIGH | CONFIRMED | Repack `SpawnId` to `owner14\|socket2\|fireCount12\|fork4`; source count from firing socket; don't touch fork bits (§1). | | NP-2 | netcode | HIGH | PARTIAL | Lock single shared projectile prefab + bake-time guard; classifier generalization deferred (§1, open risk). | | NP-3 | netcode | MED | PARTIAL | Blink = velocity-driven `DashState` chassis, own `BlinkState`/cooldown, dash-wins precedence (§6). | | NP-4 | netcode | MED | PARTIAL | `SocketCooldown` = hot owner-predicted scalar-shaped cooldown (NOT `EquipmentSlot`), `SendToOwner`; L3 rollback gate (§3, open risk). | | NP-5 | netcode | HIGH | CONFIRMED | Client feel layer moves to REWORK; new replicated class signal (§8, step 2.5). | | NP-6 | netcode | MED | CONFIRMED | Predict-spawn is Projectile-only; Aoe/Hitscan/decoy = server-only spawn, cooldown both worlds (§5). | | RS-1 | reuse | HIGH | PARTIAL | Same as NP-1; socket bits mandatory; regression test (§1). | | RS-2 | reuse | MED | CONFIRMED | Same as NP-5; re-file 4 client systems, add class `[GhostField]`, per-socket edges (§8). | | RS-3 | reuse | HIGH | PARTIAL | Blink is a separate predicted system, not an `AbilityFireSystem` dispatch; sole writer of its cooldown row (§6, open risk). | | RS-4 | reuse | HIGH | CONFIRMED | Aoe/spawn advances cooldown both worlds but `Instantiate` server-only; new branch must add the cooldown advance (§5). | | RS-5 | reuse | MED | PARTIAL | Player zone = new server-only AoE; borrow scheduling only, stamp casterId, enemy-only, no region gate (§10, open risk). | | RS-6 | reuse | MED | CONFIRMED | Reel = new homing state on LIVE Husks; pull flag is scaffold; bosses immune (§9, open risk). | | RS-7 | reuse | MED | PARTIAL | 4-wide `EffectiveAbilityStats` fold, uniform band, `StatRecomputeSystem` stays 5 args; re-point 3 readers (§7). | | DB-1 | determinism | MED | PARTIAL | Drop `AbilityRef`+`AbilityCooldown` from query → 4 args + `BufferLookup`s; no `IJobEntity` rewrite (§2). | | DB-2 | determinism | MED | PARTIAL | Same as RS-7; per-socket `EffectiveAbilityStats` is explicit REWORK, bounded inside the socket restructure (§7). | | DB-3 | determinism | HIGH | PARTIAL | Same as NP-1; assert adopted-entity identity (swap, not double-spawn) in the test (§1). | | DB-4 | determinism | MED | CONFIRMED | Classifier single-type valid under shared-prefab lock; if generalized, inner scan matches `newSpawn.GhostType` (§1, open risk). | | DB-5 | determinism | MED | PARTIAL | Fix "teleport" wording → velocity blink; forbid raw `LocalTransform` write; true Flash out of scope (§6). | | DB-6 | determinism | MED | CONFIRMED | Windup resolve tick = pure fn of `GetDataAtTick(serverTick − WindupTicks)`; reprediction test (§4, open risk). |