Files
Project-M/Docs/Vault/06_Roadmap/Phase1_Combat_Gym_Build_Spec.md
T
kronic 6474e8faff Docs: LANTERN Phase 1 combat review-hardened — Build Spec + DR-050
Adversarial pre-code review (wf_c7575828-934; 23 agents, 0 failures) over the Phase 1
combat-gym design raised 19 findings (8 CONFIRMED + 11 PARTIAL, 0 refuted) and caught
design-breakers before code:
- SpawnId bit budget exhausted (owner16|fireCount12|fork4) -> repack owner14|socket2|
  fireCount12|fork4 so 4 sockets don't collide/desync.
- "KEEP as-is" client feel layer is a real migration (CombatFeedback/PlayerAnimationDrive/
  HUD read the reworked AbilityRef/AbilityCooldown) + needs a new replicated class signal.
- predict-spawn is Projectile-only (Aoe/Hitscan/decoy = Cone-precedent server-only spawn);
  Blink = velocity-blink in its own system (not a transform teleport); SocketCooldown hot
  not cold; EffectiveAbilityStats 4-wide; windup needs a rollback-safe resolve tick;
  Harpooner reel + player zone = new server-only code, not salvage.

Verdict: pass-with-changes on all 3 lenses, no redesign. Phase1_Combat_Gym_Build_Spec =
the implementation contract (8-step build order, wire/bake churn, open risks, ledger).
DR-050 records it; pre-review design -> superseded.

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

36 KiB
Raw Blame History

title, date, tags, status, permalink
title date tags status permalink
LANTERN Phase 1 — Combat Gym Build Spec (review-hardened) 2026-07-14
roadmap
lantern
combat
phase-1
build-spec
netcode
review-hardened 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 InputEvents (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 SpawnIdsProjectileClassificationSystem (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 SpawnIds 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<PlayerInput>, RefRO<PlayerFacing>, RefRO<LocalTransform>, RefRO<GhostOwner> + .WithEntityAccess(), filters .WithAll<Simulate>().WithDisabled<Dead>().
  • Read inside the main-thread foreach, keyed by the player entity, via lookups (mirroring the existing m_BoonEffectsLookup ComponentLookup at :131 and GetBuffer<InputBufferData<PlayerInput>>(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 DynamicBuffers → 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 InputEvents 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()).

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 AbilityCooldownSocketCooldown and AbilityRefAbilitySocket 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).
  • ProjectileClassificationSystemSpawnId 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.
  • ProjectileSpawnId doc comment update (:21-24).

REWORK — per-socket data model:

  • AbilityRef{[GhostField] byte Id}AbilitySocket 4-wide buffer (EquipmentSlot-modelled, cold).
  • AbilityCooldownSocketCooldown 4-wide owner-predicted cooldown (scalar-shaped, NOT EquipmentSlot-shaped).
  • PlayerInput.FireSocket0..3 InputEvents.
  • 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 + BufferLookups (§2); SpawnId repack (§1); per-socket cooldown; Cone + Projectile paths first. Wire/bake: command-serializer hash change (Socket0..3 InputEvents) + 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 12 + 2.5 re-bake the player ghost (socket/cooldown/class [GhostField]s) and change the command-collection hash (Socket0..3 InputEvents) — the RPC/command collection hash must match across all peers. Step 3 adds interpolated-ghost prefabs (additive). Everything else is additive config + client-only.

L1L3 verification approach:

  • L1refresh_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 + BufferLookups; 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).