Files
Project-M/CLAUDE.md
T
kronic c87dd04e87 Docs: truth pass over CLAUDE.md, DRs and the MOCs (audit M13/M14/M15/M16/M19/L11/L12)
CLAUDE.md — it is loaded every session, so its errors cost the most:
- M13: it still said "No world code until the world-model spike passes
  its design review" while DR-049 has been `accepted` since 07-13 and the
  roadmap says PASSED. A hard contradiction between the two authority
  docs, with CLAUDE.md winning by being always-loaded — plausibly part of
  why Phase 2 never started. Now points at the build spec, warns it needs
  re-anchoring, and links the parked engine fork.
- Records the H2 hazard as a rule: a socket whose SparkId is missing from
  the baked AbilityDatabase silently reads Damage/Range/Cooldown 0.
- L12: the asmdef table was stale on all four rows; also notes the fifth
  compilation unit (Scripts/Editor has no asmdef) and that tests now
  reference Authoring.
- M19: per-machine setup — the machine-loss recovery checklist — was
  missing Blender + blendermcp (which the committed /art-dev skill
  hard-requires) and ctx7, and claimed a ${CLAUDE_PROJECT_DIR} in
  .mcp.json that isn't there.
- Structures / harvest / persistence / BefourStudios bullets rewritten:
  they described deleted subsystems.
- Net-zero rule honoured: the additions pushed it to 41,057 (over the
  40,960 hard limit), so two long art-pipeline bullets moved verbatim to
  the gotchas archive under a dated heading. 39,661 now — inside the
  file's own 39,936 soft ceiling.

- M14: DR-053 files the Bevy fork as PARKED. The guide was reachable from
  NOTHING — one file, zero inbound links, and its gating instruction
  lived only in machine-local memory, which CLAUDE.md explicitly forbids
  as a sole home. It now has a committed home and inbound links.
- DR-054 records this purge, including the three files that were in the
  delete set until their consumers were checked and turned out to be
  load-bearing.
- M15: 13 superseded DRs re-statused with `superseded_by`. Only DR-002
  carried the field before, though the project's own template prescribes
  it.
- M19: Home.md stated three wrong facts (latest DR, latest session, date)
  — refreshed. Pillars.md and Systems_Index.md now carry warning banners:
  both assert things the purges falsified, and Systems_Index still claims
  to be "the accurate map of what exists in code today".
- L11: the 4 genuinely-broken wikilinks fixed (typo'd DR filenames). Zero
  broken vault-internal links now.
- Session log written — the last three commits before today had none,
  breaking the protocol's own bookend rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 13:44:18 -07:00

176 lines
39 KiB
Markdown

# Project M — CLAUDE.md
Multiplayer game on **Unity DOTS (Entities) + Netcode for Entities** — server-authoritative, input-only clients, client prediction. This file is committed and is the authoritative, cross-machine source of conventions. The `/dots-dev` skill drives feature work; one-time stack setup lives in `Docs/dots-setup-task.md`.
## Maintaining this file (size budget — read before editing) ★
**Hard limit: 40 KB (40 960 bytes). This file is context-loaded every session — over-budget gets it truncated. Keep ≥1 KB of headroom below it (target ≤ ~39 KB).** After **any** edit, keep it under budget:
- **Size check** — bash: `wc -c CLAUDE.md` · PowerShell: `(Get-Item CLAUDE.md).Length`. Must be `< 40960`.
- **Archive, don't delete.** When trimming, append the verbose / least-hot detail to the obsidian reference note `Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md` under a **new dated heading** (never overwrite an older snapshot), and leave a one-line pointer + the relevant `[[DR-###]]` link here.
- **Net-zero rule:** every addition is paid for by a condensation elsewhere. Keep only the hottest, highest-recurrence operational rules inline (flag them **★**); depth lives in the archive + DRs.
- Condensation history → the archive's dated headings (each edit's payment noted there).
## Stack — Unity 6.5.1 (`6000.5.1f1`, stable) as of 2026-06-27
| Package | Version | Notes |
|---|---|---|
| `com.unity.entities` | **6.5.0** | Entities/Collections/Graphics track the **Editor** version (6.x). |
| `com.unity.entities.graphics` | **6.5.0** | Renders entities under URP 17.5. |
| `com.unity.collections` | 6.5.0 | (transitive) |
| `com.unity.netcode` | **6.5.0** | Netcode **for Entities** (ECS). NOT `com.unity.netcode.gameobjects`. Unified 6.x since 6.5 (was 1.x). |
| `com.unity.physics` | **6.5.0** | Unity Physics (DOTS). Unified 6.x since 6.5 (was 1.x). |
| `com.unity.charactercontroller` | **1.4.2** | DOTS kinematic collide-and-slide. Declares entities/physics 1.3.15, resolves on 6.5.0 via SemVer floor; compiles+bakes on 6.5. |
| `com.unity.transport` | 6.5.0 | (transitive) |
| `com.unity.burst` | 1.8.29 | (transitive) |
| `com.unity.mathematics` | 1.4.0 | (transitive) |
| `com.rukhanka.animation` | **2.9.0** | Local pkg (`Packages/com.rukhanka.animation`). ECS skeletal animation (Burst CPU/GPU skinning). Resolves on 6.5.0 via SemVer floor. Netcode replication **OFF** → client-derived. See [[DR-022_Animation_Pipeline_Rukhanka_Synty]]. |
Values match `packages-lock.json` (URP 17.5.0). **History:** 6.6.0a6 transport bug — [[DR-002_Unity66_Alpha_Netcode_Transport]] + archive.
## Namespaces & assembly split
Root namespace: **`ProjectM`**. Code lives under `Assets/_Project/Scripts/` in four asmdefs (never create/edit `.csproj`/`.sln`; only `.asmdef`):
| Assembly | Namespace | Runs in | References |
|---|---|---|---|
| `ProjectM.Simulation` | `ProjectM.Simulation` | **client + server** worlds | Entities, **Unity.Transforms**, Collections, Mathematics, Burst, Unity.Physics, Unity.NetCode, Unity.CharacterController |
| `ProjectM.Client` | `ProjectM.Client` | client world only | + Simulation, Unity.Entities.Graphics, **Unity.InputSystem**, Unity.Transforms, Unity.NetCode, **Unity.Physics + Unity.CharacterController** (KinematicCharacterBody source-gen), **Rukhanka.Runtime** (animation), Unity.Networking.Transport, UnityEngine.UI, Rukhanka.Toolbox |
| `ProjectM.Server` | `ProjectM.Server` | server world only | + Simulation, **Unity.Transforms**, Unity.NetCode, Unity.Networking.Transport, Unity.Physics |
| `ProjectM.Authoring` | `ProjectM.Authoring` | bake time (+ scene runtime) | Simulation, Entities, **Unity.Entities.Hybrid**, Collections, Mathematics, Unity.NetCode, Unity.Physics, Unity.CharacterController |
- **Simulation** = components + systems shared by both worlds (most gameplay). **Client/Server** = world-specific. **Authoring** = `…Authoring` MonoBehaviours + `Baker<T>`.
- A **fifth** compilation unit exists by omission: `Scripts/Editor/` has NO asmdef, so it compiles into `Assembly-CSharp-Editor`. `ProjectM.Tests.EditMode` references all four asmdefs incl. **Authoring** (so bakers are testable).
- Other folders: `Assets/_Project/Subscenes/` (baked entity subscenes), `Assets/_Project/Prefabs/`, `Assets/_Project/Tests/EditMode/`.
- Feature folders added since (`Client/UI`, `Client/Settings`, `Server/Automation`, `Server/Persistence`, `Simulation/Automation`, `Simulation/Persistence`) live **inside the existing four asmdefs — no new assemblies**.
## Build gotchas (distilled)
Long-form originals + the milestone each came from: `Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md`. The highest-recurrence hazards are flagged **★**.
### Assemblies, asmdefs & source-gen
- **`Unity.Transforms` must be a DIRECT asmdef reference** for any assembly whose source-gen'd systems touch `LocalTransform`/`LocalToWorld` — transitive visibility compiles hand-written code but the generator emits **CS0246** in `*.g.cs` — SAME failure if the consuming FILE omits `using Unity.Transforms;` (source-gen copies the file's usings into `*.g.cs`; adding a `LocalTransform` query to a system that lacked the using breaks only `*.g.cs`).
- **`Unity.Physics` must ALSO be a DIRECT asmdef ref** for any assembly whose source-gen touches `KinematicCharacterBody` (it nests `Unity.Physics.ColliderKey`) → else CS8377/CS0012 in `*.g.cs` (same class as the Transforms rule).
- **Authoring asmdefs need `Unity.Entities.Hybrid`** (`Baker<T>`) **+ `Unity.Collections`** (baking source-gen). Never name a nested baker `Baker` (shadows `Baker<T>`) — use `FooBaker`.
- **Never name an `IComponentData` `PlayerInput`** and don't `using UnityEngine.InputSystem;` in a file referencing such a component — collides with the managed `UnityEngine.InputSystem.PlayerInput`, generator binds `RefRW<…>` to the class → misleading **CS8377**. Fully-qualify Input System types instead.
- **The generated Input Actions C# wrapper must live inside the consuming asmdef** — set the importer's `wrapperCodePath` (in `.inputactions.meta`) to e.g. `Assets/_Project/Scripts/Client/Input/ProjectMInput.cs`; the default location compiles into `Assembly-CSharp` which asmdefs can't reference. No `.inputactions` edit unless you intend a wrapper regen.
- `IInputComponentData` requires implementing **`FixedString512Bytes ToFixedString()`**.
### Burst hazards ★
- **Cross-assembly generics + enums trip Burst internal compiler errors.** Predicted-spawn classification (`SnapshotDataBufferComponentLookup.TryGetComponentDataFromSnapshotHistory<T>`) and any enum compared inside a Bursted system are the known offenders. Make such systems **plain non-Burst `ISystem`**, and **store ops/schemes/region ids as `byte`, never `enum`** in anything Bursted or in RPC payloads.
- **A Burst ICE corrupts the editor's incremental cache** → afterward, valid `[BurstCompile]` entry points log `"… is not a known Burst entry point"` + run slow managed-fallback. A clean compile + green tests + working runtime confirm the *code* is fine. **Fix = editor restart** (or delete `Library/BurstCache` while closed); a domain reload alone does NOT clear it.
- **Editing a Bursted ISystem's SystemAPI query set on an UNFOCUSED editor can leave a STALE binary** → runtime `InvalidOperationException: "required component type was not declared in the EntityQuery"` from an *unrelated* `GetSingleton<T>` (Burst stack reports the OLD line number). Workaround: Burst compilation OFF for the session; permanent fix = restart. Prefer a **focused** editor for Burst-affecting edits.
### Netcode / prediction ★
- **`PredictedSimulationSystemGroup` runs multiple times per frame on rollback** → predicted systems must be deterministic/idempotent, filter with `.WithAll<Simulate>()`, and use **no wall-clock / `Time.deltaTime` / `System.Random`**.
- **Predicted physics is implicit** — with the netcode-physics package present, Netcode relocates `PhysicsSystemGroup` into `PredictedFixedStepSimulationSystemGroup` (child of the predicted group, **OrderFirst**). `NetCodePhysicsConfig` only tunes lag-comp/run-mode/history; put one in the gameplay subscene with `PhysicGroupRunMode = LagCompensationEnabledOrAnyPhysicsEntities`.
- **The predicted physics group is OrderFirst**, so `[UpdateBefore/After(PredictedFixedStepSimulationSystemGroup)]` from the parent predicted group sorts oddly: `UpdateBefore` is ignored (1-tick offset, still in-sync); for same-tick put the system *inside* the fixed-step group `[UpdateBefore(PhysicsSystemGroup)]`. **`OrderFirst`/`OrderLast` ALSO wins against `[UpdateBefore/After]` the predicted group from the plain `SimulationSystemGroup`** — a server-only system there always runs *after* the predicted group → use `[UpdateAfter(PredictedSimulationSystemGroup)]`, never `UpdateBefore` (Unity logs "Ignoring invalid UpdateBefore…").
- **Move ownerless INTERPOLATED ghosts (enemies, pickups) SERVER-ONLY in the plain `SimulationSystemGroup`** — they aren't predicted; the server has no rollback. Stock `LocalTransform` replication carries position (no hand-written `[GhostField]`). A contact `DamageEvent` appended there drains the *following* tick (~16ms, fine for melee).
- **`PhysicsVelocity` auto-replicates** (Netcode ships the default variant + serializer) — drive a predicted-physics body by writing `PhysicsVelocity.Linear`, not by teleporting `LocalTransform`.
- **Ownerless interpolated ghost ≠ owner-predicted for buffer replication.** A server-spawned ownerless ghost replicates a `[GhostField] IBufferElementData` to all clients with **no `OwnerSendType` / no `GhostOwner`** — server mutations just propagate. `OwnerSendType.All` + `GhostOwner` are only for a predicting owner to recompute its own state.
- **One-off shared-state actions belong on an `IRpcCommand`, not a predicted `InputEvent`** (RPCs are reliable; one-shot `InputEvent`s drop under tick-batching). **★ Raw `InputBufferData` reads: `InputEvent.IsSet` = EVER-pressed — wire counts ACCUMULATE (only the decoded component is delta-corrected); gate on a count-STEP vs the prior tick's command, else one press recasts at every cooldown reopen (07-21).** RPC payloads are plain blittable scalars (`int CellX/CellZ`, not `int2`; no `[GhostField]`). For a SINGLE shared target resolve a **server singleton** — never put an `Entity` in the command; use ghost-id+spawn-tick (`SpawnedGhostEntityMap`) only for many targets.
- **Apply server-only RPC effects in the server `SimulationSystemGroup`, NOT the predicted loop** (rollback would double-apply). Mutating a `DynamicBuffer` is not a structural change, so it's safe while iterating a different query.
- **A system-ordering CYCLE is INVISIBLE to plain-Entities EditMode tests** (they register systems individually, unsorted) — it only throws `ComponentSystemSorter` "circular dependency cycle" at **world creation (Play)**. When you add cross-system `[UpdateBefore/After]`, re-audit the EXISTING `[Update*]` attributes of the systems you order around and **always Play-validate**. [[DR-017_Persistent_Base_Player_Driven_Pacing]]
- **A dev/debug `IRpcCommand` wire TYPE must be UNCONDITIONAL** — the RpcCollection hash must match across release/dev peers; `#if`-gate only the send/receive SYSTEMS, never the struct. **Re-mean bytes, don't rename**: unchanged byte VALUES keep the `[GhostField]` serializer identical → re-bake-free (only authoring *default-value* edits re-bake the subscene).
- **Derive enableable gates instead of replicating them.** e.g. player `Dead` = a LOCAL enableable derived every predicted tick from replicated `Health<=0` (rollback-correct, no `[GhostEnabledBit]`). To write the bit on a disabled entity the query must visit it (`.WithPresent<Dead>()`); **bake the enableable DISABLED** so instances spawn off. Respawn/death *timing* is server-only.
- **Cooldown/spawn "next tick" sentinels:** route every stored tick through **`TickUtil.NonZero(...)`** (a computed `ServerTick+delay` can wrap to 0, the "ready" sentinel) and compare with `NetworkTick.IsNewerThan` / `.TicksSince`, **never** raw `uint <` / subtraction. **★ A BAKED `[GhostField]` scheduled-tick defaults to 0 → an "invalid ⇒ fire" guard STORMS it; for a baked/periodic tick INVERT (0 = not-ready → skip/lazy-stamp) + stamp born-correct. Client cues off a periodic tick ride the ABSOLUTE tick + value-latch + was-counting-down arm-guard — never edge-detect (phantom-fires on `0→stamp` / relevancy re-entry). Tick SOURCE: a predicted-player threat reads `nt.ServerTick` (Geyser); an INTERPOLATED ghost's own effect reads `InterpolationTick` (ZoneTelegraph — predicted pins the fill ~RTT wrong, invisible on loopback).** See [[Geyser_Build_Spec]].
- **`GhostRelevancy` for region splits:** use `GhostRelevancyMode.SetIsIrrelevant` (not `SetIsRelevant`) so untagged/global ghosts stay relevant for free — only enumerate cross-region ghosts to hide. `RegionTag{byte Region}` is **server-only, NOT a `[GhostField]`**. **★ A 2nd region sharing an EXISTING tag (`EnemyTag`) → re-audit every query/cull over it: once-safe global despawns/cleared-checks then wipe or block cross-region (DR-031, DR-040).** `RelevantGhostForConnection` = `{int Connection=NetworkId.Value; int Ghost=ghostId}`. See [[DR-013_M6_Aether_Cycle_Region_Split]].
- **Shared GLOBAL state (resource ledger, `RunInfo`, meta tiers) rides the UNTAGGED director ghost**, never a region-tagged one (`SetIsIrrelevant` would hide it cross-region). Resolve the ledger via its DISTINCT `ResourceLedger` tag (the multi-`StorageEntry` "multiple instances" rule).
- **Frontend world lifecycle (menu → on-demand worlds) ★:** use `CreateClientWorld`/`CreateServerWorld` (they register the `ServerWorld`/`ClientWorld` statics the UI reads; `CreateLocalWorld` was internal pre-6.5, PUBLIC on 6.5.0); menu world via `DefaultWorldInitialization.Initialize(name, false)`. **Never dispose/create worlds inside an ECS system** — do it on a frame-boundary coroutine (`SessionRunner`, `DontDestroyOnLoad`). The gameplay subscene streams in ONLY if a netcode world is the `DefaultGameObjectInjectionWorld` at `LoadScene` time. See [[DR-019_Frontend_Menu_Settings_Saves_Build]].
### Physics & character controller
- **Unity Physics 1.x bakes built-in `UnityEngine` colliders + `Rigidbody`** (the Physics-0.x `PhysicsShapeAuthoring`/`PhysicsBodyAuthoring` are gone). Static collider (no Rigidbody) → baked into the subscene PhysicsWorld, deterministic, no replication. `Rigidbody.FreezeRotation` is **NOT** honored by the baker — zero angular velocity + write rotation each tick, or set `PhysicsMass.InverseInertia = float3.zero`. **A `MeshCollider` bakes ONLY if the mesh has Read/Write enabled** — else `InvalidOperationException` per bake and NO baked shape (classic scene view still shows the collider; only a baked-`CollisionWorld` probe catches it); flip the ModelImporter `isReadable`. Env-collider fidelity is tool-driven: `ColliderFitTools` (audit/apply) refits subscene walls/cover/landmarks to the Game.unity visuals.
- **The player is a Unity Character Controller kinematic character** (NOT a dynamic Rigidbody; M5's `PlayerMoveSystem`/`PlayerPlanarConstraintSystem` deleted, predicted-physics infra kept). `PlayerControlSystem` maps input → `CharacterControl`; `CharacterProcessor` collide-and-slides in the relocated `KinematicCharacterPhysicsUpdateGroup`. CC 1.4.2 API = `IKinematicCharacterProcessor<T>` + `KinematicCharacterDataAccess` + static `KinematicCharacterUtilities.Update_*` (verify with `unity_reflect`).
- **`KinematicCharacterUtilities.BakeCharacter` aborts with a `Rigidbody`** and needs uniform (1,1,1) scale. **`CharacterInterpolation` must be PredictedClient-only** (a `DefaultVariantSystemBase` strips it from server + interpolated prefabs) — else double-interp on remotes. **Do NOT copy the CC sample's global `LocalTransform → DontSerializeVariant`** (project-wide; breaks non-character ghosts that rely on stock `LocalTransform` replication).
- **Top-down CC config:** `SnapToGround=false`, `InterpolateRotation=false` (rotation owned by `PlayerAimSystem`), `SimulateDynamicBody=false`; gravity handled by feeding `float3.zero` to `Update_GroundPushing`.
- **Hit/area tests must be SWEPT, not point checks** — a point check tunnels when the per-tick step exceeds the target radius (high speed *or* tick-batching); test the segment traversed this tick. **In a PLAIN `SimulationSystemGroup` system do NOT use `SystemAPI.Time.DeltaTime`** (wall-frame delta, not the fixed step) — store the per-tick step on the projectile (`Projectile.LastStep`, written in the fixed-step group) and rebuild the segment as `cur - dir*LastStep`. `ecb.DestroyEntity` **at-most-once** per tick (destroyed-bitset; double destroy throws at Playback). **TWO target types in one pass: UNIFY into one best-target loop + one shared bitset** (separate sweeps double-destroy a projectile overlapping both — DR-018). **A per-hit yield `(int)` cast that also gates despawn is an immortal-sink** (sub-1.0→0→no deposit, shot still consumed): guard `math.max(1,(int)yield)` + `[Min(1f)]` authoring.
### Build / structures / grid
- **Grid math** (`BaseGridMath`, still live for spawn rings/respawn/lights): corner-origin, center-returning, **half-open** cell bounds, `math.floor`; lock cell size as a coordinate space once. Structures/placement themselves are deleted — recipe + atomicity rules in [[DR-014_M6_Build_Structures_Automation_Foundation]] if buildables return.
- **Ledger spends:** afford→act else SOFT-FAIL (no cooldown-burn), read LIVE in-loop (no hoist); a Health-less entity silently drops OUT of an aggro snapshot (snapshot ABOVE the early-return).
- **DR-051 purge (07-15) ★:** siege/cycle/core/turret/automation + legacy `AbilityRef` path + onboarding **DELETED** (git = the archive). **Retired byte VALUES stay reserved, never renumbered** (`StructureType` 1-4, `ResourceId.Charge`, `DebugOp` 3/10/11, `TuningKnob` 20-23); `DebugOp.SpawnWave`/`EndSiege` RE-MEANT (force-wave / quiet-arena). **Waves UNGATED** — a baked `WaveDirectorAuthoring` decides by placement. Sockets are THE ability model (frame loadout seeded unconditionally at spawn) — **the Spark defs must be in EVERY gameplay subscene's `AbilityDatabaseAuthoring`; a socket whose SparkId is missing from the baked blob silently reads Damage/Range/Cooldown 0** (audit H2, live-proven). `FrameKind` = Bathynaut(2)/Harpooner(3); `PlayerClass` is gone (FrameId is the single frame identity). [[DR-051_Lantern_Realignment_Purge]] · **2026-08-07 audit purge deleted the whole base/expedition shell** (run FSM, meta shop, prep, boons, build/structures, storage, inventory/equipment, enemy variants + boss): [[DR-054_Audit_Purge_2026-08]].
- **Harvest is single-sink** (→ the shared ledger, via `HarvestMath.DepositYield`). The personal-bag/equipment layer was deleted 2026-08-07; reintroduce LANTERN's carried-vs-banked split *inside HarvestMath*, not at its two call sites.
- **Disk persistence (`SaveData`, single-slot atomic JSON, versioned) ★:** **born-correct load**`CycleDirectorSpawnSystem` (now the ledger host only) applies the menu-staged `PendingSave` AT SPAWN. **v7 = a FRESH EPOCH: `MinLoadableVersion = CurrentVersion = 7`**; additive going forward — the save now carries only the ledger (structure/meta fields persist empty so v7 files still load). See [[DR-019_Frontend_Menu_Settings_Saves_Build]].
### Presentation / juice / VFX
- **All juice/HUD = client-only observe-only `SystemBase` in `PresentationSystemGroup`** (once/frame, no rollback double-fire), never mutates the sim. Read ECS via `SystemAPI.Query` + `EntityManager.CompleteDependencyBeforeRO<T>()` — NOT MonoBehaviour `LateUpdate` (job-safety throw). `Entity` = a stable client dict key per ghost lifetime — **prune the cache each frame** (a pruned ghost = a kill/loss → death VFX); **never `DestroyEntity` a ghost client-side** (`GhostDespawnSystem` owns despawn). Hit-stop = camera punch, **never `Time.timeScale`**.
- **Asset-free presentation:** procedural `AudioClip.Create` SFX; runtime `ParticleSystem` pool; code-built **UI Toolkit**. Prefab-asset edits: `LoadPrefabContents`→modify→`SaveAsPrefabAsset``Unload`. Watch shared-material bleed on re-tint; ACES needs URP grading mode HDR. Detail → archive 07-16.
- **Prototype glue lives in `ProjectM.Client` as MonoBehaviours:** `PrototypeCameraRig` (player-following ARPG cam), `VFXConfig` (static `Instance` + prefab fields bridging authored VFX to `CombatFeedbackSystem`; keep a procedural fallback). A **static presentation bridge must reset on play-enter** via `[RuntimeInitializeOnLoadMethod(SubsystemRegistration)]` (statics survive fast-enter-playmode reloads → stale flash).
- **UITK HUD + menus ★:** `MenuUi` owns the palette/factories/`PanelSettings`/`EventSystem` plumbing; `HudSystem` = a `PresentationSystemGroup` observe-only `SystemBase` owning a runtime `UIDocument` (`sortingOrder 50`, root `pickingMode = Ignore`, tree built once `rootVisualElement != null`). **Runtime UITK needs `PanelSettings` WITH a `themeStyleSheet` AND an `EventSystem` + `InputSystemUIInputModule`** or buttons are silently dead. The build palette (lazy from the client `StructureCatalog`) drives click-to-place: green/red `BuildPreviewMath` ghost → `BuildPlaceRequest` RPC, right-click/Esc cancel, `[`/`]`/R rotate. See [[DR-021_HUD_UITK_BuildPalette]].
- **HUD skin = build-safe `HudTheme` SO of serialized sprite refs** (runtime `Resources.Load` by name is build-stripped); tint MULTIPLIES, never set `unitySlice*` on 9-slices → archive 2026-07-06 + [[DR-024_HUD_Synty_Skin_Theme]].
### Art import (HDRP store packs → URP)
- Synty = URP-native. (BefourStudios HDRP pack deleted 2026-08-07 — 4 reachable textures kept in `_Project/Textures/Env`.)
- **World = the LANTERN murk ★ (DR-051; Synty biomes deleted):** ONE look — `PostFX_Lantern.asset` (ACES; needs URP HDR grading) + `Env_SeabedKit.prefab` (ArtStaging-sourced) + unified RenderSettings (NO skybox; Exp² teal fog {0.02,0.10,0.12}; flat ambient {0.03,0.055,0.08}; density knob 0.035 play / 0.075 staging; camera clearFlags **SolidColor deep-water** — else no-skybox corners bleed blue). `ScenePolicy.IsGameplayScene()` gates the dynamic-look systems — never re-add `scene.name` string checks. `WorldAtmosphereSystem` = water-column murk.
- **A dark-lit screenshot MASKS material bugs — verify material *values*** (`GetPropertyType`-guard before `GetColor`/`GetFloat`; detail → archive 07-16).
- **EG per-instance tint (`URPMaterialPropertyBaseColor`) works ONLY on a Hybrid-Per-Instance `_BaseColor` graph** (AnimatedLitShader yes; stock Synty `Generic_Basic` = Unity-Per-Material → renders but silently no-ops) — check the graph first; else procedural decals. Detail → archive 07-16.
- **VolumeProfile.Add persistence + the URP `m_AssetVersion` build blocker** → archive 2026-07-06 (+ native memory `urp-global-settings-version-blocks-build`).
- **`LocalTransform.FromPosition()` resets Scale=1** — server spawners read the prefab's baked `LocalTransform`, override only Position (Scale is a `[GhostField]` → consistent-but-wrong).
- **Static decor → gameplay subscene** (EG renders only baked entities); **strip colliders from cosmetic props** + no `GhostAuthoring` on scenery (classic-URP colliders are inert to the DOTS PhysicsWorld). **World collision = subscene-only ★:** `Environment`-layer boundary ring + landmark colliders (player blocked via the layer matrix); enemies slide via a server `CollisionWorld.SphereCast` in `EnemyAISystem`. **★ enemy slide has NO pathfinding — a near-vertical wall normal or an embedded spawn FROZE Husks on cover rocks (soft-locks a room on one leftover); fixed 07-07 via `EnemyMoveUtil.Depenetrate` + tangent-slide + an `EnemyNavState` nudge backstop. Re-validate movers aren't frozen when adding Environment cover. 07-10: the nudge is COVER-AWARE — a live destructible-cover ghost (`BlightClutter` carrier) SUPPRESSES the phase-through (breakable ⇒ no soft-lock); static wedges still nudge.** Boundary = `SM_Env_Rock_Cliff` rim. See [[2026-06-08_World_Collision_HUD_Scaling]].
- **A GA "projectile" prefab self-propels** — strip to particles before `Start` (`CombatFeedbackSystem.StripCosmetic`); verify *components*, not the name.
### Aim / facing (SoD model — DR-052) ★
- **`PlayerFacing` is body-yaw ONLY** (moves→face movement; cast window→turn to aim; idle→hold; never passively track the cursor). **Every gameplay direction reads `FacingMath.ResolveAim(PlayerInput.Aim, facing)`** (all AbilityFireSystem archetypes + assist seed + melee cleave) and aim-readout presentation reads the SAME resolver; Movement-archetype sockets never open a cast window (`TickWindowMath`); PlayerAimSystem stays UN-gated (integrator over the snapshot-restored [GhostField]). Scheme byte KBM=0/Gamepad=1; KBM reticle re-raycasts. [[DR-052_SoD_Facing_Underwater_Feel]] + archive 2026-07-06.
### Animation (Rukhanka) ★
Full rationale: [[DR-022_Animation_Pipeline_Rukhanka_Synty]] · [[DR-023_Enemy_Animation_MonsterMash]] · [[Synty_Asset_Inventory]]. Skeletal animation = **Rukhanka 2.9** (the only maintained Entities-native option on 6.4). **Netcode replication OFF** (`RUKHANKA_WITH_NETCODE` undefined) → **client-derived**: `PlayerAnimationDriveSystem` (client-only `SystemBase`, `[WorldSystemFilter(LocalSimulation|ClientSimulation)]` + `[UpdateBefore(RukhankaAnimationSystemGroup)]`) reads replicated state and writes params via `AnimatorParametersAspect`/`FastAnimatorParameter`. No new `[GhostField]`s; no `DefaultVariant` strip (define off → ghost hash unchanged).
- **The rig must bake on the SAME entity that holds the gameplay components the drive job reads** — `Animator` + `RigDefinitionAuthoring` on the **player root** (not a child), flatten skeleton + SMRs under it, else the drive query matches nothing.
- **CPU engine skins via Entities-Graphics GPU deformation → needs a deformation-aware material** (`AnimatedLitShader`, multi-target ShaderGraph + `UniversalTarget`; Synty atlas → `_BaseColorMap`). Stock URP/Lit renders **unskinned static** + a `"does not support skinning"` warning (NOT magenta — that's a reused HDRP sample `.mat`).
- **The 3 deformation ShaderGraphs (incl. `AnimatedLitShader`) live in `_Project/Shaders/`** — GUID-preserved `MoveAsset` out of the Rukhanka "Animation Samples" tree (then deleted; importing those samples drags in 26 subscenes + world-running sample systems + a TMP conflict). Detail → gotchas archive 07-04b.
- **First Rukhanka bake is ~60 s, main-thread-synchronous** (editor freezes — not a hang; cached after).
- **The server runs Rukhanka unless you strip it** — its **deformation** systems are `[WorldSystemFilter(Default)]` (⊇ ServerSimulation). **`ServerStripAnimationSystem`** (server-only one-shot) disables every `Rukhanka.Runtime` system on the server (group-disable cascades; matched by assembly name → no type ref). *Only Play-validation caught this.*
- **Build the controller via the `AnimatorController` API** (`manage_animation` drops enum/Vector blend-tree fields). **Skeleton-root = walk up from a bone to the soldier's direct child**, NOT `SkinnedMeshRenderer.rootBone` (the *bounds* root — head SMR's is `Spine_03` → destroys the lower skeleton).
- **Skinned attachments (graft/rebase/bindpose) + the HUMANOID clip pipeline (Blender per-action FBX rules) → archive 2026-08-07** (long-form; also in the `/art-dev` cookbook). Rig type IS Humanoid; root motion OFF; Optimize Game Objects OFF.
- **ENEMIES reuse the player pipeline** — a Husk = ownerless interpolated ghost = a remote player, so `EnemyAnimationDriveSystem` mirrors the REMOTE path (`LocalTransform` delta velocity + prevPos cache; `IsAttacking = AttackWindup != 0`). **Drop `[RequireMatchingQueriesForUpdate]`** so the prune runs every frame (else a cache entry leaks per kill). Build enemy prefabs via **`EnemyRigTools`**, GUID-preserving (`DeleteAsset+CopyAsset` orphans subscene refs); `WaveSystem` uses `baked.WithPosition` (not `FromPosition` → Scale reset). See [[DR-023_Enemy_Animation_MonsterMash]].
### MCP / editor workflow ★
- **Edit Assets `.cs` ONLY via MCP `apply_text_edits` / `create_script`** (Unity's scripting pipeline) — the raw `Write` tool does NOT reliably trigger a recompile on an unfocused editor → tests/`execute_code` run a **stale assembly**; a raw-`Write`-created NEW `.cs` gets **no `.meta` / no test-discovery** until `refresh_unity scope=all mode=force`. (`Write`/`Edit` are fine for non-asset files: this vault, asmdef JSON, etc.) `script_apply_edits` **`anchor_replace`** (regex) + **`delete_method`** work even on a `struct : ISystem`.
- **`apply_text_edits` with MULTIPLE non-adjacent edits in one call can MISALIGN** — one edit per call (or strict bottom-first), always with `precondition_sha256` (it returns the current SHA on mismatch). **★ One edit can SWALLOW an adjacent attribute/comment line** (07-06 `_portalMat` NRE · 07-07 `[RuntimeInitializeOnLoadMethod]` off `WorldFeelConfig.ResetDefaults` → feedback slice silently dead) — re-read neighbors after editing beside attributes; silent presentation slice → probe its config's `Enabled` in-play. **`create_script` won't overwrite**; full-file rewrites = whole-span `apply_text_edits` (its brace-balance validator guards botched spans) or `manage_script delete`+`create_script` (NON-GUID-referenced files only — systems/tests, never authoring MonoBehaviours). `script_apply_edits replace_method` is safe for class methods but **can't target a `struct : ISystem`**. [[DR-017_Persistent_Base_Player_Driven_Pacing]]
- **`execute_code` runs as a method body** — no `using` directives (parsed as statements); fully-qualify every type. Identify worlds by `world.Name == "ServerWorld"/"ClientWorld"` (flags overlap a shared `Game` bit).
- **`manage_gameobject create` / `manage_prefabs modify_contents` `component_properties` SILENTLY DROP enum + Vector3 fields** — set those via a follow-up `manage_components set_property` and VERIFY through `mcpforunity://scene/gameobject/{id}/component/{Type}` (or read the baked component in `execute_code` after Play). `manage_material set_renderer_color` uses a runtime PropertyBlock that does NOT persist into Play — create + assign a material asset instead.
- **New ghost prefab recipe:** `manage_asset duplicate` a correctly-configured ghost (`UpgradePickup.prefab`) → swap the authoring MB (ownerless/interpolated `GhostAuthoring` + LEG come free). **Runtime-spawn shared ghosts** via a one-shot server spawner (dodges the prespawn handshake); wire baked spawners via `manage_scene load additive``set_active`→create→`save``close_scene`. Detail → archive 07-16.
- **An UNFOCUSED editor throttles Edit mode to near-idle** (MCP pings time out, bridge looks hung — it still queues; `telemetry_ping` succeeds) and stalls EditMode test INIT (pass `run_tests(init_timeout=120000)`, retry). `Application.runInBackground` only helps in **Play** mode. Prefer `refresh_unity scope=scripts` for code-only changes. Ask the operator to **focus Unity** for heavy build/test/Burst sessions.
- **Run an adversarial design-review Workflow (netcode/relevancy · determinism/prediction · reuse/scope → synthesize) BEFORE coding a netcode-heavy slice** — it has pre-caught relevancy traps, singleton collisions, dt-traps, double-destroys.
## Bootstrap & worlds
- `ProjectM.Simulation.GameBootstrap : ClientServerBootstrap` overrides `Initialize` with `AutoConnectPort = 0` (M4 — listen/connect is explicit via the `ConnectionConfig` singleton + per-world ConnectionControlSystems). **Editor default = instant-into-game + MPPM** (creates `ServerWorld` (`WorldFlags.GameServer`) + `ClientWorld` (`WorldFlags.GameClient`)); the `ProjectM/Boot Into Menu (Editor)` EditorPref flips the MAIN editor to the frontend path. **Player builds boot the UITK frontend menu** (`return false` → one menu world, no netcode worlds until a menu choice). See [[DR-019_Frontend_Menu_Settings_Saves_Build]].
- **Scenes (the DR-051 contract — exactly these four):** `MainMenu.unity` (build 0, UITK frontend) · `Game.unity` (build 1, the seabed arena; subscene `Gameplay.unity`) · `DevSandbox.unity` (renamed from Gym; dev tooling + subscene `GymSub.unity`; the `DebugOverlay`/F1-F2 dev scripts gate on this scene NAME) · `ArtStaging.unity` (art viewing, no player; the look's source of truth). All share the LANTERN look (see World bullet). The on-demand lifecycle (`WorldLauncher`/`SessionRunner`/`MainMenuController`) creates the right worlds per menu choice (Single/Host/Join), THEN `LoadScene(Game)` (subscene-streaming rule above).
- **Direction = LANTERN ★ — pivot LOCKED 2026-07-13 ([[DR-048_Lantern_Adoption_Full_Pivot]]).** Co-op action-RPG, *light is territory* (seed-pinned pocket-graph; SoD manual-aim skillshots; suit-frames + Sparks + wild mutations). Supersedes the Awakening-Engine fiction + the Co-op Hades iteration. Operative roadmap [[Roadmap_Lantern_Slice]]; **existing code (combat feel, ability/boon plumbing, run/hub lifecycle, save, regions/relevancy) is QUARRY, not foundation** — keep/rework/mothball per [[Lantern_Strip_Mothball_Inventory]]. World-model review **PASSED** 2026-07-13 ([[DR-049_Lantern_World_Model_Design]] `accepted`) → build against [[World_Model_Build_Spec]]; **re-anchor it first** (its step 1 assumes SaveData `MinLoadableVersion` < 7, which DR-051 already shipped, and its RegionTag→PocketTag line anchors have moved). **★ The engine fork ([[DR-053_Engine_Fork_Bevy_Parked]]) is open — surface it before starting world code.** The prior co-op-Hades core-loop was DELETED 2026-08-07 (audit H1), not mothballed; git is the archive. ★ **general gotcha kept: a serialized prefab bool ignores the C# initializer — flip the value in the prefab.**
## DOTS / ECS conventions (authoritative summary)
Full rules: `.claude/skills/dots-dev/references/dots-conventions.md` (in-repo; travels with the repo). These **replace** classic MonoBehaviour/GameObject patterns.
- **`struct : IComponentData`** is the default (unmanaged, Burst/job-friendly). `class : IComponentData` only for genuine managed refs (main-thread, no Burst). `IBufferElementData` for per-entity arrays. `IEnableableComponent` to toggle state without a structural change.
- **Systems:** `ISystem` (struct) + `[BurstCompile]` is the **default**; `SystemBase` only when touching managed objects. `SystemAPI.Query<…>()` to iterate — **max 7 type args**; read an 8th component via a `ComponentLookup` keyed by the entity (hit twice in Phase 1.7 boons). **Aspects (`IAspect`) are DEPRECATED (Entities 1.4+) — do not author new ones.**
- **Jobs:** `IJobEntity` / `IJobChunk`; thread `JobHandle` through `state.Dependency`; mark inputs `[ReadOnly]`. Allocators: `Temp` (frame), `TempJob` (one job), `Persistent` (must dispose). Burst breaks on managed types/exceptions/reflection/strings.
- **Structural changes** (add/remove component, create/destroy entity) invalidate handles + cause sync points → batch via **`EntityCommandBuffer`** (Begin/End`Simulation`EntityCommandBufferSystem; `.AsParallelWriter()` in parallel jobs).
- **Baking:** `…Authoring` MonoBehaviour + `class FooBaker : Baker<FooAuthoring>``GetEntity(authoring, TransformUsageFlags.…)` then `AddComponent`. Subscenes stream async — entities aren't present the instant a reference exists.
- **Netcode:** ghosts = replicated entities (`GhostAuthoringComponent` + `[GhostField]`); predicted (player-controlled, rolled back) vs interpolated. Core sim runs in `PredictedSimulationSystemGroup` (fixed step, **runs multiple times per frame** on rollback → deterministic/idempotent; filter with `.WithAll<Simulate>()`). **Server-authoritative: clients send input (`IInputComponentData`), not state.** RPCs (`IRpcCommand`) for one-off events. **No wall-clock/`Time.deltaTime`/`System.Random` in predicted sim.**
- **Always verify volatile DOTS/Netcode API shape via context7 at code-time** — do not trust memory. Pinned IDs: Entities → `/websites/unity3d_packages_com_unity_entities_6_5_manual`; Netcode → `/websites/unity3d_packages_com_unity_netcode_1_10_api` (closest published as of 07-07, no 6.x set yet — installed is 6.5.0; re-resolve periodically); ECS samples → `/unity-technologies/entitycomponentsystemsamples`.
## Testing
- **Default = plain-Entities EditMode test:** create a `World`, register the system in `SimulationSystemGroup`, tick, assert. Public API, version-independent. e.g. `HealthApplyDamageSystemTests`. Run via `run_tests(mode="EditMode", assembly_names=["ProjectM.Tests.EditMode"])`.
- **`NetCodeTestWorld` is `internal`** (6.5.0 re-check: not even loaded outside test asmdefs), exposed only to a fixed `[InternalsVisibleTo]` allow-list — to use it, name a test asmdef to match an entry (e.g. `Unity.NetcodeSamples.EditModeTests`) or vendor the test utils. Netcode world boot is covered by the Play Mode check, not a NetCodeTestWorld test. See [[DR-001_Netcode_Test_Harness]].
- Burst/source-gen errors surface at editor compile, not a plain build — always `read_console` after script changes, and run a play/tick test, not just a compile. **Cover swept hit-detection with a tunnelling regression test** (the point-check tunnel bug doesn't surface in a point-based unit test).
## Guardrails
- **Never** edit a `.meta` independently of its asset; delete an asset **and** its `.meta` together.
- **Never** read/write `Library/`, `Temp/`, `obj/`, `Logs/`, `UserSettings/` (generated/cache). Use MCP resources for editor state.
- **Never** create/edit/commit `.csproj`/`.sln` — only `.asmdef`.
- **No asset/scene edits during Play Mode.** Check `editor_state.advice.ready_for_tools` before mutating; package adds/refreshes trigger domain reloads — wait for `is_compiling=false`.
## Memory — three layers (which tool when)
Full protocol + per-layer detail: [[Documentation_Protocol]] (`Docs/Vault/_Meta/Documentation_Protocol.md`). The three layers: **in-repo vault** `Docs/Vault/` (design docs, DRs, session logs — committed) · **basic-memory** MCP (semantic/wikilink recall over the vault) · **native Claude memory** (`memory/`, `MEMORY.md` — machine-local). (serena removed 07-07.)
- Where is X / who calls it → **Grep/Glob**. What did we decide / how does Z work → **basic-memory** → read the vault note. Current DOTS API → **context7**. Conventions → this file. Long-form build lessons → the gotchas archive.
- **Cross-machine rule:** durable truth → the **vault** or **this file** (both committed); native `memory/` is local-only, never the sole home of a decision.
## Per-machine setup (NOT in git — redo on each machine)
`.mcp.json` is committed (one server: `basic-memory`); the **`dots-dev` + `art-dev` skills travel with the repo** (`.claude/skills/dots-dev/`). Each machine still needs: (1) `uv`/`uvx` + Obsidian app + `obsidian-cli` (machine-local, don't sync); (2) basic-memory registration — `uvx basic-memory project add gamevault "<repo>/Docs/Vault" --default` then `uvx basic-memory reindex --full --search --embeddings --project gamevault`; (3) Unity 6.5 open + the Unity-MCP bridge connected (`mcpforunity://editor/state``ready_for_tools`); (4) **Blender 5.1 + the blendermcp addon** (socket 9876) — the committed `/art-dev` skill hard-requires it; (5) **`npx ctx7`** available (+ `CONTEXT7_API_KEY` for higher limits).