Initial Combat Implementation

This commit is contained in:
Luis Gonzalez
2026-05-31 21:35:12 -07:00
parent 7fa77ce821
commit 1f647dd5e1
166 changed files with 93337 additions and 91 deletions
@@ -0,0 +1,56 @@
---
date: 2026-05-31
type: session
tags: [session, dots, netcode, m2, combat]
permalink: gamevault/07-sessions/2026/2026-05-31-m2-combat
---
# Session 2026-05-31 — M2 Combat Foundation
## Goal
Build **M2 — Combat**: directional ability fire (predicted projectile), deterministic soft auto-target, server-authoritative health/damage, and a training dummy to shoot — plus migrate input to the new Unity Input System action map. Strict, foundation-grade pass.
## Done
### Architecture locked — see [[DR-003_M2_Combat_Netcode_Architecture]]
Predicted projectile ghosts (client predict-spawn + classification by `SpawnId`); server-authoritative auto-target at the fire tick (reconciled via `[GhostField] Projectile.Direction`); server-only damage (`Health.Current` `[GhostField]`); swept hit detection. Fire is a netcode `InputEvent`.
### Built — 26 files, compiles clean, runtime-validated on 6.4.7
- **Input migration:** added `Aim` (Vector2) + `Fire` (Button) to `Project M Input.inputactions`; retargeted the generated wrapper into the `ProjectM.Client` asmdef (`wrapperCodePath``Scripts/Client/Input/ProjectMInput.cs`) so client systems can reference it; rewrote `PlayerInputGatherSystem` as a managed `SystemBase` reading the wrapper (`Fire.Set()` on press edge).
- **Simulation:** `Health`, `HitRadius`, `DamageEvent` (buffer), `AbilityStats`, `AbilityCooldown` (`[GhostField]`), `Projectile` (`[GhostField]` Direction+SpawnId), `ProjectileSpawner`, `TrainingDummyTag`, `TrainingDummySpawner`; systems `AutoTarget` (static), `AbilityFireSystem` (predicted, `IsFirstTimeFullyPredictingTick`-gated, server-branch auto-target), `ProjectileMoveSystem` (predicted).
- **Client:** `ProjectileClassificationSystem` (predicted-spawn classifier; **non-Burst** — see DR-003).
- **Server:** `ProjectileDamageSystem` (swept hit), `HealthApplyDamageSystem`, `TrainingDummySpawnSystem` (spawns 3 dummies, self-disables).
- **Authoring/assets:** `Projectile.prefab` (OwnerPredicted ghost), `TrainingDummy.prefab` (Interpolated ghost), `PlayerAuthoring` bakes Health/AbilityStats/AbilityCooldown/DamageEvent; both spawners wired into `Gameplay.unity`; `Application.runInBackground` enabled (M1 follow-up).
- **Tests:** `AutoTargetTests`, `ProjectileMoveSystemTests`, `HealthApplyDamageSystemTests`, `ProjectileDamageSystemTests` (incl. tunnelling regression) — **EditMode 22/22 green**.
### Runtime validation (Play Mode, in-editor, `execute_code` inspection)
- connect → server spawns 3 dummies (HP 60) → **replicated to client** (both worlds show 3 dummies); player ghost spawns with Health 100 / AbilityStats / AbilityCooldown.
- Server-injected projectile → `ProjectileMoveSystem``ProjectileDamageSystem` (swept hit) → `DamageEvent``HealthApplyDamageSystem`**dummy HP 60→40**, and the drop **replicated server→client** via the `Health` `[GhostField]`.
- Bug caught + fixed at runtime: a fast (speed-25) projectile tunnelled the point hit-check; the swept fix made it hit. Slow (speed-3) hit before the fix. Regression test added.
### Method
Orchestrated authoring + 3-lens adversarial review of all 25 logic files via a background workflow against a frozen build contract; applied in compile-gated clusters; every volatile Netcode 1.13.2 API verified via `unity_reflect`/context7 + the official ECS samples before coding.
## Decisions
- [[DR-003_M2_Combat_Netcode_Architecture]] — predicted projectiles, server auto-target/damage, swept hits, non-Burst classifier.
## Open / deferred
- **ENVIRONMENT (action needed): restart the Unity editor.** A Burst *internal compiler error* (from the first compile, since fixed by de-Bursting the classifier) corrupted the Burst incremental cache → every newly-added `[BurstCompile]` entry point (the 5 combat systems + the generated Health/AbilityCooldown/Projectile ghost serializers) logs "not a known Burst entry point" and runs managed-fallback (slow → server tick-batching + ~3040s play-enter). Code is correct (clean compile + 22/22 tests + runtime damage/replication all work). A **fresh editor launch** (or `Library/BurstCache` wipe while closed) should clear it; re-confirm the console is clean on the next warm play.
- **Live interactive fire not yet validated:** the Input System ignores injected device input while the Game view is unfocused, so the input→`AbilityFireSystem`→predicted-spawn→classification path was validated *structurally* (compiles, instantiates, mirrors the verified sample) but not by a real keypress. **Operator test:** focus the editor in Play Mode, press Space / left-click / RT → expect your predicted projectile + dummy HP drop.
- Deferred (revisit at trigger): mouse-cursor aim for KBM (needs camera ground-ray rig); player death/respawn (currently HP clamps ≥0, dummies despawn); predicted auto-target if the soft server reconcile ever feels off; projectile/dummy visual polish (currently primitive meshes).
## Addendum (same session) — input-validation tooling + prototype visuals
**Headless input interaction (for validation).** The Unity Input System ignores device input while the Game view is unfocused, which blocked headless fire/move validation. Three options were weighed; focus-switching CLI was rejected (fragile, intrusive). Enabled/built:
- `InputSettings.editorInputBehaviorInPlayMode = AllDeviceInputAlwaysGoesToGameView` (+ `runInBackground`) so injected/real input reaches the unfocused game.
- `DebugInputInjectionSystem` (`#if UNITY_EDITOR`, `ProjectM.Client`): runs after the real gather; static pokes (`Fire()`, `SetMove`, `SetAim`, `Stop()`) drive the local player's `PlayerInput`, exercising the authentic command→prediction pipeline (not a shortcut). Cross-platform (pure C#). **Validated: `SetMove(2,0)` drove the player to x≈101 on server, replicated to client** — proving the full input→command→server→sim→replication path (and the tool) works for continuous input.
**Finding — one-shot fire vs tick-batching.** Driving the `Fire` `InputEvent` via the hook did **not** advance `AbilityCooldown.NextFireTick` (AbilityFireSystem never fired), while the server was **tick-batching** (the Burst-cache degradation). Continuous values (Move) survive batching; one-shot events are exactly what Unity's tick-batch warning says gets lost. **Action:** validate fire on a *healthy* (post-editor-restart) editor — re-run the hook (now upgraded to hold Fire across ~10 frames for reliable propagation) or press the key in a focused Game view. If it still fails when healthy, switch AbilityFireSystem's `input.Fire.IsSet` gate to the buffered `.Count` read the HelloNetcode sample uses.
**Prototype visuals.** `PrototypeCameraRig` (MonoBehaviour on Main Camera, `ProjectM.Client`): player-following, fully tunable (pitch/yaw/distance/FOV/ortho), default **mid 3/4 ~45° perspective** (V Rising / D4 feel) — operator-chosen. Bright URP-Lit materials: player = cyan, dummies = red, projectiles = yellow, ground = dark grey; added a ground plane to `SampleScene`. Screenshot confirms framing + colors: `Assets/Screenshots/M2_prototype_view.png`.
**Post-restart verification (2026-05-31).** Operator restarted Unity (Burst cache cleared — the "not a known Burst entry point" flood is gone) and relaunched the Local Reference editor. **Fire now validated end-to-end**: via `DebugInputInjectionSystem.Fire()`, `AbilityFireSystem` fired (`NextFireTick` advanced) and the dummy went **60→20 HP on both server and client** — input→fire→predicted-projectile→swept-hit→damage→replication all confirmed live. The earlier fire failure **was** the Burst-degraded tick-batching dropping the one-shot `InputEvent`. **Projectile ghost-map errors** the operator saw are **not a code bug** — they appear only under **server tick-batching** (predicted-spawn reconciliation races); a clean single fire produces zero ghost errors. Root cause of the persistent batching: **two Unity editors running at once** (Project M + Local Reference) starving Project M's server — close the reference editor (or keep Project M focused) for clean netcode. **Fixed:** `PrototypeCameraRig` startup job-safety bug (it queried `EntityManager` from LateUpdate during subscene load) — now an ECS `PrototypeCameraTargetSystem` publishes the player position to the camera. **M2 marked ✅ Done.**
## Next
**M3 — Data-driven abilities & modifiers** (new, slotted before co-op): ability + character stats in ScriptableObjects → baked blob assets, runtime flat/% modifier stacks → effective stats — design in [[Data_Driven_Abilities]]. Then **M4 — Co-op** over Unity Relay per [[Milestones]].
@@ -0,0 +1,51 @@
---
date: 2026-05-31
type: session
tags: [session, dots, netcode, m3, abilities, data-driven, modifiers]
permalink: gamevault/07-sessions/2026/2026-05-31-m3-data-driven-abilities
---
# Session 2026-05-31 — M3 Data-Driven Abilities & Modifiers
## Goal
Build **M3 — Data-driven abilities & modifiers**: move M2's hard-baked combat/character values into authored ScriptableObjects baked to **blob assets**, add a runtime **flat + % `StatModifier`** stack producing effective stats (server-authoritative + prediction-correct), and prove it by refactoring the projectile ability + 2 sample abilities, exercised by a debug hook **and** a real pickup. Design: [[Data_Driven_Abilities]]; architecture locked in [[DR-004_M3_DataDriven_Abilities_Modifiers]].
## Intake decisions (operator)
Modifiers replicate as a **ghost buffer of `StatModifier`** (both worlds recompute identically); upgrade source = **both** (debug hook + real pickup); **2 extra** sample abilities (fast-light + slow-heavy). Smaller technical defaults decided in-plan (every-tick recompute, raw-byte enum replication, single projectile prefab, `PlayerAimSystem` unchanged, `MaxHealth` single-source via SO, permanent modifiers).
## Done — 22 files, compiles clean, EditMode 38/38, runtime-validated on 6.4.7
### Built (see [[DR-004_M3_DataDriven_Abilities_Modifiers]])
- **Simulation:** `StatIds` (`AbilityId`/`CharacterId`/`StatTarget`/`ModOp` enums); `StatModifier` (replicated `[GhostField]` buffer, `OwnerSendType.All`, raw-byte Target/Op); `EffectiveAbilityStats` / `EffectiveCharacterStats`; `AbilityRef` (`[GhostField]` id) / `CharacterStatsRef`; `AbilityDatabaseBlob` (+ `AbilityDefBlob`/`CharacterStatsBlob`, `TryGet*`); `AbilityDatabase` singleton + `AbilityPrefabElement` companion buffer; `StatMath` (pure fold); `StatRecomputeSystem` (predicted, every-tick); `UpgradePickup` + `UpgradePickupSpawner`. Rerouted `AbilityFireSystem` (effective stats + prefab-by-id + snapshot-at-fire), `PlayerMoveSystem` (effective move). **Deleted** `AbilityStats`, `PlayerMoveStats`.
- **Authoring:** `AbilityDefinition` / `CharacterStatsDefinition` ScriptableObjects; `AbilityDatabaseAuthoring` (blob baker, `DependsOn`); `UpgradePickupAuthoring` / `UpgradePickupSpawnerAuthoring`; `PlayerAuthoring` now references the SOs and bakes refs/effective/modifier-buffer/Health-from-SO.
- **Server:** `UpgradePickupSpawnSystem` (one-shot), `UpgradePickupSystem` (overlap → `AppendToBuffer` + despawn), `DebugModifierInjectionSystem` (`#if UNITY_EDITOR`, server world); `HealthApplyDamageSystem` clamps to effective MaxHealth.
- **Assets:** 4 SO definitions (Primary/FastLight/SlowHeavy + Default character); `UpgradePickup.prefab` (interpolated ghost); `Player.prefab` re-wired; `AbilityDatabase` + `UpgradePickupSpawner` GameObjects added to `Gameplay.unity`.
- **Tests:** `StatMathTests`, `AbilityDatabaseBlobTests`, `StatRecomputeSystemTests`, `UpgradePickupSystemTests` (+ migrated `PlayerMoveSystemTests`) — **EditMode 38/38 green** (16 new + 22 existing).
### Runtime validation (Play Mode, in-editor, `execute_code` inspection)
- **Blob baked into both worlds** (`db=1` each); player spawns with data-driven base effective stats on server **and** client: `move=6, maxHp=100, dmg=20, spd=25, cd=12`; 2 pickups spawned + replicated.
- **Modifier replication + prediction-correct recompute (the key claim):** server-granted `+50 Damage(Flat)` and `+50% MoveSpeed(PercentAdd)`**identical** `effDmg=70`, `effMove=9` and matching modifier buffers on **server and owner-predicted client**. Held **even under tick-batching**.
- **Data-only ability swap:** `CycleAbility` Primary→FastLight → `dmg 20→8, spd 25→40, cd 12→5` on both worlds, `AbilityRef.Id` GhostField replicated — zero code per ability. `ClearModifiers` reverted to base.
- **Real pickup grant:** drove the player over a pickup → modifier granted server-side + pickup despawned (2→1) + replicated; `+10 Damage` folded to `effDmg=18` on both worlds.
- Console: only the expected server-tick-batching warning (in-editor, unfocused, heavy first bake); **no Burst ICE, no ghost-serialization or prediction-divergence errors**.
### Notable fix caught by tests
- **`readonly` blob lookup methods read the array as empty** — a `readonly` struct method calling `BlobArray`'s non-readonly indexer forces a defensive copy that breaks the relative-offset pointer. Dropped `readonly`; reach the blob via `ref blob.Value`. (Caught by `AbilityDatabaseBlobTests` before any runtime use.)
### Method
context7-led research (blob baking, `[GhostField]` buffers, `OwnerSendType`) → Plan-agent design → plan-gated → compile-checkpointed clusters (A data types → B pure tests → C recompute+reroute+deletes atomic → D authoring → E server → F assets/scene → G runtime). `read_console` after every write; `Write` (not delete+recreate) for the GUID-referenced `PlayerAuthoring`; `execute_code` for `List<SO>` wiring (component_properties can't set list/ref fields).
## Decisions
- [[DR-004_M3_DataDriven_Abilities_Modifiers]] — blob definition DB + companion prefab buffer, replicated `StatModifier` buffer, every-tick effective recompute, snapshot-at-fire, server-world debug hook.
## Open / deferred
- **UI/icon/description pipeline** — managed lookup keyed by id, not built (deferred).
- **Multi-prefab abilities** — M3 reuses one projectile ghost prefab (different stats snapshotted at fire); a per-ability *different* projectile ghost would need `ProjectileClassificationSystem` generalized.
- **Timed/removable modifiers** — M3 modifiers are permanent-once-granted; `StatModifier.SourceId` reserved for future `ClearByType`/expiry-on-`NetworkTick`.
- **Standalone-server debug** — the modifier hook is in-editor single-process only; promote to an `IRpcCommand` if remote-determinism testing is needed.
- **Rate-limited turning** — `PlayerAimSystem` still snaps rotation; `EffectiveCharacterStats.TurnRate` is wired but unused.
## Next
**M4 — Co-op** (24 players, client-hosted listen-server over Unity Relay) per [[Milestones]]. The modifier framework is the foundation for the upgrade/loadout meta-game on top of this slice.
@@ -0,0 +1,35 @@
---
id: DR-003
title: M2 combat netcode architecture — predicted projectiles, server-authoritative auto-target & damage, swept hits
status: accepted
date: 2026-05-31
tags:
- decision
- netcode
- combat
- prediction
permalink: gamevault/07-sessions/decisions/dr-003-m2-combat-netcode-architecture
---
# DR-003 — M2 Combat Netcode Architecture
## Context
M2 builds the [[Milestones|combat foundation]]: directional ability fire + deterministic soft auto-target + server-authoritative health/damage, on Unity 6.4.7 / Netcode for Entities 1.13.2. Several architecture forks materially shape every future combat system, so they were settled up front (operator-approved) and validated against the official ECS samples + live `unity_reflect`.
## Decision
1. **Projectiles are owner-predicted ghosts with client predicted-spawn.** The firing client predict-spawns the projectile immediately and a custom classification system pairs it with the server's authoritative ghost by a `SpawnId = (ownerNetworkId << 16) | absoluteFireCount`. Pattern lifted verbatim from HelloNetcode `02_PredictedSpawning` (`ProcessFireCommandsSystem` + `GrenadeClassificationSystem`). Spawning is gated on `NetworkTime.IsFirstTimeFullyPredictingTick` so rollback never double-spawns; the absolute fire count is read from `InputBufferData<PlayerInput>.GetDataAtTick(ServerTick)` (the live `InputEvent.Count` is only the per-tick delta).
2. **Fire is a netcode `InputEvent`** on `PlayerInput` (`[GhostField]`). The client gather (`PlayerInputGatherSystem`, managed `SystemBase`) resets it each frame and calls `.Set()` on the press edge; netcode latches the monotone absolute count into the command buffer.
3. **Auto-target is server-authoritative, resolved at the fire tick.** The client predicts the shot along raw aim; the server runs the in-cone nearest-target search (`AutoTarget.Resolve`, deterministic, ties by index) and writes the assisted direction into the projectile's `[GhostField] Direction`, which reconciles the client's predicted projectile. Because the assist arc is soft (small), the reconciliation correction is minor.
4. **Damage is server-only.** `Health.Current` is a `[GhostField]` (replicated for display/reconcile); hits and destruction run only on the server (`ProjectileDamageSystem``DamageEvent` buffer → `HealthApplyDamageSystem`). Clients do not predict hits/destruction (matches the sample). No Unity Physics — hits are distance/cone math (Physics arrives at M4).
5. **Projectile hit detection is a swept segment-vs-sphere test**, not a point check (see Consequences).
## Consequences
- **Predicted-spawn classification cannot be `[BurstCompile]`d on 1.13.2.** Bursting the classifier trips a Burst *internal compiler error* (type-hash resolution failure) on the cross-assembly generic `SnapshotDataBufferComponentLookup.TryGetComponentDataFromSnapshotHistory<T>()`. `ProjectileClassificationSystem` is therefore a non-Burst `ISystem` (it only runs when ghost spawns are *received* — a cold path). Note: in 1.13.2 that method takes `ref DynamicBuffer<SnapshotDataBuffer>` (the official sample's by-value form is older).
- **Predicted projectile + server auto-target = a deliberate reconciliation curve.** The firing client briefly sees its projectile along raw aim until the server's auto-targeted `Direction` replicates. Acceptable for soft assist + co-op PvE. Upgrade path if it ever feels off: run auto-target predictively (needs deterministic, stable-id target selection on both worlds).
- **Point-distance hit tests tunnel** — a fast projectile (or any projectile while the server tick-batches under load) steps past a target's hit radius in a single tick. Caught at runtime (a speed-25 shot missed; speed-3 hit). Fixed with a swept test over each tick's travel segment (`[curPos - dir*speed*dt, curPos]`), `ProjectileDamageSystem` ordered `[UpdateAfter(ProjectileMoveSystem)]`, earliest-along-path target wins. Regression-tested in `ProjectileDamageSystemTests`.
- **Deferred** (revisit at the noted milestone/trigger): live interactive fire validation (press Fire in a focused editor — Input System ignores injected device input while the Game view is unfocused); predicted-spawn *client* feel; mouse-cursor aim for KBM (needs a camera ground-ray rig); player death/respawn (M2 clamps player HP ≥ 0, only dummies despawn).
Mirrors the server-authoritative + input-only-clients pillar from [[Pillars]]; extends the M1 predicted player slice ([[2026-05-30_M1_Player_Slice]]).
@@ -0,0 +1,40 @@
---
id: DR-004
title: M3 data-driven abilities & modifiers — blob definition DB, replicated StatModifier buffer, every-tick effective recompute
status: accepted
date: 2026-05-31
tags:
- decision
- netcode
- combat
- data-driven
- prediction
permalink: gamevault/07-sessions/decisions/dr-004-m3-data-driven-abilities-modifiers
---
# DR-004 — M3 Data-Driven Abilities & Modifiers
## Context
M2 hard-coded combat/character values into baked components (`AbilityStats`, `Projectile.Speed/Damage/Range`, `PlayerMoveStats`, `Health.Max`). M3 ([[Data_Driven_Abilities]]) moves those into **authored ScriptableObject definitions baked to DOTS blob assets**, with a runtime **flat + % modifier stack** producing effective stats — server-authoritative and prediction-correct. These forks shape the whole upgrade/buff meta-game, so they were settled up front (operator-approved at intake) and validated against context7 (Entities 6.4 / Netcode 1.13.2) + the official ECS samples, then runtime-confirmed. Extends [[DR-003_M2_Combat_Netcode_Architecture]].
## Decision
1. **Definitions → blob, not replicated.** `AbilityDefinition` / `CharacterStatsDefinition` ScriptableObjects → an `AbilityDatabaseAuthoring` baker builds a `BlobAssetReference<AbilityDatabaseBlob>` (`BlobArray` of ability/character entries, each keyed by a stable `AbilityId`/`CharacterId` byte + a `FixedString64Bytes` name) and adds an `AbilityDatabase` singleton. Baked in the gameplay subscene → streams identically into both worlds (config). Definitions are **never replicated**.
2. **Entity/prefab refs live OUTSIDE the blob.** Blob assets don't remap entity references. The per-ability projectile ghost prefab is resolved via `GetEntity` into a **companion `DynamicBuffer<AbilityPrefabElement>{ byte Id; Entity Prefab }`** on the database singleton (entity refs in components *are* patched). `AbilityFireSystem` resolves the prefab by the firing player's `AbilityRef.Id`.
3. **Modifiers replicate as a `[GhostField]` buffer.** `StatModifier : IBufferElementData` (`[GhostComponent(OwnerSendType = SendToOwnerType.All)]`, `[InternalBufferCapacity(8)]`) on the player ghost. **`Target`/`Op` replicate as raw `byte`** (mapped to the `StatTarget`/`ModOp` enums only in the pure math), to keep the generated serializer trivial and dodge the cross-assembly enum-codegen hazard that already de-Bursted the M2 classifier. Server is the only writer; the owner **must** receive it (`All`) or it mispredicts.
4. **Effective stats are derived, never replicated.** `EffectiveAbilityStats` / `EffectiveCharacterStats` (plain `IComponentData`) are computed by `StatRecomputeSystem` (Burst, `PredictedSimulationSystemGroup`, `[UpdateBefore]` Aim+Move so all consumers read fresh) folding blob base + the replicated modifier buffer via the pure `StatMath.Apply``effective = (base + Σflat) × (1 + ΣpercentAdd) × Π(1 + percentMult)`.
5. **Recompute EVERY predicted tick, no dirty flag.** Both inputs (blob base, replicated buffer) are restored on rollback; the `Effective*` components are **not** in the ghost snapshot, so a dirty-flag/change-filter would leave them stale across reprediction. Unconditional per-tick recompute over a tiny buffer is cheap and unconditionally prediction-correct.
6. **Projectile stats are snapshotted at fire.** `AbilityFireSystem` reads `EffectiveAbilityStats` and writes effective Damage/Speed/Range into the spawned `Projectile`, so the downstream move/damage systems are unchanged and predicted+server projectiles match (both folded the same replicated modifiers this tick).
7. **Upgrade source = both.** A real server-authoritative pickup (`UpgradePickup` interpolated ghost + subscene spawner; `UpgradePickupSystem` in `SimulationSystemGroup`, NOT predicted, overlap-grants a modifier via `ecb.AppendToBuffer` and despawns), plus an editor-only `DebugModifierInjectionSystem` running in the **server** world (static pokes → applied to the local player; a client-side append would be stomped by the next snapshot).
8. **`MaxHealth` single source = `CharacterStatsDefinition`.** `PlayerAuthoring` references the SO and seeds `Health.Current=Max` from it; `EffectiveCharacterStats.MaxHealth` (base+mods) is the runtime clamp ceiling in `HealthApplyDamageSystem` (no auto-heal). `PlayerAimSystem` left as-is (it snaps rotation and ignores turn rate; `TurnRate` kept in effective stats for future rate-limited turning).
## Consequences
- **Ghost-buffer-on-owner-predicted-ghost is the load-bearing surface.** Validated at runtime: a server-granted `+50 Damage` / `+50% MoveSpeed` produced **identical** `effDmg=70` / `effMove=9` on server **and** owner-predicted client — replication + recompute agree, no divergence. This held **even under server tick-batching** (the M2 stress condition), confirming the every-tick recompute choice.
- **`readonly` blob methods are a footgun.** A `readonly` struct method that calls a non-readonly member on a `BlobArray` field forces a defensive copy of the array → its relative-offset pointer breaks → the array reads empty. Caught by a unit test (lookups returned false for present ids); fix = drop `readonly`, always reach the blob through `ref blob.Value`.
- **Zero code per new ability.** Two sample abilities (FastLight, SlowHeavy) are pure SO data; swapping `AbilityRef.Id` (a `[GhostField]`) re-points the same fire code at different blob stats. Runtime-confirmed: cycling Primary→FastLight gave `dmg 20→8, spd 25→40, cd 12→5` on both worlds with no code path change.
- **Pickup is interpolated (server-authoritative), like the dummy.** Clients see/despawn it; the grant flows server → snapshot → owner. The debug hook is in-editor single-process only; a standalone-server debug path would need an `IRpcCommand` (deferred).
- **Deviations from the proposed design** (refined at build time): prefab refs → companion buffer (not the blob); recompute every-tick (not the doc's dirty-flag lean); raw-byte enum replication; `MaxHealth` sourced from the SO not a separate authoring number.
Mirrors the server-authoritative + deterministic pillar from [[Pillars]]; the modifier framework is the foundation the full ability/loadout/upgrade system builds on.