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>
36 KiB
title, date, tags, status, permalink
| title | date | tags | status | permalink | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| LANTERN Phase 1 — Combat Gym Build Spec (review-hardened) | 2026-07-14 |
|
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.
PlayerInputgains four independentSocket0..3InputEvents (not a singleFire+ActiveSocketbyte). The review confirms this is the correct feel choice and that it forces theAbilityFireSystem7-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-socketAutoTargetRange = 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/AbilityArchetypeblob dispatch,MeleeComboSystem,DashSystem, and the client feel systems are the correct chassis. DashSystemis the correct Blink template — and the correct template because it drivesCharacterControl.MoveVelocityevery predicted pass and never writesLocalTransform(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). AbilitySocketmodelled onEquipmentSlot(cold, server-sole-writer, socketed in the hub) is correct.SocketCooldownis not anEquipmentSlot-shaped cold buffer — it is a hot owner-predicted cooldown; see contract (NP-4).- Mutations stay Phase 4. Sparks are
AbilityDefBlobrows; theBoonEffectssubstrate 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
SpawnIdbit 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
SpawnIdcollision (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:
SpawnIdbudget (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 SpawnIds → 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 ownInputEvent.Count(applied.InternalInput.Socket{n}.Count), NOT a sharedFire.Count. Wraps at 4096 shots — consistent with the existing 12-bit wrap philosophy.fork(4)untouched — Fork mutations are Phase 4 (AbilityFireSystem.cs:222caps 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) | absoluteFireCountform:Projectile.cs:21-24andProjectileClassificationSystem.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
AbilityRefandAbilityCooldownfrom 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 existingm_BoonEffectsLookupComponentLookupat:131andGetBuffer<InputBufferData<PlayerInput>>(entity)at:192):AbilitySocket— read-onlyBufferLookup.SocketCooldown— read-writeBufferLookup(per-socketNextFireTickwrite).- per-socket
EffectiveAbilityStats— read-onlyBufferLookup(see §7). BoonEffects— existingComponentLookup.- These are
DynamicBuffers →BufferLookup, notComponentLookup. Declare/.Update()inOnCreate/OnUpdatelikem_BoonEffectsLookup.
- Loop sockets 0..3 per player inside the
foreach. Per-socket RW cooldown write stays main-thread (no parallel-buffer aliasing) → noIJobEntityrewrite required. - Hard rule: never add
AbilitySocket/SocketCooldown/EffectiveAbilityStats-buffer asSystemAPI.Querytype args. - Keep
Unity.Transforms+Unity.Physicsas DIRECTProjectM.Simulationasmdef refs (already true) soLocalTransformsource-gen in*.g.csdoesn't regress to CS0246/CS8377. - Step 1 need not literally keep
AbilityRefin 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 genuineEquipmentSlotanalog (EquipSystemwrites it).SocketCooldown(NP-4) — a HOT, owner-PREDICTED cooldown. Its proven shape is the scalarAbilityCooldown[GhostField], NOT the coldEquipmentSlotbuffer — do not citeEquipmentSlotas its precedent. Preferred shape: a singleIComponentDataholding 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'sAbilityCooldown, 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 onEquipmentSlot— it is a predicted buffer, and the mandatory L3 rollback validation below applies. Route every stored tick throughTickUtil.NonZero, compare viaNetworkTick.IsNewerThan(never rawuint).- 4
Socket0..3InputEvents onPlayerInput(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]predictedISysteminPredictedSimulationSystemGroup,[UpdateAfter(PlayerControlSystem)]and[UpdateAfter(DashSystem)], sibling ofDashSystem. NoIsFirstTimeFullyPredictingTickguard. - Own state: non-replicated
BlinkState(ownStartTick+ half-open window). Do not reuseDashState(a same-tick dash+blink would clobber the single window). - START = idempotent pure function of the replicated Blink-socket
InputEvent+ tick (mirrorDashSystem.cs:57-75). - Drive
CharacterControl.MoveVelocity(large speed + highGroundedMovementSharpness), re-applied on every predicted pass, lower-bounded on the half-open[StartTick, …)window (mirrorDashSystem.cs:82-93). Never writeLocalTransform— a raw write bypasses the CC collide-and-slide processor that ownsLocalTransformintegration (blink through subscene-only Environment colliders → the no-player-pathfinding soft-lock class), fights per-tick integration, and resetsScale=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:
BlinkSystemearly-outs its override while aDashStatewindow 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:
BlinkSystemis the sole writer of its socket'sSocketCooldownrow (AbilityFireSystemskips 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 anAbilitySocketrow" 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
CollisionWorldto 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
EffectiveAbilityStatsgoes 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 offEffectiveAbilityStats. - Band semantics (LOCKED): the single per-player
StatModifierband (class traits + boons) applies uniformly to all 4 sockets —StatMath.Applyfolds 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). StatRecomputeSystemstays at 5 query args (:37-40); the 7-arg-cap work is scoped toAbilityFireSystemonly.- 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-onlyPlayerClass(MetaComponents.cs:67-75, currently NOT replicated) to aSendToOwner[GhostField]. Required because in the socket model no single ability id maps 1:1 to a class. Re-pointClassPrepPortalHudSystem.cs:88andMetaShopHudSystem.cs:81fromClassTraits.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 4SocketCooldownrows (per-socketNextFireTickedge with a last-tick-per-socket cache). ThelocalIsConegate (:321-327) and coneArchetypegate (:439) must resolve from the fired socket'sSparkIdvia theAbilityDatabaseblob, notAbilityRef.Id.PlayerAnimationDriveSystem(move OUT of KEEP):FireActive(:131-138, read at:170-171/:231-232) readsAbilityCooldown.NextFireTick+EffectiveAbilityStats.CooldownTicks→ must become per-socket (which socket fired + its cooldown row + its per-socket effectiveCooldownTicks).IsCone(:182-188/:235-241) fromAbilityRef→ from the fired socket'sSparkIdblob. Note theExecutein-args areIJobEntityparams (:153-164), notSystemAPI.Queryargs, so the 7-arg cap does not bind — butAbilityCooldown→SocketCooldownandAbilityRef→AbilitySocketbecome buffer params.- KEEP genuinely as-is:
EnemyDangerTelegraphSystem,AimReticleSystem,DynamicLightSystem, the ambient/underwater stack — none readsAbilityRef/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+ bakedKnockbackStateperEnemyAuthoring.cs:53+ underEnemyAISystemcontrol) as the reel/knockback targets. An inert dummy carries neitherKnockbackStatenor a mover → pull/knockback silently no-ops (ProjectileDamageSystem.cs:169guard;EnemyAISystemis the sole applier,:129-142). Plain damage + client hit-flash do work on anyHealth+HitRadiusdummy (damage is unconditional atProjectileDamageSystem.cs:161; flash edge-detects replicatedHealth) — 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 fixedTuning.KnockbackSpeedfor a fixed window (ProjectileDamageSystem.cs:169-178), andEnemyAISystem.cs:135drives a constant-velocity nudge along that frozenDir— 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/KnockbackStateso it doesn't fight the seek and preservesEnemyAISystemas the sole mover. Store a hooked-target→caster link; each server tick recomputeDirtoward the caster's currentLocalTransform.Position; drive until within melee/leash, then release. Route scheduled ticks throughTickUtil.NonZero; gate movement withIsNewerThan. - 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/ theStatModifierband 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),SpawnIdrepack (§1), windup resolve (§4).ProjectileClassificationSystem—SpawnIddoc update (:14-16); singlem_GhostTypestays valid under the shared-prefab lock; generalization deferred (open risk DB-4).StatRecomputeSystem→ 4-wideEffectiveAbilityStatsfold (§7); stays at 5 query args.Projectile—SpawnIddoc comment update (:21-24).
REWORK — per-socket data model:
AbilityRef{[GhostField] byte Id}→AbilitySocket4-wide buffer (EquipmentSlot-modelled, cold).AbilityCooldown→SocketCooldown4-wide owner-predicted cooldown (scalar-shaped, NOTEquipmentSlot-shaped).PlayerInput.Fire→Socket0..3InputEvents.EffectiveAbilityStatssingle → 4-wide buffer.EquipSystem— writesAbilitySocket(the socketing action) instead of / alongsideAbilityRef.
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 newFrameId/PlayerClass[GhostField]instead ofClassForAbility(AbilityRef.Id).HudSystem.cs:543(ability-barEffectiveAbilityStatsread) → 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.
- Socket data model, no behaviour. Add
AbilitySocket+SocketCooldown(+BlinkCooldown) baked 4-wide; add the class-signal[GhostField](FrameIdor promotedPlayerClass). Leave the legacyAbilityRef/AbilityCooldownquery untouched. Wire/bake: player-ghost re-bake (new[GhostField]s). L1 only.- 1b. Per-socket effective-stat resolution. 4-wide
EffectiveAbilityStatsbuffer +StatRecomputeSystemper-socket fold (§7). Uniform band. Bake: none (Effective* not in snapshot). L1 + an L2 fold test.
- 1b. Per-socket effective-stat resolution. 4-wide
AbilityFireSystemsocket restructure. Query drop to 4 args +BufferLookups (§2);SpawnIdrepack (§1); per-socket cooldown; Cone + Projectile paths first. Wire/bake: command-serializer hash change (Socket0..3InputEvents) + player-ghost re-bake — must match across peers; re-mean bytes where possible. L1 + theSpawnIduniqueness 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.
- 2.5. Client feel + class signal migration (§8).
- 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.
- The 5 Spark defs + prefabs (greybox) + Blink (
BlinkSystem, §6) + Harpooner reel homing state (§9) + player zone periodic-AoE (§10). Bake:AbilityDefBlobrows (additive config); reel/zone are server-only, no ghost re-bake beyond step 3. L2 (reel homing, zone attribution) + L3 (feel). - Two frames = class-chassis reskin + per-frame socket defaults + movement-feel knobs. Bake: config only.
- Manual-aim grammar:
WindupTickstelegraphs (§4); auto-target off by default. Bake: config only. L2 windup resolve-tick determinism test. - Underwater feel pass (client-only, §11). No sim/bake change.
- 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 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.
L1–L3 verification approach:
- L1 —
refresh_unity scope=scriptsthenread_consoleafter 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):SpawnIdcross-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
SocketCooldownpredicted-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-98resolves a singlem_GhostType;:154filters on it;:167/:171match 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 fromAbilityPrefabElement, 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 collidingSpawnId. Watch:ProjectileClassificationSystem.cs:154, 167, 171. - NP-4 —
SocketCooldownpredicted-buffer restore. If a[GhostField] IBufferElementDatabuffer 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: theSocketCooldowndefinition +AbilityFireSystemcooldown write. - NP-3 / RS-3 — dash+blink MoveVelocity precedence. Two systems write
CharacterControl.MoveVelocityin 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
SocketCooldownshared-row write.BlinkSystemwrites its socket's row whileAbilityFireSystemwrites the others — disjoint rows, but a shared-buffer write dependency across two systems; order them and verify no aliasing throw. Watch: both systems'SocketCooldownBufferLookupRW. - DB-6 — windup resolve-tick divergence is undetectable by
SpawnId. AfireCount-keyedSpawnIdclassifies a client/server windup-tick disagreement as a "match" then snaps. Gate: the reprediction test (§4). Watch:AbilityFireSystem.cs:193(GetDataAtTickoffset) +:232(SpawnIdpack). - RS-5 — player zone attribution/friendly-fire regression. Copying
GeyserEruptSystem/HazardExplosionSystemverbatim 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 stampcasterId, drop the player loop, add no region gate. - RS-6 — reel unfeelable against inert dummies. The pull no-ops without baked
KnockbackState+ anEnemyAISystemmover. Watch: gym spawns LIVE Husks (EnemyAuthoring.cs:53);ProjectileDamageSystem.cs:169guard;EnemyAISystem.cs:135frozen-Dirnudge is not a homing reel. - 7-arg cap regression on new systems.
BlinkSystem(needs socket buffers + CC + input) and the reworkedPlayerAnimationDriveSystemcan approach the cap. Watch: read socket buffers viaBufferLookup, never asSystemAPI.Querytype 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). |