From dbc4a92a869887550507822858ebd7daa9db21e7 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 4 Jun 2026 11:46:08 -0700 Subject: [PATCH] Update CLAUDE.md --- CLAUDE.md | 203 +++++++----------- .../_Meta/CLAUDE_Build_Gotchas_Archive.md | 133 ++++++++++++ 2 files changed, 215 insertions(+), 121 deletions(-) create mode 100644 Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md diff --git a/CLAUDE.md b/CLAUDE.md index d67d998ed..e94283530 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,24 +1,24 @@ # 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; the one-time stack setup lives in `Docs/dots-setup-task.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`. -## Stack — reverting to Unity 6.4.7 (stable) as of 2026-05-30 +> **Build-gotcha archive:** the full, verbose per-milestone build lessons were condensed out of this file on 2026-06-04 (to stay under the 40 KB context-load limit). The distilled rules live below in **Build gotchas (distilled)**; the long-form originals are in `Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md`, and the design rationale in the per-milestone DRs (`Docs/Vault/07_Sessions/_Decisions/DR-###`). + +## Stack — Unity 6.4.7 (`6000.4.7f1`, stable) as of 2026-05-30 | Package | Version | Notes | |---|---|---| | `com.unity.entities` | **6.4.0** | Entities/Collections/Graphics track the **Editor** version (6.x). | | `com.unity.entities.graphics` | **6.4.0** | Renders entities under URP 17.4. | | `com.unity.collections` | 6.4.0 | (transitive) | -| `com.unity.netcode` | **1.13.2** | Netcode **for Entities** (ECS). NOT `com.unity.netcode.gameobjects`. Independent 1.x line on Unity 6.4. | -| `com.unity.physics` | **1.4.6** | Unity Physics (DOTS). Independent 1.x line on Unity 6.4. | -| `com.unity.charactercontroller` | **1.4.2** | Unity Character Controller (DOTS, kinematic collide-and-slide). Player movement foundation (M5b). Declares `entities`/`physics` 1.3.15 but resolves on our 6.4.0/1.4.6 via SemVer floor (no downgrade). | +| `com.unity.netcode` | **1.13.2** | Netcode **for Entities** (ECS). NOT `com.unity.netcode.gameobjects`. Independent 1.x line. | +| `com.unity.physics` | **1.4.6** | Unity Physics (DOTS). Independent 1.x line. | +| `com.unity.charactercontroller` | **1.4.2** | DOTS kinematic collide-and-slide. Declares entities/physics 1.3.15 but resolves on 6.4.0/1.4.6 via SemVer floor (no downgrade). | | `com.unity.transport` | 2.7.2 | (transitive) | | `com.unity.burst` | 1.8.29 | (transitive) | | `com.unity.mathematics` | 1.3.3 | (transitive) | -> ✅ Reconciled 2026-06-02: `manifest.json` pins aligned to the resolved **Unity 6.4.7** lock (entities/entities.graphics 6.4.0, URP 17.4.0, test-framework 1.6.0, ugui 2.0.0, multiplayer.center 1.0.1) — a no-op re-resolve (lock unchanged, console clean). The values above now match `packages-lock.json`. See [[DR-008_M5_HomeBase_BaseLayer_Storage]]. - -> **Version history & status (2026-05-30):** built on **6.4.7** (`6000.4.7f1`; Netcode 1.13.2 / Physics 1.4.6 / Entities 6.4.0). Briefly upgraded to **6.6.0a6**, where Netcode→6.6.0, Physics→6.5.0, Entities→6.5.0 all **renumbered** into the editor line — BUT the alpha's **Netcode/Transport runtime is broken** (all in-editor connections fail with "invalid wrapped network interface"; **confirmed engine bug** via a zero-gameplay repro — see `Docs/Vault` DR-002 and `Docs/UnityBugReport-Netcode-Transport-6.6.0a6.md`). **→ Reverting to Unity 6.4.7 for stable netcode runtime.** If returning to 6.6 later, expect the renumber and re-test the runtime. The M1 player slice should port to 6.4 / Netcode 1.13.2 with no or minimal changes — recompile and `read_console` after the downgrade. +Values match `packages-lock.json` (reconciled 2026-06-02; URP 17.4.0, test-framework 1.6.0, ugui 2.0.0, multiplayer.center 1.0.1). **History:** briefly tried **6.6.0a6** (renumbers Netcode→6.6.0/Physics→6.5.0/Entities→6.5.0) but its Netcode/Transport runtime is a **confirmed engine bug** ("invalid wrapped network interface") → reverted. If returning to 6.6, expect the renumber + re-test runtime. See [[DR-002_Unity66_Alpha_Netcode_Transport]] and `Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md`. ## Namespaces & assembly split @@ -27,127 +27,88 @@ Root namespace: **`ProjectM`**. Code lives under `Assets/_Project/Scripts/` in f | Assembly | Namespace | Runs in | References | |---|---|---|---| | `ProjectM.Simulation` | `ProjectM.Simulation` | **client + server** worlds | Entities, **Unity.Transforms**, Collections, Mathematics, Burst, Unity.Physics, Unity.NetCode | -| `ProjectM.Client` | `ProjectM.Client` | client world only | + Simulation, Unity.Entities.Graphics, **Unity.InputSystem** | +| `ProjectM.Client` | `ProjectM.Client` | client world only | + Simulation, Unity.Entities.Graphics, **Unity.InputSystem**, Unity.Transforms | | `ProjectM.Server` | `ProjectM.Server` | server world only | + Simulation, **Unity.Transforms**, Unity.NetCode | | `ProjectM.Authoring` | `ProjectM.Authoring` | bake time (+ scene runtime) | Simulation, Entities, **Unity.Entities.Hybrid**, Collections, Mathematics, Unity.NetCode | - **Simulation** = components + systems shared by both worlds (most gameplay). **Client/Server** = world-specific. **Authoring** = `…Authoring` MonoBehaviours + `Baker`. - Other folders: `Assets/_Project/Subscenes/` (baked entity subscenes), `Assets/_Project/Prefabs/`, `Assets/_Project/Tests/EditMode/`. -### Build gotchas (learned — M1, 2026-05-30) +## Build gotchas (distilled) -- **`Unity.Transforms` must be a DIRECT asmdef reference** for any assembly whose source-gen'd systems use `LocalTransform`/`LocalToWorld`. It is its own assembly; transitive visibility compiles your hand-written code but the Entities generator emits **CS0246** inside the `*.g.cs`. -- **Authoring asmdefs need `Unity.Entities.Hybrid`** (defines `Baker`) **and `Unity.Collections`** (baking source-gen). A nested baker class must **not** be named `Baker` (it shadows `Baker` → CS0308/CS0246) — name it `FooBaker`. -- **Never name an `IComponentData` `PlayerInput`**, and don't `using UnityEngine.InputSystem;` in a file that references such a component: it collides with `UnityEngine.InputSystem.PlayerInput`, and the Entities generator binds `RefRW<…>` to the *managed* class → a misleading **CS8377 "must be a non-nullable value type"**. Fully-qualify Input System types (`UnityEngine.InputSystem.Keyboard.current`) instead. +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`. +- **Authoring asmdefs need `Unity.Entities.Hybrid`** (`Baker`) **+ `Unity.Collections`** (baking source-gen). Never name a nested baker `Baker` (shadows `Baker`) — 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()`**. -- An input-gather system that reads the managed Input System belongs in `GhostInputSystemGroup` as a **non-Burst `ISystem`** (or `SystemBase`), never inside the prediction loop. -### Build gotchas (learned — M2 combat, 2026-05-31) +### Burst hazards ★ +- **Cross-assembly generics + enums trip Burst internal compiler errors.** Predicted-spawn classification (`SnapshotDataBufferComponentLookup.TryGetComponentDataFromSnapshotHistory`, takes `ref DynamicBuffer` in 1.13.2) 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` (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. -- **The generated Input Actions C# wrapper must live inside an asmdef** any system needs to reference. By default it generates next to the `.inputactions` (e.g. `Assets/Settings/`), which has no asmdef → it compiles into `Assembly-CSharp`, and asmdef assemblies (`ProjectM.Client`) **cannot** reference that. Fix: set the importer's `wrapperCodePath` (in the `.inputactions.meta`) to a path inside the consuming asmdef, e.g. `Assets/_Project/Scripts/Client/Input/ProjectMInput.cs`, and delete the old generated file. Read the action map via a managed `SystemBase` holding the wrapper; gather `Fire` as a netcode **`InputEvent`** (reset the field each frame, `.Set()` on the press edge — netcode latches the absolute `Count` into the command buffer; the live component value is only the per-tick delta). -- **Predicted-spawn classification cannot be `[BurstCompile]`d (Netcode 1.13.2).** The cross-assembly generic `Unity.NetCode.LowLevel.SnapshotDataBufferComponentLookup.TryGetComponentDataFromSnapshotHistory()` trips a Burst **internal compiler error** (type-hash resolution). Make the classifier a plain non-Burst `ISystem` (it only runs when spawns are received — cold path). In 1.13.2 that method takes **`ref DynamicBuffer`** (the public HelloNetcode sample's by-value `data` is from an older version). -- **A Burst *internal compiler error* corrupts the editor's Burst incremental cache.** After the error is fixed in code, newly-added `[BurstCompile]` entry points (systems **and** generated ghost-component serializers) keep logging `"... is not a known Burst entry point"` and run managed-fallback (slow → server tick-batching, ~30–40s play-enter). A clean compile + green tests + working runtime confirm the code is fine. Clear it with an **editor restart** (or delete `Library/BurstCache` while closed) — a domain reload alone does not. -- **Projectile/area hit tests must be swept, not point checks.** A point distance check tunnels straight through a target when the per-tick step exceeds the target radius — at high projectile speed *or* whenever the server tick-batches under load. Test the segment the projectile traversed this tick (`[curPos - dir*speed*dt, curPos]`) against each target; order the damage system `[UpdateAfter(MoveSystem)]`. (Caught at runtime, not by a point-based unit test — cover hit detection with a tunnelling regression test.) -- **In-editor input injection needs a focused Game view — unless you change two settings.** By default the Input System ignores injected/real device input while the Game view is unfocused, so headless (MCP `execute_code`) keypress simulation won't drive `IInputComponentData`. Fix (both now set in this project): `InputSettings.editorInputBehaviorInPlayMode = AllDeviceInputAlwaysGoesToGameView` + `Application.runInBackground = true`. For deterministic, device-independent validation prefer the editor-only **`DebugInputInjectionSystem`** (`ProjectM.Client`, `#if UNITY_EDITOR`): poke its statics from `execute_code` — `DebugInputInjectionSystem.Fire()` / `.SetMove(x,z)` / `.SetAim(x,z)` / `.Stop()` — to drive the local player's `PlayerInput` through the authentic command→prediction pipeline. (Validated: `SetMove` drives + replicates movement. One-shot `Fire` propagation needs a healthy editor — tick-batching under a degraded/corrupt-Burst editor drops one-shot `InputEvent`s while continuous values survive.) -- **Prototype presentation glue lives in `ProjectM.Client` as MonoBehaviours.** `PrototypeCameraRig` (on the Main Camera) is a tunable player-following ARPG cam (default mid 3/4 ~45° perspective) that reads the local player ghost's `LocalTransform` each LateUpdate. Bright prototype URP-Lit materials are in `Assets/_Project/Materials/` (player cyan, dummy red, projectile yellow, ground grey). `ProjectM.Client` now references `Unity.Transforms` directly (the rig reads `LocalTransform`). +### Netcode / prediction ★ +- **`PredictedSimulationSystemGroup` runs multiple times per frame on rollback** → predicted systems must be deterministic/idempotent, filter with `.WithAll()`, 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 — like `Fire` — drop under server tick-batching). RPC payloads are plain blittable fields (no `[GhostField]`), scalars only (`int CellX/CellZ`, not `int2`). 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. +- **Derive enableable gates instead of replicating them.** e.g. player `Dead` = a LOCAL enableable derived every predicted tick from replicated `Health<=0` (rollback-correct on server + owner, no `[GhostEnabledBit]`). To write the bit on a disabled entity the query must visit it (`.WithPresent()`); **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. +- **`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]`** (server decides relevancy; client just gains/loses ghosts). `RelevantGhostForConnection{int Connection (=NetworkId.Value); int Ghost (=GhostInstance.ghostId)}`. +- **Shared GLOBAL state (cycle phase, resource ledger, goal meter) rides an UNTAGGED ghost**, never a region-tagged one (`SetIsIrrelevant` would hide it cross-region). Resolve a ledger buffer via a DISTINCT tag (`ResourceLedger`), **never `GetSingleton`** when a second `StorageEntry` buffer exists elsewhere → "multiple instances" throw. -### Build gotchas (learned — M5 physics-in-prediction, 2026-06-01) +### 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`. +- **The player is a Unity Character Controller kinematic character** (NOT a dynamic Rigidbody — `PlayerMoveSystem`/`PlayerPlanarConstraintSystem` were deleted; the DR-006 predicted-physics infra is kept). `PlayerControlSystem` maps input → `CharacterControl`; `CharacterProcessor` collide-and-slides in the relocated `KinematicCharacterPhysicsUpdateGroup`. CC 1.4.2 API = `IKinematicCharacterProcessor` + `KinematicCharacterDataAccess` + static `KinematicCharacterUtilities.Update_*` (verify shape with `unity_reflect`, don't assume the legacy aspect). +- **`KinematicCharacterUtilities.BakeCharacter` aborts if the GameObject has a `Rigidbody`** and needs uniform (1,1,1) scale. **`CharacterInterpolation` must be PredictedClient-only** (register a `DefaultVariantSystemBase` stripping it from server + interpolated prefabs) — else double-interp on remotes. **Do NOT copy the CC sample's global `LocalTransform → DontSerializeVariant`** (project-wide; breaks the 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 distance check tunnels through a target 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`** (it's the 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`. A node hit by N projectiles in one tick: `ecb.DestroyEntity` **at-most-once** (destroyed-bitset; a double destroy throws at Playback). -- **Editing Assets `.cs` with the raw `Write` tool does NOT reliably trigger a Unity recompile** on an unfocused editor — `refresh_unity` did a domain reload *without* recompiling, so tests + `execute_code` ran a **stale assembly** (symptom: behaviour that exists in *neither* the old nor new source). **Always edit Assets `.cs` via MCP `apply_text_edits` / `create_script`** (Unity's own scripting pipeline) — never `Write`. (`Write`/`Edit` are fine for non-asset files: vault, asmdef JSON, etc.) See [[2026-06-01_M5_Physics_In_Prediction]]. -- **Predicted physics is implicit — there is no `PredictedPhysics` toggle.** With the netcode-physics package present (`Unity.NetCode.Physics`, `…Physics.Hybrid`) and predicted ghosts carrying physics components, Netcode relocates `PhysicsSystemGroup` into the **`PredictedFixedStepSimulationSystemGroup`** (a child of `PredictedSimulationSystemGroup`, marked **OrderFirst**). `NetCodePhysicsConfig` only tunes lag-comp / run-mode / history. Put one in the gameplay subscene with `PhysicGroupRunMode = LagCompensationEnabledOrAnyPhysicsEntities` so the group runs whenever physics entities exist. -- **Unity Physics 1.x bakes built-in `UnityEngine` colliders + `Rigidbody`** — the old `PhysicsShapeAuthoring`/`PhysicsBodyAuthoring` (Physics 0.x) are **gone** (`unity_reflect` finds neither). Author a dynamic body with a `CapsuleCollider`/`BoxCollider` + `Rigidbody` (`useGravity=false` → planar/`PhysicsGravityFactor`=0; `isKinematic=false`; `interpolation=Interpolate` → `PhysicsGraphicalSmoothing`). Static colliders = collider, no Rigidbody, baked into the subscene (present identically in server + client worlds, deterministic, no replication). -- **`PhysicsVelocity` auto-replicates** — Netcode ships `PhysicsVelocityDefaultVariant` + a generated serializer, so a predicted-physics ghost needs **no hand-written `[GhostField]`** for velocity (`LocalTransform` is already replicated). Drive the character by writing `PhysicsVelocity.Linear`, not by teleporting `LocalTransform`. -- **`Rigidbody.FreezeRotation` is NOT honored by the DOTS baker** (baked `PhysicsMass.InverseInertia` stays non-zero). Hold a top-down character's facing by zeroing angular velocity each tick + writing rotation directly (`PlayerAimSystem`); set `PhysicsMass.InverseInertia = float3.zero` in a baker/system if a hard lock is needed. -- **Gravity-off bodies accumulate vertical contact impulses permanently** (a capsule rides up a box edge and floats away — looks like tunnelling, isn't). Pin players to the movement plane *after* the physics step: a system in `PredictedSimulationSystemGroup` `[UpdateAfter(PredictedFixedStepSimulationSystemGroup)]` clamping Y to `PlayerSpawner.SpawnPoint.y` + zeroing `Linear.y` (`PlayerPlanarConstraintSystem`). -- **The predicted physics group is OrderFirst**, so a system in `PredictedSimulationSystemGroup` with `[UpdateBefore(PredictedFixedStepSimulationSystemGroup)]` is **ignored** (OrderFirst/OrderLast wins) → 1-tick velocity offset (consistent across server/client/rollback — prediction stays in sync). For same-tick application, put the system *inside* `PredictedFixedStepSimulationSystemGroup` `[UpdateBefore(Unity.Physics.Systems.PhysicsSystemGroup)]` (verified to sort before the step) — but expect cosmetic "invalid UpdateBefore" warnings from the relocation. +### Build / structures / grid +- **Build-grid math must be deterministic + integer-stable:** corner-origin, center-returning, **half-open** cell bounds, `math.floor` (not truncation — negatives). Lock `CellSize`/`PlotSize` as a coordinate space once (`BaseGridMath`, EditMode-tested) — changing them invalidates placed structures. +- **`PlacedStructure{[GhostField] byte Type; int2 Cell (server-only); uint NextTick; uint LastProcessedTick}`** on an ownerless interpolated ghost. **Bake the two tick fields now** (turret reuses `NextTick` as fire cooldown; they're the offline-catch-up linchpin). Only `Type` replicates (client derives `Cell` via `BaseGridMath.WorldToCell`). Data-driven `StructureCatalog` buffer. **Occupancy is DERIVED** by scanning live structure ghosts into a Temp `NativeHashSet`, never a mutable buffer on the baked `BaseAnchor`. +- **Co-op placement atomicity:** commit the `StorageMath.Withdraw` + cell-reservation **in-place inside the RPC foreach** (only `Instantiate` goes through the ECB) so two same-tick requests for one cell can't both pass. +- **Buildable turret = hitscan = reversed `EnemyAISystem`:** nearest living Husk in-region within Range, on `NextTick` cooldown append a direct `DamageEvent{Damage, SourceNetworkId=-1}` → reuses `HealthApplyDamageSystem`. No projectile → no tunnelling, no team model. +- **Resource-gated ability tiers reuse `StatModifier`** — grow ONE `StatModifier{Target=Damage, Op=PercentAdd, SourceId=}` (replace-by-SourceId so the buffer stays bounded); `StatRecomputeSystem` folds it into `EffectiveAbilityStats` on both worlds. `GoalProgress{[GhostField] int Charge, Target}` lives on the global CycleDirector ghost. **Disk-persistence deferred to post-M7** — freeze the save schema + bake structure tick fields now so it's additive. -### Build gotchas (learned — M5b Unity Character Controller, 2026-06-01) +### Presentation / juice / VFX +- **All juice/HUD = client-only managed `SystemBase` in `PresentationSystemGroup`** (once/frame, no rollback double-fire) that OBSERVES replicated state, never mutates the sim. Read ECS via `SystemAPI.Query` in `OnUpdate` + `EntityManager.CompleteDependencyBeforeRO()` — NOT a MonoBehaviour `LateUpdate` (job-safety throw). `Entity` is a stable client dict key for a ghost's lifetime — **prune the cache each frame** (a pruned enemy = a kill → death VFX); **never `DestroyEntity` a ghost from the client** (`GhostDespawnSystem` owns despawn). Hit-stop = a camera punch, **never `Time.timeScale`** (corrupts the deterministic sim). +- **Asset-free presentation:** procedural `AudioClip.Create` SFX; runtime `ParticleSystem` pool (Sprites/Default + HDR start color); code-built uGUI HUD (`RawImage` over `Texture2D.whiteTexture`, legacy `Text` + `LegacyRuntime.ttf`). Edit a prefab asset's component in code via `PrefabUtility.LoadPrefabContents` → modify → **`SaveAsPrefabAsset(root, path)`** → `UnloadPrefabContents`. Watch **shared-material bleed** when re-tinting. ACES tonemapping needs URP color grading mode = HDR (`m_ColorGradingMode=1`). +- **Prototype glue lives in `ProjectM.Client` as MonoBehaviours:** `PrototypeCameraRig` (player-following ARPG cam), `VFXConfig` (static `Instance` + prefab fields bridging authored VFX to the managed `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). -- **The player is now a Unity Character Controller kinematic character, NOT a dynamic Rigidbody.** `PlayerMoveSystem` + `PlayerPlanarConstraintSystem` (M5) are **deleted**. Movement: `PlayerControlSystem` maps `PlayerInput.Move` × `EffectiveCharacterStats.MoveSpeed` → `CharacterControl` (via the unit-tested `CharacterControlMath.DesiredMovement`); `CharacterProcessor` (collide-and-slide) consumes it in `CharacterPhysicsUpdateSystem` (`[UpdateInGroup(KinematicCharacterPhysicsUpdateGroup)]`, relocated into the predicted loop). The DR-006 predicted-physics infra (`NetCodePhysicsConfig`, baked static walls) is **kept** — the CC character sweeps against that same PhysicsWorld. -- **A package declaring an older `com.unity.entities`/`com.unity.physics` dependency can still resolve on our renumbered stack** — Unity treats the dep as a SemVer **floor**, so Entities 6.4.0 satisfies a `1.3.15` requirement and is NOT downgraded. Don't trust a version-string mismatch as "incompatible": **probe** (add the package, confirm `packages-lock.json` kept Entities 6.4.0 / Physics 1.4.6 / Netcode 1.13.2 + a clean compile; rollback if not). CC 1.4.2 verified this way. -- **CC 1.4.2 API shape = `IKinematicCharacterProcessor` + `KinematicCharacterDataAccess` + static `KinematicCharacterUtilities.Update_*`.** The legacy `KinematicCharacterAspect` (IAspect, instance `Update_*`) also exists but is NOT what the 1.4.x samples use — verify the installed shape with `unity_reflect`, don't assume. (A sub-agent's package-cache read disagreed with reflect; reflect + first-try clean compile won.) -- **`KinematicCharacterUtilities.BakeCharacter` aborts** (logs an error, adds nothing) **if the GameObject has a `Rigidbody`** and requires uniform (1,1,1) scale. The player prefab keeps its `CapsuleCollider` (baked into `PhysicsCollider`) but the M5 `Rigidbody` was removed. Two bakers on one prefab GameObject (`PlayerAuthoring` + `PlayerCharacterAuthoring`) is fine — both resolve the same entity. -- **`CharacterInterpolation` must be PredictedClient-only.** `BakeCharacter` adds it to all prefab versions; a `DefaultVariantSystemBase` registers `CharacterInterpolation → [GhostComponent(PrefabType = GhostPrefabType.PredictedClient)]` so it's stripped from server + interpolated-client prefabs (else double-interp on remotes). Verified: server ghost has no `CharacterInterpolation`, client ghost does. -- **Do NOT copy the CC sample's global `LocalTransform → DontSerializeVariant`.** It is project-wide and would break the non-character ghosts here (projectiles/dummies/pickups) that rely on stock `LocalTransform` replication. Our CC character replicates position via the normal owner-predicted `LocalTransform` path; only the `CharacterInterpolation` variant is registered. -- **Top-down CC config (planar, no gravity):** `AuthoringKinematicCharacterProperties` with `SnapToGround=false`, `InterpolateRotation=false` (rotation owned by `PlayerAimSystem`), `SimulateDynamicBody=false` (players don't physically push each other); gravity is handled in the processor by feeding `float3.zero` to `Update_GroundPushing` and never adding a gravity term. Result: stays on the spawn plane (y≈1) with no planar-pin system. +### Art import (HDRP store packs → URP) +- BefourStudios art is **HDRP-authored** → magenta under URP 17.4 + Entities Graphics. **Convert, don't switch pipelines** (HDRP breaks Entities Graphics). Re-author to stock URP/Lit via `Assets/_Project/Scripts/Editor/EnvArtTools.cs` (menu `ProjectM/Art/1. Convert Curated Env Materials`). Synty art is **URP-native — no conversion**. +- **A dark-lit screenshot MASKS material bugs — verify material *values*.** Always `shader.GetPropertyType(idx)`-guard before `GetColor`/`GetFloat`/`GetTexture` (`S_General`'s `_BaseColorMultiply` is a float; `GetColor` on it returns black). Gate source emission on the `_Emissive` flag AND a fixture name. Keep converted env metallic low (0.1–0.2). +- **`VolumeProfile.Add()` does NOT persist** (serializes `{fileID:0}` on save) — use `AssetDatabase.AddObjectToAsset(component, profile)` + `SaveAssets`, verify on disk. +- **`LocalTransform.FromPosition()` resets Scale=1** — server spawners must read the prefab's baked `LocalTransform` and override only Position (Scale is a replicated `[GhostField]` → consistent-but-wrong). +- **Static decor → gameplay subscene** (Entities Graphics renders only baked/EG-spawned entities); **strip colliders from cosmetic props** (else they bake into the PhysicsWorld the CC sweeps), no `GhostAuthoring` on scenery. Cosmetic SampleScene GameObjects (classic URP, `SyntyWorld` root) render via classic URP and their colliders are **inert to the DOTS PhysicsWorld** — no stripping needed there. To swap a subscene object's visual while keeping collision: disable the MeshRenderer, keep the collider. +- **A GA "projectile" prefab self-propels** (non-kinematic `Rigidbody` + collider + `ProjectileMoveScript`) — strip to particles before `Start` (`CombatFeedbackSystem.StripCosmetic`). Verify a prefab's *components*, not its name. -### Build gotchas (learned — M5 home base + shared storage, 2026-06-02) +### Aim controls +- **Client-derived aim rides the EXISTING `PlayerInput.Aim` `[GhostField]`** — mouse-cursor aim computed in `PlayerInputGatherSystem` (managed `SystemBase`, `GhostInputSystemGroup`): `Mouse.current.position` → `Camera.main.ScreenPointToRay` → `AimMath.PlanarAimFromRay` (pure, unit-tested) → player→cursor direction. Only the direction crosses the wire; strafe-while-aiming is free (`Move` already decoupled from `Aim`). +- **Active scheme = last-meaningful-actuation-wins, replicated as `byte`** (`PlayerInput.Scheme`, KBM=0/Gamepad=1 — byte because compared in Bursted `AbilityFireSystem`). Server gates the `AutoTarget` cone to gamepad only → precise mouse, gamepad-only assist. +- **Cursor/reticle = client `PresentationSystemGroup` `SystemBase` (`AimReticleSystem`) that OBSERVES.** Re-raycast the KBM ground point INSIDE that system (PresentationSystemGroup runs after the follow-cam's LateUpdate) — latching from the gather drifts a frame behind. Hardware cursor hidden while aiming + focused, restored on focus-loss/`OnDestroy`. -- **Ownerless interpolated ghost ≠ owner-predicted for buffer replication.** A server-spawned **ownerless** ghost (e.g. the shared storage chest) replicates a `[GhostField]` `IBufferElementData` to all clients with **no `OwnerSendType` and no `GhostOwner`** — server mutations just propagate. `[GhostComponent(OwnerSendType = SendToOwnerType.All)]` (per `StatModifier`) is **only** for the predicting *owner* to recompute its own state; adding it (or a `GhostOwner`) to an ownerless ghost is wrong. -- **One-off shared-state actions belong on an `IRpcCommand`, not a predicted `InputEvent`.** RPCs are reliable, so deposit/withdraw landed even while the server tick-batched (the M2 one-shot `Fire` InputEvent drops under batching). RPC payloads are **plain blittable fields — no `[GhostField]`**; store an op as a **byte**, not an enum (cross-assembly enum-Burst hazard, same one that de-Bursted `ProjectileClassificationSystem`). For a **single** shared target, resolve it as a **server singleton** — never put an `Entity` (not stable cross-world) in the command; only reach for a ghost-id+spawn-tick (`SpawnedGhostEntityMap`) when there are many targets (and that lookup may force the handler off Burst). -- **Apply server-only RPC effects in the server `SimulationSystemGroup`, NOT the predicted loop** — the predicted loop re-runs on rollback and would double-apply. (Mutating a `DynamicBuffer` is not a structural change, so it's safe to do while iterating a *different* entity query, e.g. the RPC requests.) -- **Build-grid math must be deterministic + integer-stable.** Corner-origin + center-returning + **half-open** cell bounds + `math.floor` (not truncation — negatives). Lock `CellSize`/`PlotSize` as a coordinate space once: M6 placement builds on it; changing them later invalidates placed structures. (`BaseGridMath`, unit-tested in EditMode like `PlayerSpawnMath`.) -- **Runtime-spawn shared ghosts; don't bake them into the subscene.** A one-shot server spawner (mirrors `UpgradePickup`/`TrainingDummy`) keeps the subscene ghost-free and dodges the prespawn section-ack/CRC handshake. Do **not** add such a ghost to a connection's `LinkedEntityGroup` if it must survive that player's disconnect (the shared base is world-owned). -- **Build a correctly-configured ghost prefab by duplicating an existing one** (`UpgradePickup.prefab` → `Storage.prefab`, then swap the authoring MonoBehaviour via `manage_prefabs modify_contents`) rather than hand-adding `GhostAuthoringComponent` — its ownerless/interpolated settings (`HasOwner=0`, `DefaultGhostMode=Interpolated`) + `LinkedEntityGroupAuthoring` come along for free. -- **`execute_code` runs as a method body** — **no `using` directives** (they parse as statements → "Identifier expected"); fully-qualify every type (`Unity.Entities.World`, `ProjectM.Simulation.BaseAnchor`, …). Also: world flags overlap a shared `Game` bit, so identify worlds by `world.Name == "ServerWorld"/"ClientWorld"` rather than `(Flags & GameServer)`. -- **An unfocused editor throttles Edit mode to near-idle** → MCP pings time out and the bridge looks hung (it still *queues* commands — `telemetry_ping` succeeds). `Application.runInBackground` only helps in **Play** mode. If it wedges, focus or restart the editor; don't pile `refresh_unity` calls onto a blocked main thread. Prefer `refresh_unity scope=scripts` for code-only changes (`scope=all force` is heavy and contributed to a mid-session hang). - -### Build gotchas (learned — M5.5 game feel & identity, 2026-06-02) - -- **Move an ownerless INTERPOLATED enemy ghost SERVER-ONLY in the plain `SimulationSystemGroup`, never in `PredictedSimulationSystemGroup`** (interpolated ghosts aren't predicted; the server has no rollback). Use `[UpdateAfter(PredictedSimulationSystemGroup)]`, **NOT `[UpdateBefore]`** — the predicted group is **OrderFirst** in `SimulationSystemGroup`, so `UpdateBefore`/`After` *it* is silently *ignored* (Unity logs "Ignoring invalid UpdateBefore… OrderFirst/OrderLast has higher precedence"). A plain-`SimulationSystemGroup` server system therefore always runs **after** the predicted group, so a contact `DamageEvent` it appends drains the **following** tick (~16ms, fine for melee). Stock `LocalTransform` replication carries position — **no hand-written `[GhostField]`**. Build the enemy ghost by **duplicating an existing interpolated ghost** (`UpgradePickup.prefab` → `Enemy.prefab`) so the ownerless/interpolated `GhostAuthoringComponent` comes free — the training dummy is **not** a ghost (server-only → invisible to clients). See [[DR-009_GameFeel_Identity_FirstBlood]]. -- **Derive an enableable gate from already-replicated state instead of replicating it.** Player `Dead` = a LOCAL enableable derived every predicted tick from the replicated `Health<=0` (`PlayerDeathStateSystem`, runs in **both** worlds, before movement/aim/fire) — the same derive-don't-replicate idiom as `StatRecomputeSystem`/`EffectiveCharacterStats`; rollback-correct on server + owner-client with **no `[GhostEnabledBit]`**. To write the bit on a currently-disabled entity the query must visit it: `.WithPresent()` (write) vs `.WithDisabled()` (alive-only run); `.WithAll()` ANDs independently. **Bake the enableable DISABLED** (`AddComponent(e); SetComponentEnabled(e, false);` in the baker) so instances spawn in the off state (instantiated entities inherit the prefab's enabled state). Respawn TIMING is server-only (`SimulationSystemGroup`, after the predicted group). -- **All juice/HUD = client-only managed `SystemBase` in `PresentationSystemGroup`** (once per frame, no rollback double-fire) that OBSERVES replicated state — never mutates the sim. Read ECS via `SystemAPI.Query` inside `OnUpdate` + `EntityManager.CompleteDependencyBeforeRO()` — NOT from a MonoBehaviour `LateUpdate` (that throws the job-safety exception the camera rig hit). `Entity` is a stable client dict key for a ghost's lifetime — **prune the cache each frame** (a pruned enemy = a kill → death VFX at its last pos; **never `DestroyEntity` a ghost from the client** — `GhostDespawnSystem` owns despawn). Netcode-safe "hit-stop" = a camera punch, **never `Time.timeScale`** (it would corrupt the deterministic sim). -- **Asset-free presentation:** procedural `AudioClip.Create` SFX; a runtime `ParticleSystem` pool (Sprites/Default material + HDR start color so bursts bloom); a code-built uGUI HUD (`RawImage` over `Texture2D.whiteTexture` for anchor-driven bars + legacy `Text` with `Resources.GetBuiltinResource("LegacyRuntime.ttf")`). To edit a prefab asset's component in code: `PrefabUtility.LoadPrefabContents` → modify → **`SaveAsPrefabAsset(root, path)`** → `UnloadPrefabContents` (`SavePrefabAsset` rejects the contents root — "Can't save a Prefab instance"). Watch **shared-material bleed** when re-tinting (`M_Dummy` doubled as the wall material → orange walls; Husks got their own `M_Husk`). **ACES tonemapping needs the URP asset color grading mode = HDR** (`m_ColorGradingMode = 1`). -- **The "0 = ready" raw-`uint` cooldown sentinel can collide at tick wraparound** — a computed `ServerTick + delay` can equal 0. Route every cooldown/spawn "next tick" write through **`TickUtil.NonZero(...)`** (coerce 0→1), and compare stored ticks with `new NetworkTick(raw).IsNewerThan(serverTick)` / `.TicksSince(...)` — **never** raw `<` / subtraction — the HUD cooldown bar included. (Caught by the adversarial review; `RespawnMath` already guarded it.) -- **An unfocused editor stalls EditMode test INIT** ("tests did not start within timeout") and slows play-enter domain reloads — pass `run_tests(init_timeout=120000)` and retry; ask the operator to focus Unity for heavy build/test sessions (`Application.runInBackground` only helps in Play mode). - -### Build gotchas (learned — art import / Synty packs → URP, 2026-06-03) - -The imported store art (BefourStudios; future Synty) is **HDRP-authored** but we run **URP 17.4 + Entities Graphics** → source `M_*` materials render **magenta**. The reusable converter is `Assets/_Project/Scripts/Editor/EnvArtTools.cs` (menu `ProjectM/Art/1. Convert Curated Env Materials`); it outputs stock URP/Lit to `Assets/_Project/Materials/Env/`. See [[DR-010_Art_Import_URP_Conversion_Visual_Upgrade]]. - -- **Convert, don't switch pipelines.** Re-author to **stock URP/Lit** (`933532a4…`, the same shader our prototypes use → DOTS-instancing/Entities-Graphics compatible). FBX meshes + `T_*_B/_N/_ORM` textures are reusable as-is; the auto-generated `MI_*` URP stubs are blank. Switching the project to HDRP is NOT an option (breaks Entities Graphics). -- **A dark-lit screenshot MASKS material bugs — verify material *values*, not just the render.** `S_General` exposes a **float** `_BaseColorMultiply` and the real Color in `_AlbedoTint`. `HasProperty("_BaseColorMultiply")` is true but `GetColor()` on a float returns **(0,0,0)** (and logs a "doesn't have a color property" warning) → black albedo everywhere. **Always `shader.GetPropertyType(idx)`-guard before `GetColor`/`GetFloat`/`GetTexture`.** -- **Gate source emission on the `_Emissive` (0/1) flag AND a fixture name** — `S_General` carries a non-zero default `_EmissiveColor` even when off; reading it unconditionally makes crates/walls/domes glow (flat color can't reproduce the source emission mask). -- **`VolumeProfile.Add()` does NOT persist the override** — on save the `components` list serializes `{fileID:0}` nulls (works in-session, gone after reload). Use `AssetDatabase.AddObjectToAsset(component, profile)` per effect, then `SaveAssets`; verify non-null refs on disk. -- **`LocalTransform.FromPosition()` resets Scale=1**, silently discarding a ghost prefab's authored scale (Scale is a replicated `[GhostField]` → consistent-but-wrong, not a desync). Server spawners must read the prefab's baked `LocalTransform` and override only Position (fixed in `UpgradePickupSpawnSystem`/`SharedStorageSpawnSystem`). -- **High metallic + no reflection probe + dark skybox = near-black.** Keep converted env metallic low (0.1–0.2); rely on albedo + direct light. -- **Static decor goes in the gameplay subscene** (Entities Graphics renders only baked/EG-spawned entities; a SampleScene MeshRenderer renders via classic URP). **Strip colliders** from cosmetic props (else they bake into the static PhysicsWorld the CC sweeps) and put **no GhostAuthoring** on scenery. Edit the subscene via `manage_scene load … additive` → place → `SaveScene` → `close_scene` (re-bakes on Play); the baked-entity view disappears while it's open additively — verify placement via `execute_code` over the scene roots, not the game view. -- **HUD-free beauty shot** = a **positioned `game_view` capture** (`view_position`/`view_rotation`) — direct camera rendering excludes Screen-Space-Overlay UI. `scene_view` rejects positioned capture. -- **VFX (GabrielAguiar) is now imported** (499 prefabs, ~94% Shuriken; 27 VFX-Graph). Wired into combat via [[DR-011_Synty_World_VFX_Integration]] — see the VFX gotchas below. VFX-Graph (hits/beams) packs still need separate URP setup if wanted. - -### Build gotchas (learned — Synty world + GabrielAguiar VFX, 2026-06-03) - -The world is now a cohesive **Synty** sci-fi colony + GabrielAguiar combat VFX. See [[DR-011_Synty_World_VFX_Integration]] / [[2026-06-03_Synty_World_And_VFX]]. - -- **Synty is URP-native — NO conversion** (unlike the HDRP BefourStudios art). The grounded world is built as **cosmetic classic-URP GameObjects in SampleScene** (`SyntyWorld` root), NOT the DOTS subscene — the custom `Synty/Generic_Basic` shader just renders, and you never have to verify its Entities-Graphics DOTS-instancing. Only the gameplay subscene needs Entities Graphics + the baked PhysicsWorld. -- **Cosmetic SampleScene colliders are inert to gameplay** — classic PhysX is separate from the DOTS PhysicsWorld (baked only from the subscene); the planar-pinned CC never sees them. So a cosmetic world needs no collider stripping for *gameplay*. -- **"Grounded" = surround + horizon, not a bigger plane** — a skyline ring of tall buildings + a planet/asteroid backdrop + a `Skybox/6 Sided` space skybox + light fog killed the floating-plane far better than extending the ground. -- **Swap a subscene object's VISUAL while keeping collision:** disable its MeshRenderer but keep the BoxCollider — the collider still bakes to the static `PhysicsCollider`, and a disabled renderer bakes no RenderMesh (invisible wall, collision intact). Used to retire the BefourStudios walls under the Synty world. -- **A GA "projectile" prefab is NOT a passive trail** — it ships a non-kinematic `Rigidbody` + collider + `ProjectileMoveScript` (self-propels, collides, spawns secondary muzzle/hit VFX). Any authored VFX dropped into a cosmetic slot must be **stripped to particles**: destroy `Rigidbody`/`Collider` and disable `Projectile`/`Move`-named MonoBehaviours BEFORE their `Start` runs (`CombatFeedbackSystem.StripCosmetic`). Verify a prefab's *components*, not its name. -- **VFX prefab → client SystemBase bridge:** a MonoBehaviour with a static `Instance` + prefab fields in the bootstrap scene (`VFXConfig`, mirrors `PrototypeCameraRig`) hands authored assets to the managed `CombatFeedbackSystem`; keep a procedural fallback so a null slot still runs. Derive VFX TTL from the prefab's longest ParticleSystem (not a blanket constant) and cap concurrent VFX to bound GC churn under swarms. -- **Projectile-follow VFX:** query `SystemAPI.Query>().WithAll()` each presentation frame; dict-by-`Entity` spawn/reposition/prune (same idiom as the Health FX cache). The one-shot Fire `InputEvent` still drops under the **unfocused** editor, so fire-driven VFX (muzzle/trail/enemy-death) need a focused editor / real client to fire. - -### Build gotchas (learned — aim controls: mouse cursor + gamepad, 2026-06-03) - -The KBM/gamepad aim rework is [[DR-012_Aim_Controls_Cursor_Gamepad]] / [[2026-06-03_Aim_Controls_Cursor_Gamepad]]. - -- **Client-derived aim rides the EXISTING `PlayerInput.Aim` `[GhostField]` — no new netcode surface.** Mouse-cursor aim is computed client-side in `PlayerInputGatherSystem` (managed `SystemBase`, `GhostInputSystemGroup`, once/frame): `Mouse.current.position` → `Camera.main.ScreenPointToRay` → `AimMath.PlanarAimFromRay` (pure, Burst-safe, unit-tested) against the player's movement plane → write the player→cursor direction as `Aim`. Only the resulting direction crosses the wire; predicted/server systems are unchanged. Movement (`Move`) is already decoupled from facing (`Aim`/`PlayerFacing`), so **strafe-while-aiming is free** the moment `Aim` is the cursor. Don't add a mouse binding to the `Aim` action — the gather reads the device directly (no `.inputactions` edit → no wrapper regen). -- **Active input scheme = last-meaningful-actuation-wins, replicated as a `byte` (NOT an enum).** Detect KBM vs gamepad each frame (stick/trigger/button past a 0.04 lengthsq deadzone vs mouse-delta/click/movement-key; `InputDevice.lastUpdateTime` breaks ties; hold last when idle) and stream `PlayerInput.Scheme` (`InputSchemeId.KeyboardMouse=0/Gamepad=1`). It is a **byte** because it is compared inside the Burst-compiled `AbilityFireSystem` (the cross-assembly enum-in-Burst ICE hazard). The server gates the `AutoTarget` cone to `applied.InternalInput.Scheme == Gamepad` (read at the fire tick from the same `GetDataAtTick` lookup) → **precise mouse, gamepad-only assist**; mouse then predicts == server (fewer reconcile snaps). -- **A static presentation bridge must reset on play-enter.** `AimPresentation.Scheme` (mirrors `PrototypeCameraRig`/`VFXConfig` statics) needs `[UnityEngine.RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]` to reset — statics survive **fast-enter-playmode** domain reloads, and a stale value flashes the wrong cursor/reticle for the first frames (caught by the adversarial review). -- **Cursor/reticle = client `PresentationSystemGroup` `SystemBase` (`AimReticleSystem`) that OBSERVES, never mutates.** A flat world-space ground ring (primitive quad, `Sprites/Default` with a null-guard fallback, procedural ring texture) is the aim indicator for BOTH schemes — KBM at the cursor's ground-projection point, gamepad a fixed distance ahead along replicated `PlayerFacing`. The hardware cursor is **hidden while aiming + focused** (`Application.isFocused`-gated) and restored on focus-loss / `OnDestroy`. A radial **dead-zone** (`AimMath.PlanarAimFromRay` `deadZoneRadius`) holds facing when the cursor is over the character. **The KBM ground point is re-raycast INSIDE `AimReticleSystem`** (PresentationSystemGroup runs after the follow-cam's LateUpdate), not latched from the gather (`GhostInputSystemGroup`, before the move) — latching there drifts the ring a frame behind the cursor under the moving camera. Optional camera **aim look-ahead** (`PrototypeCameraRig.AimLeadDistance`, tunable) leads the framed point toward `PlayerFacing` (not the live cursor projection, to avoid a feedback loop). Headless validation: drive `DebugInputInjectionSystem` (now stamps `Scheme`) + force `AimPresentation.Scheme`; the **real cursor / live device-switch needs a focused Game view** (the unfocused editor can't inject mouse position). - -### Build gotchas (learned — M6 Aether Cycle core loop, 2026-06-03) - -The M6 core-loop slice (Expedition→Defend→Build) + the base/expedition world split. See [[DR-013_M6_Aether_Cycle_Region_Split]] / [[2026-06-03_M6_Aether_Cycle_CoreLoop]]. **Stages 0–1 done; 2–4 are the continuation.** - -- **Base/expedition split = coordinate-region + per-connection `GhostRelevancy`, NOT `SceneSystem` streaming** (supersedes DR-008's framing). One server world; the expedition lives at `base + (1000,0,0)`; a server `RegionRelevancySystem` in `GhostSimulationSystemGroup` (before `GhostSendSystem`) sets `GhostRelevancyMode.SetIsIrrelevant` and each tick marks region-tagged ghosts irrelevant to connections whose player is in a different region. **Use `SetIsIrrelevant` (not `SetIsRelevant`)** so untagged/global ghosts (the future cycle director) stay relevant to everyone for free — you only enumerate cross-region ghosts to hide. Verify the API on the installed Netcode (1.13.2) with `unity_reflect`: `GhostRelevancy` singleton has `GhostRelevancyMode` + `NativeParallelHashMap GhostRelevancySet`; `RelevantGhostForConnection{ int Connection; int Ghost }` (`Connection`=`NetworkId.Value`, `Ghost`=`GhostInstance.ghostId`). `RegionTag{byte Region}` is **server-only (NOT a `[GhostField]`)** — the server makes all relevancy decisions; the client just gains/loses ghosts. Reuses the runtime-ghost spawn path verbatim (no baked ghosts → no prespawn handshake), no async-load race; co-op drop-in is free. -- **Region transit + cycle phase use the established byte-RPC + tick-safe + server-`SimulationSystemGroup` patterns.** `RegionTransitRequest{byte TargetRegion}` (resolve sender via `ReceiveRpcCommandRequest.SourceConnection`→`NetworkId`→`GhostOwner`, flip `RegionTag`, teleport `LocalTransform`). The macro loop is a server-only `CycleState` singleton ([GhostField]-pre-annotated for the later CycleDirector ghost) driven by `CyclePhaseSystem` (`[UpdateBefore(WaveSystem)]`); it **gates `WaveSystem`** with a one-line `if (TryGetSingleton(out var c) && c.Phase != CyclePhase.Defend) return;`. All phase timers are wrap-safe `NetworkTick` (`TickUtil.NonZero` + `IsNewerThan`), never raw `uint <`. -- **Editing an existing `[BurstCompile]` ISystem's SystemAPI query set on an UNFOCUSED editor can leave a STALE Burst binary** (managed assembly recompiles with shifted source-gen query indices, Burst's async recompile doesn't finish) → runtime `InvalidOperationException: "… required component type was not declared in the EntityQuery"` thrown from an *unrelated* `GetSingleton` in that system. **Tell:** the Burst stack reports the *old* line number for the failing call. Same family as the M2 Burst-cache gotcha. **Workaround:** `Jobs ▸ Burst ▸ Enable Compilation` OFF for the session (verify `BurstCompiler.Options.EnableBurstCompilation==false`) — everything runs the fresh managed source-gen. **Permanent fix = restart Unity** to clear the cache, then re-enable Burst. Prefer a **focused** editor for Burst-affecting edits. -- **Shared GLOBAL game-state (cycle phase, resource ledger, goal meter) rides an UNTAGGED ghost, never a region-tagged one** — `SetIsIrrelevant` hides a region-tagged ghost (e.g. the base storage) from players in the *other* region. The M6 resource **ledger** is a `StorageEntry` buffer on the global `CycleDirector` ghost, resolved via a distinct `ResourceLedger` tag — **never `GetSingleton`** (the base storage container owns a second `StorageEntry` buffer → "multiple instances" throw). Runtime-proven: the director stays relevant to an expedition player while the base storage despawns. -- **A hit/area sweep that runs in the PLAIN `SimulationSystemGroup` must NOT use `SystemAPI.Time.DeltaTime`** — that group sees the variable *wall-frame* delta, not the fixed tick step, so a `cur - dir*Speed*dt` segment is wrong. Store the per-tick step on the projectile (`Projectile.LastStep`, written by `ProjectileMoveSystem` in the fixed-step predicted group) and reconstruct the swept segment as `cur - dir*LastStep` — tunnel-safe with zero dependence on the consuming system's clock. `ResourceHarvestSystem` runs `[UpdateAfter(PredictedSimulationSystemGroup)]` so it only sees projectiles that survived `ProjectileDamageSystem` (relies on the ~1000u base/expedition coordinate gap so a base shot can't reach a node). A node hit by N projectiles in one tick: deposit per hit but `ecb.DestroyEntity` **at-most-once** (destroyed-bitset + local Remaining copy — a double destroy throws at Playback); persist the decremented `[GhostField] Remaining` via `SetComponent` so depletion carries across ticks. -- **New ghost prefab recipe (proven M6):** `manage_asset duplicate` UpgradePickup.prefab → `manage_prefabs modify_contents` (swap the authoring MonoBehaviour; **strip MeshFilter+MeshRenderer for an invisible state-holder**, keep them for a visible node). Wire the baked spawner into the gameplay subscene: `manage_scene load additive` → `set_active_scene Gameplay` → `manage_gameobject create` (+ `manage_components set_property` for the prefab ref, verify via `mcpforunity://scene/gameobject/{id}/component/...`) → `save` → `set_active_scene SampleScene` → `close_scene` (re-bakes on Play). -- **Run an adversarial design-review Workflow (3 critics: netcode/relevancy, determinism/prediction, reuse/scope → synthesize) BEFORE coding a netcode-heavy slice** — for M6 Stage 2 it caught every one of the above pre-implementation (relevancy trap, singleton collision, dt-trap, double-destroy, lazy-create hazard). -- **`manage_gameobject create` `component_properties` SILENTLY DROPS enum + Vector3 fields** (it set object-refs and simple scalars, but baked authoring enums/`Vector3` stayed at their C# defaults — two gates baked identical, one worked only by coincidence). **Always set those via a follow-up `manage_components set_property` (with a `properties` dict) and VERIFY through the `mcpforunity://scene/gameobject/{id}/component/{Type}` resource** (or, for a ghost, by reading the baked component in `execute_code` after Play). Same caveat applies to `manage_prefabs modify_contents` `component_properties`. Per-renderer color via `manage_material set_renderer_color` defaults to a runtime **PropertyBlock that does NOT persist into Play** — create a material asset (`manage_material create`) and `assign_material_to_renderer`, or use a prefab-stage assign, for colors that survive a domain reload. -- **Walk-in region gates (M6 visibility pass):** a baked `ExpeditionGate{FromRegion,ToRegion,Radius,ArrivalPos}` entity (visible primitive, collider stripped so you pass through) + a server `ExpeditionGateSystem` (plain group, `[UpdateAfter(CyclePhaseSystem)]`) proximity-transits a player whose `RegionTag` matches `FromRegion` (flip RegionTag + teleport to `ArrivalPos`, offset from the destination gate so no re-trigger). Returning to base mid-Expedition expires the cycle timer → Defend ("timer cap + early return"). The expedition is a *place* = cosmetic ground/pillars in **SampleScene** at the +1000 offset (classic URP, like SyntyWorld), not the DOTS subscene; gameplay nodes/gates are the baked subscene entities. -- **Build/automation foundation (M6 Stage 3, the M7 contract):** generic `PlacedStructure{[GhostField] byte Type; int2 Cell (server-only); uint NextTick; uint LastProcessedTick}` on an ownerless interpolated ghost (`RegionTag{Base}`, world-owned, runtime-spawned). **Bake the two tick fields NOW** — the turret reuses `NextTick` as its fire cooldown, and they are the deterministic-offline-catch-up linchpin M7 needs and that can't be reconstructed retroactively. Only `Type` replicates (client derives `Cell` from `LocalTransform` via `BaseGridMath.WorldToCell`). Data-driven `StructureCatalog` buffer (`{byte Type; Entity Prefab; byte CostResourceId; int CostAmount}`, modeled on `AbilityPrefabElement`); M7 adds a recipe column additively. **Occupancy is DERIVED** by scanning live structure ghosts into a Temp `NativeHashSet` (structures are the source of truth — restart/replay-safe), NEVER a mutable buffer on the immutable baked `BaseAnchor`. -- **Co-op placement atomicity:** `BuildPlaceSystem` commits the `StorageMath.Withdraw` + cell-reservation **IN-PLACE inside the RPC foreach** (only the `Instantiate` goes through the ECB) — the `StorageOpReceiveSystem` idiom — so two same-tick `BuildPlaceRequest`s for one cell can't both pass (validated: → exactly one structure + one withdraw). RPC carries `int CellX/CellZ` scalars, not `int2` (scalar-only RPC precedent). -- **Buildable turret = hitscan = reversed `EnemyAISystem`:** snapshot living Husks, nearest-in-same-region-within-Range, on the `NextTick` cooldown append a direct `DamageEvent{Damage, SourceNetworkId=-1}` → reuses `HealthApplyDamageSystem` (despawns at HP≤0). NO projectile → no tunnelling, no friendly-fire/team model. Plain server group `[UpdateAfter(PredictedSimulationSystemGroup)]`. -- **Resource-gated ability tiers reuse `StatModifier` — no new replicated component.** `AbilityUpgradeSystem` spends Aether and grows ONE `StatModifier{Target=Damage, Op=PercentAdd, SourceId=}` on the player (**replace-by-SourceId** so the `[InternalBufferCapacity(8)]` buffer stays bounded — repeated upgrades grow one row, not append); `StatRecomputeSystem` folds it into `EffectiveAbilityStats.Damage` on both worlds (the `UpgradePickup` path). `GoalProgress{[GhostField] int Charge, Target}` lives on the global CycleDirector ghost, single-writer in `CyclePhaseSystem`. **Disk-persistence writer is deferred to post-M7** (in-session-only state, per DR-008); freeze the save schema + bake the structure tick fields now so it's additive. See [[DR-014_M6_Build_Structures_Automation_Foundation]]. +### 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**. (`Write`/`Edit` are fine for non-asset files: this vault, asmdef JSON, etc.) +- **`execute_code` runs as a method body** — no `using` directives (parse 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` an existing correctly-configured ghost (e.g. `UpgradePickup.prefab`) → `manage_prefabs modify_contents` to swap the authoring MonoBehaviour (strip MeshFilter+MeshRenderer for an invisible state-holder) — its ownerless/interpolated `GhostAuthoringComponent` + `LinkedEntityGroupAuthoring` come free. **Runtime-spawn shared ghosts** via a one-shot server spawner; don't bake them into the subscene (dodges the prespawn handshake). Wire a baked spawner into the subscene: `manage_scene load additive` → `set_active_scene Gameplay` → create + set props + verify → `save` → `set_active_scene SampleScene` → `close_scene` (re-bakes on Play). +- **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. Don't pile `refresh_unity` onto a blocked main thread; 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`, sets `AutoConnectPort = 7979` (in-editor auto-connect over IPC; set in M1 — was 0), calls `CreateDefaultClientServerWorlds()`. Entering Play Mode creates separate `ServerWorld` (`WorldFlags.GameServer`) and `ClientWorld` (`WorldFlags.GameClient`) — verified. +- `ProjectM.Simulation.GameBootstrap : ClientServerBootstrap` overrides `Initialize`, sets `AutoConnectPort = 7979` (in-editor auto-connect over IPC), calls `CreateDefaultClientServerWorlds()`. Entering Play Mode creates `ServerWorld` (`WorldFlags.GameServer`) + `ClientWorld` (`WorldFlags.GameClient`). - `Assets/_Project/Subscenes/Gameplay.unity` is wired into `SampleScene` (GameObject `GameplaySubScene`) as a baking target. Replace `SampleScene` with a dedicated bootstrap scene when building for real. +- **Region split:** one server world; the expedition lives at `base + (1000,0,0)`, hidden per-connection via `GhostRelevancy` (see the Netcode gotchas). Expedition *place* = cosmetic ground/pillars in SampleScene at the +1000 offset; gameplay nodes/gates are baked subscene entities. See [[DR-013_M6_Aether_Cycle_Region_Split]]. ## DOTS / ECS conventions (authoritative summary) @@ -158,19 +119,19 @@ Full rules: `~/.claude/skills/dots-dev/references/dots-conventions.md` (Windows: - **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` → `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 → must be deterministic/idempotent; filter with `.WithAll()`). **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. See `context7-libraries.md`. Pinned IDs for our versions: Entities → `/websites/unity3d_packages_com_unity_entities_6_5_manual`; Netcode → `/websites/unity3d_packages_com_unity_netcode_1_10_api` (closest published; we run 1.13.2 — re-resolve if a 1.13 set appears); ECS samples → `/unity-technologies/entitycomponentsystemsamples`. +- **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()`). **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; we run 1.13.2 — re-resolve if a 1.13 set appears); ECS samples → `/unity-technologies/entitycomponentsystemsamples`. ## Testing -- **Default pattern = plain-Entities EditMode test:** create a `World`, register the system in `SimulationSystemGroup`, tick, assert. Public API, always green, version-independent. Example: `Assets/_Project/Tests/EditMode/HeartbeatSystemTests.cs`. Run via Unity Test Runner or MCP `run_tests(mode="EditMode", assembly_names=["ProjectM.Tests.EditMode"])`. -- **`NetCodeTestWorld` is `internal`** in netcode 1.13.2 (`Unity.NetCode.Tests`, assembly `Unity.NetCode.TestsUtils.Runtime.Tests`), exposed only via a fixed `[InternalsVisibleTo]` allow-list of Unity assemblies. To use it you must name a test asmdef to match an entry (e.g. `Unity.NetcodeSamples.EditModeTests`) — or vendor the test utils. See `Docs/Vault/07_Sessions/_Decisions/DR-001_Netcode_Test_Harness.md`. **This does not change on Unity 6.6.** Netcode world boot is covered by the Play Mode check, not a NetCodeTestWorld test. -- Burst/source-gen errors surface at editor compile, not a plain build — always check `read_console` after script changes, and run a play/tick test, not just a compile. +- **Default = plain-Entities EditMode test:** create a `World`, register the system in `SimulationSystemGroup`, tick, assert. Public API, version-independent. Example: `Assets/_Project/Tests/EditMode/HeartbeatSystemTests.cs`. Run via `run_tests(mode="EditMode", assembly_names=["ProjectM.Tests.EditMode"])`. +- **`NetCodeTestWorld` is `internal`** in netcode 1.13.2, 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 instead. +- **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`. @@ -179,17 +140,17 @@ Full rules: `~/.claude/skills/dots-dev/references/dots-conventions.md` (Windows: | Layer | Use for | Crosses machines? | |---|---|---| | **In-repo vault** `Docs/Vault/` | Design docs, decision records (DR-###), session logs, roadmap — human-facing truth | **Yes** (git) | -| **basic-memory** MCP | Semantic/wikilink recall over those same vault files | Yes (indexes the vault) | +| **basic-memory** MCP | Semantic/wikilink recall over those vault files | Yes (indexes the vault) | | **serena** MCP | C# symbol nav (`find_symbol`, references) of `Assets/_Project/` | N/A (from code) | | **Native Claude memory** (`memory/`, `MEMORY.md`) | Machine-local facts, working-style, preferences | **No** | -- Where is X defined / who calls it → **serena** (fallback `Grep`/`Glob`). What did we decide / how does Z work → **basic-memory** → read the vault note. Literal string / asset GUID → **Grep/Glob**. Current DOTS API → **context7**. Conventions → this file. +- Where is X / who calls it → **serena** (fallback `Grep`/`Glob`). What did we decide / how does Z work → **basic-memory** → read the vault note. Literal string / asset GUID → **Grep/Glob**. Current DOTS API → **context7**. Conventions → this file. Long-form build lessons → `Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md`. - **Cross-machine rule:** durable truth goes in the **vault** or **this file** (both committed). Native `memory/` is local-only and does NOT sync — never the sole home of a decision. -- **serena C# caveat:** its language server is flaky on Unity (can auto-install the wrong .NET, `.sln` load timeouts). If `find_symbol` errors/stalls, **fall back to `Glob`/`Grep`** (or add `claude-context` with local embeddings as a code-search index). serena live-verification was deferred at setup; confirm on first use. +- **serena C# caveat:** its language server is flaky on Unity. If `find_symbol` errors/stalls, **fall back to `Glob`/`Grep`**. ## Per-machine setup (NOT in git — redo on each machine) -`.mcp.json` is committed and portable (`${CLAUDE_PROJECT_DIR}` only). The **`dots-dev` skill now travels with the repo** at `.claude/skills/dots-dev/` (project-level, auto-discovered by Claude Code on clone — no manual `~/.claude/skills/` copy needed). But each machine still needs: -1. `uv`/`uvx`, the Obsidian app + `obsidian-cli`. (The `unity-mcp-skill` and native `memory/` notes remain machine-local and do **not** sync — re-install / re-create them per machine if wanted.) -2. **basic-memory project registration** (machine-local config): `uvx basic-memory project add gamevault "/Docs/Vault" --default`, then `uvx basic-memory reindex --full --search --embeddings --project gamevault`. +`.mcp.json` is committed and portable (`${CLAUDE_PROJECT_DIR}` only). The **`dots-dev` skill travels with the repo** at `.claude/skills/dots-dev/` (auto-discovered on clone). Each machine still needs: +1. `uv`/`uvx`, the Obsidian app + `obsidian-cli`. (The `unity-mcp-skill` and native `memory/` notes are machine-local and do **not** sync.) +2. **basic-memory project registration:** `uvx basic-memory project add gamevault "/Docs/Vault" --default`, then `uvx basic-memory reindex --full --search --embeddings --project gamevault`. 3. Unity 6.4 opens the project and the CoplayDev Unity-MCP bridge connects (`mcpforunity://editor/state` → `ready_for_tools`). diff --git a/Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md b/Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md new file mode 100644 index 000000000..64af2812b --- /dev/null +++ b/Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md @@ -0,0 +1,133 @@ +--- +title: CLAUDE.md Build-Gotchas Archive +type: reference +created: 2026-06-04 +--- + +# CLAUDE.md Build-Gotchas Archive + +This note holds the **full, verbose build-gotcha entries** that were condensed out of the committed `CLAUDE.md` on 2026-06-04 to keep that file under its 40 KB context-load limit. The condensed one-liners in `CLAUDE.md` link here and to the per-milestone Decision Records (`Docs/Vault/07_Sessions/_Decisions/DR-###`). Nothing was deleted — this is the long-form source of truth for the operational lessons; the DRs carry the design rationale. + +> When a gotcha here is later proven wrong or superseded, strike it through and note the superseding DR rather than deleting it. + +--- + +## Version history & stack status + +> ✅ Reconciled 2026-06-02: `manifest.json` pins aligned to the resolved **Unity 6.4.7** lock (entities/entities.graphics 6.4.0, URP 17.4.0, test-framework 1.6.0, ugui 2.0.0, multiplayer.center 1.0.1) — a no-op re-resolve (lock unchanged, console clean). The values in the CLAUDE.md stack table match `packages-lock.json`. See [[DR-008_M5_HomeBase_BaseLayer_Storage]]. + +> **Version history & status (2026-05-30):** built on **6.4.7** (`6000.4.7f1`; Netcode 1.13.2 / Physics 1.4.6 / Entities 6.4.0). Briefly upgraded to **6.6.0a6**, where Netcode→6.6.0, Physics→6.5.0, Entities→6.5.0 all **renumbered** into the editor line — BUT the alpha's **Netcode/Transport runtime is broken** (all in-editor connections fail with "invalid wrapped network interface"; **confirmed engine bug** via a zero-gameplay repro — see `Docs/Vault` DR-002 and `Docs/UnityBugReport-Netcode-Transport-6.6.0a6.md`). **→ Reverting to Unity 6.4.7 for stable netcode runtime.** If returning to 6.6 later, expect the renumber and re-test the runtime. The M1 player slice should port to 6.4 / Netcode 1.13.2 with no or minimal changes — recompile and `read_console` after the downgrade. + +--- + +## Build gotchas (learned — M1, 2026-05-30) + +- **`Unity.Transforms` must be a DIRECT asmdef reference** for any assembly whose source-gen'd systems use `LocalTransform`/`LocalToWorld`. It is its own assembly; transitive visibility compiles your hand-written code but the Entities generator emits **CS0246** inside the `*.g.cs`. +- **Authoring asmdefs need `Unity.Entities.Hybrid`** (defines `Baker`) **and `Unity.Collections`** (baking source-gen). A nested baker class must **not** be named `Baker` (it shadows `Baker` → CS0308/CS0246) — name it `FooBaker`. +- **Never name an `IComponentData` `PlayerInput`**, and don't `using UnityEngine.InputSystem;` in a file that references such a component: it collides with `UnityEngine.InputSystem.PlayerInput`, and the Entities generator binds `RefRW<…>` to the *managed* class → a misleading **CS8377 "must be a non-nullable value type"**. Fully-qualify Input System types (`UnityEngine.InputSystem.Keyboard.current`) instead. +- `IInputComponentData` requires implementing **`FixedString512Bytes ToFixedString()`**. +- An input-gather system that reads the managed Input System belongs in `GhostInputSystemGroup` as a **non-Burst `ISystem`** (or `SystemBase`), never inside the prediction loop. + +## Build gotchas (learned — M2 combat, 2026-05-31) + +- **The generated Input Actions C# wrapper must live inside an asmdef** any system needs to reference. By default it generates next to the `.inputactions` (e.g. `Assets/Settings/`), which has no asmdef → it compiles into `Assembly-CSharp`, and asmdef assemblies (`ProjectM.Client`) **cannot** reference that. Fix: set the importer's `wrapperCodePath` (in the `.inputactions.meta`) to a path inside the consuming asmdef, e.g. `Assets/_Project/Scripts/Client/Input/ProjectMInput.cs`, and delete the old generated file. Read the action map via a managed `SystemBase` holding the wrapper; gather `Fire` as a netcode **`InputEvent`** (reset the field each frame, `.Set()` on the press edge — netcode latches the absolute `Count` into the command buffer; the live component value is only the per-tick delta). +- **Predicted-spawn classification cannot be `[BurstCompile]`d (Netcode 1.13.2).** The cross-assembly generic `Unity.NetCode.LowLevel.SnapshotDataBufferComponentLookup.TryGetComponentDataFromSnapshotHistory()` trips a Burst **internal compiler error** (type-hash resolution). Make the classifier a plain non-Burst `ISystem` (it only runs when spawns are received — cold path). In 1.13.2 that method takes **`ref DynamicBuffer`** (the public HelloNetcode sample's by-value `data` is from an older version). +- **A Burst *internal compiler error* corrupts the editor's Burst incremental cache.** After the error is fixed in code, newly-added `[BurstCompile]` entry points (systems **and** generated ghost-component serializers) keep logging `"... is not a known Burst entry point"` and run managed-fallback (slow → server tick-batching, ~30–40s play-enter). A clean compile + green tests + working runtime confirm the code is fine. Clear it with an **editor restart** (or delete `Library/BurstCache` while closed) — a domain reload alone does not. +- **Projectile/area hit tests must be swept, not point checks.** A point distance check tunnels straight through a target when the per-tick step exceeds the target radius — at high projectile speed *or* whenever the server tick-batches under load. Test the segment the projectile traversed this tick (`[curPos - dir*speed*dt, curPos]`) against each target; order the damage system `[UpdateAfter(MoveSystem)]`. (Caught at runtime, not by a point-based unit test — cover hit detection with a tunnelling regression test.) +- **In-editor input injection needs a focused Game view — unless you change two settings.** By default the Input System ignores injected/real device input while the Game view is unfocused, so headless (MCP `execute_code`) keypress simulation won't drive `IInputComponentData`. Fix (both now set in this project): `InputSettings.editorInputBehaviorInPlayMode = AllDeviceInputAlwaysGoesToGameView` + `Application.runInBackground = true`. For deterministic, device-independent validation prefer the editor-only **`DebugInputInjectionSystem`** (`ProjectM.Client`, `#if UNITY_EDITOR`): poke its statics from `execute_code` — `DebugInputInjectionSystem.Fire()` / `.SetMove(x,z)` / `.SetAim(x,z)` / `.Stop()` — to drive the local player's `PlayerInput` through the authentic command→prediction pipeline. (Validated: `SetMove` drives + replicates movement. One-shot `Fire` propagation needs a healthy editor — tick-batching under a degraded/corrupt-Burst editor drops one-shot `InputEvent`s while continuous values survive.) +- **Prototype presentation glue lives in `ProjectM.Client` as MonoBehaviours.** `PrototypeCameraRig` (on the Main Camera) is a tunable player-following ARPG cam (default mid 3/4 ~45° perspective) that reads the local player ghost's `LocalTransform` each LateUpdate. Bright prototype URP-Lit materials are in `Assets/_Project/Materials/` (player cyan, dummy red, projectile yellow, ground grey). `ProjectM.Client` now references `Unity.Transforms` directly (the rig reads `LocalTransform`). + +## Build gotchas (learned — M5 physics-in-prediction, 2026-06-01) + +- **Editing Assets `.cs` with the raw `Write` tool does NOT reliably trigger a Unity recompile** on an unfocused editor — `refresh_unity` did a domain reload *without* recompiling, so tests + `execute_code` ran a **stale assembly** (symptom: behaviour that exists in *neither* the old nor new source). **Always edit Assets `.cs` via MCP `apply_text_edits` / `create_script`** (Unity's own scripting pipeline) — never `Write`. (`Write`/`Edit` are fine for non-asset files: vault, asmdef JSON, etc.) See [[2026-06-01_M5_Physics_In_Prediction]]. +- **Predicted physics is implicit — there is no `PredictedPhysics` toggle.** With the netcode-physics package present (`Unity.NetCode.Physics`, `…Physics.Hybrid`) and predicted ghosts carrying physics components, Netcode relocates `PhysicsSystemGroup` into the **`PredictedFixedStepSimulationSystemGroup`** (a child of `PredictedSimulationSystemGroup`, marked **OrderFirst**). `NetCodePhysicsConfig` only tunes lag-comp / run-mode / history. Put one in the gameplay subscene with `PhysicGroupRunMode = LagCompensationEnabledOrAnyPhysicsEntities` so the group runs whenever physics entities exist. +- **Unity Physics 1.x bakes built-in `UnityEngine` colliders + `Rigidbody`** — the old `PhysicsShapeAuthoring`/`PhysicsBodyAuthoring` (Physics 0.x) are **gone** (`unity_reflect` finds neither). Author a dynamic body with a `CapsuleCollider`/`BoxCollider` + `Rigidbody` (`useGravity=false` → planar/`PhysicsGravityFactor`=0; `isKinematic=false`; `interpolation=Interpolate` → `PhysicsGraphicalSmoothing`). Static colliders = collider, no Rigidbody, baked into the subscene (present identically in server + client worlds, deterministic, no replication). +- **`PhysicsVelocity` auto-replicates** — Netcode ships `PhysicsVelocityDefaultVariant` + a generated serializer, so a predicted-physics ghost needs **no hand-written `[GhostField]`** for velocity (`LocalTransform` is already replicated). Drive the character by writing `PhysicsVelocity.Linear`, not by teleporting `LocalTransform`. +- **`Rigidbody.FreezeRotation` is NOT honored by the DOTS baker** (baked `PhysicsMass.InverseInertia` stays non-zero). Hold a top-down character's facing by zeroing angular velocity each tick + writing rotation directly (`PlayerAimSystem`); set `PhysicsMass.InverseInertia = float3.zero` in a baker/system if a hard lock is needed. +- **Gravity-off bodies accumulate vertical contact impulses permanently** (a capsule rides up a box edge and floats away — looks like tunnelling, isn't). Pin players to the movement plane *after* the physics step: a system in `PredictedSimulationSystemGroup` `[UpdateAfter(PredictedFixedStepSimulationSystemGroup)]` clamping Y to `PlayerSpawner.SpawnPoint.y` + zeroing `Linear.y` (`PlayerPlanarConstraintSystem`). +- **The predicted physics group is OrderFirst**, so a system in `PredictedSimulationSystemGroup` with `[UpdateBefore(PredictedFixedStepSimulationSystemGroup)]` is **ignored** (OrderFirst/OrderLast wins) → 1-tick velocity offset (consistent across server/client/rollback — prediction stays in sync). For same-tick application, put the system *inside* `PredictedFixedStepSimulationSystemGroup` `[UpdateBefore(Unity.Physics.Systems.PhysicsSystemGroup)]` (verified to sort before the step) — but expect cosmetic "invalid UpdateBefore" warnings from the relocation. + +> **Note (M5b):** `PlayerMoveSystem` + `PlayerPlanarConstraintSystem` from this milestone were **deleted** when the player became a Unity Character Controller. The predicted-physics infra (`NetCodePhysicsConfig`, baked static walls) is **kept**. See the M5b section. + +## Build gotchas (learned — M5b Unity Character Controller, 2026-06-01) + +- **The player is now a Unity Character Controller kinematic character, NOT a dynamic Rigidbody.** `PlayerMoveSystem` + `PlayerPlanarConstraintSystem` (M5) are **deleted**. Movement: `PlayerControlSystem` maps `PlayerInput.Move` × `EffectiveCharacterStats.MoveSpeed` → `CharacterControl` (via the unit-tested `CharacterControlMath.DesiredMovement`); `CharacterProcessor` (collide-and-slide) consumes it in `CharacterPhysicsUpdateSystem` (`[UpdateInGroup(KinematicCharacterPhysicsUpdateGroup)]`, relocated into the predicted loop). The DR-006 predicted-physics infra (`NetCodePhysicsConfig`, baked static walls) is **kept** — the CC character sweeps against that same PhysicsWorld. +- **A package declaring an older `com.unity.entities`/`com.unity.physics` dependency can still resolve on our renumbered stack** — Unity treats the dep as a SemVer **floor**, so Entities 6.4.0 satisfies a `1.3.15` requirement and is NOT downgraded. Don't trust a version-string mismatch as "incompatible": **probe** (add the package, confirm `packages-lock.json` kept Entities 6.4.0 / Physics 1.4.6 / Netcode 1.13.2 + a clean compile; rollback if not). CC 1.4.2 verified this way. +- **CC 1.4.2 API shape = `IKinematicCharacterProcessor` + `KinematicCharacterDataAccess` + static `KinematicCharacterUtilities.Update_*`.** The legacy `KinematicCharacterAspect` (IAspect, instance `Update_*`) also exists but is NOT what the 1.4.x samples use — verify the installed shape with `unity_reflect`, don't assume. (A sub-agent's package-cache read disagreed with reflect; reflect + first-try clean compile won.) +- **`KinematicCharacterUtilities.BakeCharacter` aborts** (logs an error, adds nothing) **if the GameObject has a `Rigidbody`** and requires uniform (1,1,1) scale. The player prefab keeps its `CapsuleCollider` (baked into `PhysicsCollider`) but the M5 `Rigidbody` was removed. Two bakers on one prefab GameObject (`PlayerAuthoring` + `PlayerCharacterAuthoring`) is fine — both resolve the same entity. +- **`CharacterInterpolation` must be PredictedClient-only.** `BakeCharacter` adds it to all prefab versions; a `DefaultVariantSystemBase` registers `CharacterInterpolation → [GhostComponent(PrefabType = GhostPrefabType.PredictedClient)]` so it's stripped from server + interpolated-client prefabs (else double-interp on remotes). Verified: server ghost has no `CharacterInterpolation`, client ghost does. +- **Do NOT copy the CC sample's global `LocalTransform → DontSerializeVariant`.** It is project-wide and would break the non-character ghosts here (projectiles/dummies/pickups) that rely on stock `LocalTransform` replication. Our CC character replicates position via the normal owner-predicted `LocalTransform` path; only the `CharacterInterpolation` variant is registered. +- **Top-down CC config (planar, no gravity):** `AuthoringKinematicCharacterProperties` with `SnapToGround=false`, `InterpolateRotation=false` (rotation owned by `PlayerAimSystem`), `SimulateDynamicBody=false` (players don't physically push each other); gravity is handled in the processor by feeding `float3.zero` to `Update_GroundPushing` and never adding a gravity term. Result: stays on the spawn plane (y≈1) with no planar-pin system. + +## Build gotchas (learned — M5 home base + shared storage, 2026-06-02) + +- **Ownerless interpolated ghost ≠ owner-predicted for buffer replication.** A server-spawned **ownerless** ghost (e.g. the shared storage chest) replicates a `[GhostField]` `IBufferElementData` to all clients with **no `OwnerSendType` and no `GhostOwner`** — server mutations just propagate. `[GhostComponent(OwnerSendType = SendToOwnerType.All)]` (per `StatModifier`) is **only** for the predicting *owner* to recompute its own state; adding it (or a `GhostOwner`) to an ownerless ghost is wrong. +- **One-off shared-state actions belong on an `IRpcCommand`, not a predicted `InputEvent`.** RPCs are reliable, so deposit/withdraw landed even while the server tick-batched (the M2 one-shot `Fire` InputEvent drops under batching). RPC payloads are **plain blittable fields — no `[GhostField]`**; store an op as a **byte**, not an enum (cross-assembly enum-Burst hazard, same one that de-Bursted `ProjectileClassificationSystem`). For a **single** shared target, resolve it as a **server singleton** — never put an `Entity` (not stable cross-world) in the command; only reach for a ghost-id+spawn-tick (`SpawnedGhostEntityMap`) when there are many targets (and that lookup may force the handler off Burst). +- **Apply server-only RPC effects in the server `SimulationSystemGroup`, NOT the predicted loop** — the predicted loop re-runs on rollback and would double-apply. (Mutating a `DynamicBuffer` is not a structural change, so it's safe to do while iterating a *different* entity query, e.g. the RPC requests.) +- **Build-grid math must be deterministic + integer-stable.** Corner-origin + center-returning + **half-open** cell bounds + `math.floor` (not truncation — negatives). Lock `CellSize`/`PlotSize` as a coordinate space once: M6 placement builds on it; changing them later invalidates placed structures. (`BaseGridMath`, unit-tested in EditMode like `PlayerSpawnMath`.) +- **Runtime-spawn shared ghosts; don't bake them into the subscene.** A one-shot server spawner (mirrors `UpgradePickup`/`TrainingDummy`) keeps the subscene ghost-free and dodges the prespawn section-ack/CRC handshake. Do **not** add such a ghost to a connection's `LinkedEntityGroup` if it must survive that player's disconnect (the shared base is world-owned). +- **Build a correctly-configured ghost prefab by duplicating an existing one** (`UpgradePickup.prefab` → `Storage.prefab`, then swap the authoring MonoBehaviour via `manage_prefabs modify_contents`) rather than hand-adding `GhostAuthoringComponent` — its ownerless/interpolated settings (`HasOwner=0`, `DefaultGhostMode=Interpolated`) + `LinkedEntityGroupAuthoring` come along for free. +- **`execute_code` runs as a method body** — **no `using` directives** (they parse as statements → "Identifier expected"); fully-qualify every type (`Unity.Entities.World`, `ProjectM.Simulation.BaseAnchor`, …). Also: world flags overlap a shared `Game` bit, so identify worlds by `world.Name == "ServerWorld"/"ClientWorld"` rather than `(Flags & GameServer)`. +- **An unfocused editor throttles Edit mode to near-idle** → MCP pings time out and the bridge looks hung (it still *queues* commands — `telemetry_ping` succeeds). `Application.runInBackground` only helps in **Play** mode. If it wedges, focus or restart the editor; don't pile `refresh_unity` calls onto a blocked main thread. Prefer `refresh_unity scope=scripts` for code-only changes (`scope=all force` is heavy and contributed to a mid-session hang). + +## Build gotchas (learned — M5.5 game feel & identity, 2026-06-02) + +- **Move an ownerless INTERPOLATED enemy ghost SERVER-ONLY in the plain `SimulationSystemGroup`, never in `PredictedSimulationSystemGroup`** (interpolated ghosts aren't predicted; the server has no rollback). Use `[UpdateAfter(PredictedSimulationSystemGroup)]`, **NOT `[UpdateBefore]`** — the predicted group is **OrderFirst** in `SimulationSystemGroup`, so `UpdateBefore`/`After` *it* is silently *ignored* (Unity logs "Ignoring invalid UpdateBefore… OrderFirst/OrderLast has higher precedence"). A plain-`SimulationSystemGroup` server system therefore always runs **after** the predicted group, so a contact `DamageEvent` it appends drains the **following** tick (~16ms, fine for melee). Stock `LocalTransform` replication carries position — **no hand-written `[GhostField]`**. Build the enemy ghost by **duplicating an existing interpolated ghost** (`UpgradePickup.prefab` → `Enemy.prefab`) so the ownerless/interpolated `GhostAuthoringComponent` comes free — the training dummy is **not** a ghost (server-only → invisible to clients). See [[DR-009_GameFeel_Identity_FirstBlood]]. +- **Derive an enableable gate from already-replicated state instead of replicating it.** Player `Dead` = a LOCAL enableable derived every predicted tick from the replicated `Health<=0` (`PlayerDeathStateSystem`, runs in **both** worlds, before movement/aim/fire) — the same derive-don't-replicate idiom as `StatRecomputeSystem`/`EffectiveCharacterStats`; rollback-correct on server + owner-client with **no `[GhostEnabledBit]`**. To write the bit on a currently-disabled entity the query must visit it: `.WithPresent()` (write) vs `.WithDisabled()` (alive-only run); `.WithAll()` ANDs independently. **Bake the enableable DISABLED** (`AddComponent(e); SetComponentEnabled(e, false);` in the baker) so instances spawn in the off state (instantiated entities inherit the prefab's enabled state). Respawn TIMING is server-only (`SimulationSystemGroup`, after the predicted group). +- **All juice/HUD = client-only managed `SystemBase` in `PresentationSystemGroup`** (once per frame, no rollback double-fire) that OBSERVES replicated state — never mutates the sim. Read ECS via `SystemAPI.Query` inside `OnUpdate` + `EntityManager.CompleteDependencyBeforeRO()` — NOT from a MonoBehaviour `LateUpdate` (that throws the job-safety exception the camera rig hit). `Entity` is a stable client dict key for a ghost's lifetime — **prune the cache each frame** (a pruned enemy = a kill → death VFX at its last pos; **never `DestroyEntity` a ghost from the client** — `GhostDespawnSystem` owns despawn). Netcode-safe "hit-stop" = a camera punch, **never `Time.timeScale`** (it would corrupt the deterministic sim). +- **Asset-free presentation:** procedural `AudioClip.Create` SFX; a runtime `ParticleSystem` pool (Sprites/Default material + HDR start color so bursts bloom); a code-built uGUI HUD (`RawImage` over `Texture2D.whiteTexture` for anchor-driven bars + legacy `Text` with `Resources.GetBuiltinResource("LegacyRuntime.ttf")`). To edit a prefab asset's component in code: `PrefabUtility.LoadPrefabContents` → modify → **`SaveAsPrefabAsset(root, path)`** → `UnloadPrefabContents` (`SavePrefabAsset` rejects the contents root — "Can't save a Prefab instance"). Watch **shared-material bleed** when re-tinting (`M_Dummy` doubled as the wall material → orange walls; Husks got their own `M_Husk`). **ACES tonemapping needs the URP asset color grading mode = HDR** (`m_ColorGradingMode = 1`). +- **The "0 = ready" raw-`uint` cooldown sentinel can collide at tick wraparound** — a computed `ServerTick + delay` can equal 0. Route every cooldown/spawn "next tick" write through **`TickUtil.NonZero(...)`** (coerce 0→1), and compare stored ticks with `new NetworkTick(raw).IsNewerThan(serverTick)` / `.TicksSince(...)` — **never** raw `<` / subtraction — the HUD cooldown bar included. (Caught by the adversarial review; `RespawnMath` already guarded it.) +- **An unfocused editor stalls EditMode test INIT** ("tests did not start within timeout") and slows play-enter domain reloads — pass `run_tests(init_timeout=120000)` and retry; ask the operator to focus Unity for heavy build/test sessions (`Application.runInBackground` only helps in Play mode). + +## Build gotchas (learned — art import / Synty packs → URP, 2026-06-03) + +The imported store art (BefourStudios; future Synty) is **HDRP-authored** but we run **URP 17.4 + Entities Graphics** → source `M_*` materials render **magenta**. The reusable converter is `Assets/_Project/Scripts/Editor/EnvArtTools.cs` (menu `ProjectM/Art/1. Convert Curated Env Materials`); it outputs stock URP/Lit to `Assets/_Project/Materials/Env/`. See [[DR-010_Art_Import_URP_Conversion_Visual_Upgrade]]. + +- **Convert, don't switch pipelines.** Re-author to **stock URP/Lit** (`933532a4…`, the same shader our prototypes use → DOTS-instancing/Entities-Graphics compatible). FBX meshes + `T_*_B/_N/_ORM` textures are reusable as-is; the auto-generated `MI_*` URP stubs are blank. Switching the project to HDRP is NOT an option (breaks Entities Graphics). +- **A dark-lit screenshot MASKS material bugs — verify material *values*, not just the render.** `S_General` exposes a **float** `_BaseColorMultiply` and the real Color in `_AlbedoTint`. `HasProperty("_BaseColorMultiply")` is true but `GetColor()` on a float returns **(0,0,0)** (and logs a "doesn't have a color property" warning) → black albedo everywhere. **Always `shader.GetPropertyType(idx)`-guard before `GetColor`/`GetFloat`/`GetTexture`.** +- **Gate source emission on the `_Emissive` (0/1) flag AND a fixture name** — `S_General` carries a non-zero default `_EmissiveColor` even when off; reading it unconditionally makes crates/walls/domes glow (flat color can't reproduce the source emission mask). +- **`VolumeProfile.Add()` does NOT persist the override** — on save the `components` list serializes `{fileID:0}` nulls (works in-session, gone after reload). Use `AssetDatabase.AddObjectToAsset(component, profile)` per effect, then `SaveAssets`; verify non-null refs on disk. +- **`LocalTransform.FromPosition()` resets Scale=1**, silently discarding a ghost prefab's authored scale (Scale is a replicated `[GhostField]` → consistent-but-wrong, not a desync). Server spawners must read the prefab's baked `LocalTransform` and override only Position (fixed in `UpgradePickupSpawnSystem`/`SharedStorageSpawnSystem`). +- **High metallic + no reflection probe + dark skybox = near-black.** Keep converted env metallic low (0.1–0.2); rely on albedo + direct light. +- **Static decor goes in the gameplay subscene** (Entities Graphics renders only baked/EG-spawned entities; a SampleScene MeshRenderer renders via classic URP). **Strip colliders** from cosmetic props (else they bake into the static PhysicsWorld the CC sweeps) and put **no GhostAuthoring** on scenery. Edit the subscene via `manage_scene load … additive` → place → `SaveScene` → `close_scene` (re-bakes on Play); the baked-entity view disappears while it's open additively — verify placement via `execute_code` over the scene roots, not the game view. +- **HUD-free beauty shot** = a **positioned `game_view` capture** (`view_position`/`view_rotation`) — direct camera rendering excludes Screen-Space-Overlay UI. `scene_view` rejects positioned capture. +- **VFX (GabrielAguiar) is now imported** (499 prefabs, ~94% Shuriken; 27 VFX-Graph). Wired into combat via [[DR-011_Synty_World_VFX_Integration]] — see the VFX gotchas below. VFX-Graph (hits/beams) packs still need separate URP setup if wanted. + +## Build gotchas (learned — Synty world + GabrielAguiar VFX, 2026-06-03) + +The world is now a cohesive **Synty** sci-fi colony + GabrielAguiar combat VFX. See [[DR-011_Synty_World_VFX_Integration]] / [[2026-06-03_Synty_World_And_VFX]]. + +- **Synty is URP-native — NO conversion** (unlike the HDRP BefourStudios art). The grounded world is built as **cosmetic classic-URP GameObjects in SampleScene** (`SyntyWorld` root), NOT the DOTS subscene — the custom `Synty/Generic_Basic` shader just renders, and you never have to verify its Entities-Graphics DOTS-instancing. Only the gameplay subscene needs Entities Graphics + the baked PhysicsWorld. +- **Cosmetic SampleScene colliders are inert to gameplay** — classic PhysX is separate from the DOTS PhysicsWorld (baked only from the subscene); the planar-pinned CC never sees them. So a cosmetic world needs no collider stripping for *gameplay*. +- **"Grounded" = surround + horizon, not a bigger plane** — a skyline ring of tall buildings + a planet/asteroid backdrop + a `Skybox/6 Sided` space skybox + light fog killed the floating-plane far better than extending the ground. +- **Swap a subscene object's VISUAL while keeping collision:** disable its MeshRenderer but keep the BoxCollider — the collider still bakes to the static `PhysicsCollider`, and a disabled renderer bakes no RenderMesh (invisible wall, collision intact). Used to retire the BefourStudios walls under the Synty world. +- **A GA "projectile" prefab is NOT a passive trail** — it ships a non-kinematic `Rigidbody` + collider + `ProjectileMoveScript` (self-propels, collides, spawns secondary muzzle/hit VFX). Any authored VFX dropped into a cosmetic slot must be **stripped to particles**: destroy `Rigidbody`/`Collider` and disable `Projectile`/`Move`-named MonoBehaviours BEFORE their `Start` runs (`CombatFeedbackSystem.StripCosmetic`). Verify a prefab's *components*, not its name. +- **VFX prefab → client SystemBase bridge:** a MonoBehaviour with a static `Instance` + prefab fields in the bootstrap scene (`VFXConfig`, mirrors `PrototypeCameraRig`) hands authored assets to the managed `CombatFeedbackSystem`; keep a procedural fallback so a null slot still runs. Derive VFX TTL from the prefab's longest ParticleSystem (not a blanket constant) and cap concurrent VFX to bound GC churn under swarms. +- **Projectile-follow VFX:** query `SystemAPI.Query>().WithAll()` each presentation frame; dict-by-`Entity` spawn/reposition/prune (same idiom as the Health FX cache). The one-shot Fire `InputEvent` still drops under the **unfocused** editor, so fire-driven VFX (muzzle/trail/enemy-death) need a focused editor / real client to fire. + +## Build gotchas (learned — aim controls: mouse cursor + gamepad, 2026-06-03) + +The KBM/gamepad aim rework is [[DR-012_Aim_Controls_Cursor_Gamepad]] / [[2026-06-03_Aim_Controls_Cursor_Gamepad]]. + +- **Client-derived aim rides the EXISTING `PlayerInput.Aim` `[GhostField]` — no new netcode surface.** Mouse-cursor aim is computed client-side in `PlayerInputGatherSystem` (managed `SystemBase`, `GhostInputSystemGroup`, once/frame): `Mouse.current.position` → `Camera.main.ScreenPointToRay` → `AimMath.PlanarAimFromRay` (pure, Burst-safe, unit-tested) against the player's movement plane → write the player→cursor direction as `Aim`. Only the resulting direction crosses the wire; predicted/server systems are unchanged. Movement (`Move`) is already decoupled from facing (`Aim`/`PlayerFacing`), so **strafe-while-aiming is free** the moment `Aim` is the cursor. Don't add a mouse binding to the `Aim` action — the gather reads the device directly (no `.inputactions` edit → no wrapper regen). +- **Active input scheme = last-meaningful-actuation-wins, replicated as a `byte` (NOT an enum).** Detect KBM vs gamepad each frame (stick/trigger/button past a 0.04 lengthsq deadzone vs mouse-delta/click/movement-key; `InputDevice.lastUpdateTime` breaks ties; hold last when idle) and stream `PlayerInput.Scheme` (`InputSchemeId.KeyboardMouse=0/Gamepad=1`). It is a **byte** because it is compared inside the Burst-compiled `AbilityFireSystem` (the cross-assembly enum-in-Burst ICE hazard). The server gates the `AutoTarget` cone to `applied.InternalInput.Scheme == Gamepad` (read at the fire tick from the same `GetDataAtTick` lookup) → **precise mouse, gamepad-only assist**; mouse then predicts == server (fewer reconcile snaps). +- **A static presentation bridge must reset on play-enter.** `AimPresentation.Scheme` (mirrors `PrototypeCameraRig`/`VFXConfig` statics) needs `[UnityEngine.RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]` to reset — statics survive **fast-enter-playmode** domain reloads, and a stale value flashes the wrong cursor/reticle for the first frames (caught by the adversarial review). +- **Cursor/reticle = client `PresentationSystemGroup` `SystemBase` (`AimReticleSystem`) that OBSERVES, never mutates.** A flat world-space ground ring (primitive quad, `Sprites/Default` with a null-guard fallback, procedural ring texture) is the aim indicator for BOTH schemes — KBM at the cursor's ground-projection point, gamepad a fixed distance ahead along replicated `PlayerFacing`. The hardware cursor is **hidden while aiming + focused** (`Application.isFocused`-gated) and restored on focus-loss / `OnDestroy`. A radial **dead-zone** (`AimMath.PlanarAimFromRay` `deadZoneRadius`) holds facing when the cursor is over the character. **The KBM ground point is re-raycast INSIDE `AimReticleSystem`** (PresentationSystemGroup runs after the follow-cam's LateUpdate), not latched from the gather (`GhostInputSystemGroup`, before the move) — latching there drifts the ring a frame behind the cursor under the moving camera. Optional camera **aim look-ahead** (`PrototypeCameraRig.AimLeadDistance`, tunable) leads the framed point toward `PlayerFacing` (not the live cursor projection, to avoid a feedback loop). Headless validation: drive `DebugInputInjectionSystem` (now stamps `Scheme`) + force `AimPresentation.Scheme`; the **real cursor / live device-switch needs a focused Game view** (the unfocused editor can't inject mouse position). + +## Build gotchas (learned — M6 Aether Cycle core loop, 2026-06-03) + +The M6 core-loop slice (Expedition→Defend→Build) + the base/expedition world split. See [[DR-013_M6_Aether_Cycle_Region_Split]] / [[2026-06-03_M6_Aether_Cycle_CoreLoop]]. **Stages 0–1 done; 2–4 are the continuation.** + +- **Base/expedition split = coordinate-region + per-connection `GhostRelevancy`, NOT `SceneSystem` streaming** (supersedes DR-008's framing). One server world; the expedition lives at `base + (1000,0,0)`; a server `RegionRelevancySystem` in `GhostSimulationSystemGroup` (before `GhostSendSystem`) sets `GhostRelevancyMode.SetIsIrrelevant` and each tick marks region-tagged ghosts irrelevant to connections whose player is in a different region. **Use `SetIsIrrelevant` (not `SetIsRelevant`)** so untagged/global ghosts (the future cycle director) stay relevant to everyone for free — you only enumerate cross-region ghosts to hide. Verify the API on the installed Netcode (1.13.2) with `unity_reflect`: `GhostRelevancy` singleton has `GhostRelevancyMode` + `NativeParallelHashMap GhostRelevancySet`; `RelevantGhostForConnection{ int Connection; int Ghost }` (`Connection`=`NetworkId.Value`, `Ghost`=`GhostInstance.ghostId`). `RegionTag{byte Region}` is **server-only (NOT a `[GhostField]`)** — the server makes all relevancy decisions; the client just gains/loses ghosts. Reuses the runtime-ghost spawn path verbatim (no baked ghosts → no prespawn handshake), no async-load race; co-op drop-in is free. +- **Region transit + cycle phase use the established byte-RPC + tick-safe + server-`SimulationSystemGroup` patterns.** `RegionTransitRequest{byte TargetRegion}` (resolve sender via `ReceiveRpcCommandRequest.SourceConnection`→`NetworkId`→`GhostOwner`, flip `RegionTag`, teleport `LocalTransform`). The macro loop is a server-only `CycleState` singleton ([GhostField]-pre-annotated for the later CycleDirector ghost) driven by `CyclePhaseSystem` (`[UpdateBefore(WaveSystem)]`); it **gates `WaveSystem`** with a one-line `if (TryGetSingleton(out var c) && c.Phase != CyclePhase.Defend) return;`. All phase timers are wrap-safe `NetworkTick` (`TickUtil.NonZero` + `IsNewerThan`), never raw `uint <`. +- **Editing an existing `[BurstCompile]` ISystem's SystemAPI query set on an UNFOCUSED editor can leave a STALE Burst binary** (managed assembly recompiles with shifted source-gen query indices, Burst's async recompile doesn't finish) → runtime `InvalidOperationException: "… required component type was not declared in the EntityQuery"` thrown from an *unrelated* `GetSingleton` in that system. **Tell:** the Burst stack reports the *old* line number for the failing call. Same family as the M2 Burst-cache gotcha. **Workaround:** `Jobs ▸ Burst ▸ Enable Compilation` OFF for the session (verify `BurstCompiler.Options.EnableBurstCompilation==false`) — everything runs the fresh managed source-gen. **Permanent fix = restart Unity** to clear the cache, then re-enable Burst. Prefer a **focused** editor for Burst-affecting edits. +- **Shared GLOBAL game-state (cycle phase, resource ledger, goal meter) rides an UNTAGGED ghost, never a region-tagged one** — `SetIsIrrelevant` hides a region-tagged ghost (e.g. the base storage) from players in the *other* region. The M6 resource **ledger** is a `StorageEntry` buffer on the global `CycleDirector` ghost, resolved via a distinct `ResourceLedger` tag — **never `GetSingleton`** (the base storage container owns a second `StorageEntry` buffer → "multiple instances" throw). Runtime-proven: the director stays relevant to an expedition player while the base storage despawns. +- **A hit/area sweep that runs in the PLAIN `SimulationSystemGroup` must NOT use `SystemAPI.Time.DeltaTime`** — that group sees the variable *wall-frame* delta, not the fixed tick step, so a `cur - dir*Speed*dt` segment is wrong. Store the per-tick step on the projectile (`Projectile.LastStep`, written by `ProjectileMoveSystem` in the fixed-step predicted group) and reconstruct the swept segment as `cur - dir*LastStep` — tunnel-safe with zero dependence on the consuming system's clock. `ResourceHarvestSystem` runs `[UpdateAfter(PredictedSimulationSystemGroup)]` so it only sees projectiles that survived `ProjectileDamageSystem` (relies on the ~1000u base/expedition coordinate gap so a base shot can't reach a node). A node hit by N projectiles in one tick: deposit per hit but `ecb.DestroyEntity` **at-most-once** (destroyed-bitset + local Remaining copy — a double destroy throws at Playback); persist the decremented `[GhostField] Remaining` via `SetComponent` so depletion carries across ticks. +- **New ghost prefab recipe (proven M6):** `manage_asset duplicate` UpgradePickup.prefab → `manage_prefabs modify_contents` (swap the authoring MonoBehaviour; **strip MeshFilter+MeshRenderer for an invisible state-holder**, keep them for a visible node). Wire the baked spawner into the gameplay subscene: `manage_scene load additive` → `set_active_scene Gameplay` → `manage_gameobject create` (+ `manage_components set_property` for the prefab ref, verify via `mcpforunity://scene/gameobject/{id}/component/...`) → `save` → `set_active_scene SampleScene` → `close_scene` (re-bakes on Play). +- **Run an adversarial design-review Workflow (3 critics: netcode/relevancy, determinism/prediction, reuse/scope → synthesize) BEFORE coding a netcode-heavy slice** — for M6 Stage 2 it caught every one of the above pre-implementation (relevancy trap, singleton collision, dt-trap, double-destroy, lazy-create hazard). +- **`manage_gameobject create` `component_properties` SILENTLY DROPS enum + Vector3 fields** (it set object-refs and simple scalars, but baked authoring enums/`Vector3` stayed at their C# defaults — two gates baked identical, one worked only by coincidence). **Always set those via a follow-up `manage_components set_property` (with a `properties` dict) and VERIFY through the `mcpforunity://scene/gameobject/{id}/component/{Type}` resource** (or, for a ghost, by reading the baked component in `execute_code` after Play). Same caveat applies to `manage_prefabs modify_contents` `component_properties`. Per-renderer color via `manage_material set_renderer_color` defaults to a runtime **PropertyBlock that does NOT persist into Play** — create a material asset (`manage_material create`) and `assign_material_to_renderer`, or use a prefab-stage assign, for colors that survive a domain reload. +- **Walk-in region gates (M6 visibility pass):** a baked `ExpeditionGate{FromRegion,ToRegion,Radius,ArrivalPos}` entity (visible primitive, collider stripped so you pass through) + a server `ExpeditionGateSystem` (plain group, `[UpdateAfter(CyclePhaseSystem)]`) proximity-transits a player whose `RegionTag` matches `FromRegion` (flip RegionTag + teleport to `ArrivalPos`, offset from the destination gate so no re-trigger). Returning to base mid-Expedition expires the cycle timer → Defend ("timer cap + early return"). The expedition is a *place* = cosmetic ground/pillars in **SampleScene** at the +1000 offset (classic URP, like SyntyWorld), not the DOTS subscene; gameplay nodes/gates are the baked subscene entities. +- **Build/automation foundation (M6 Stage 3, the M7 contract):** generic `PlacedStructure{[GhostField] byte Type; int2 Cell (server-only); uint NextTick; uint LastProcessedTick}` on an ownerless interpolated ghost (`RegionTag{Base}`, world-owned, runtime-spawned). **Bake the two tick fields NOW** — the turret reuses `NextTick` as its fire cooldown, and they are the deterministic-offline-catch-up linchpin M7 needs and that can't be reconstructed retroactively. Only `Type` replicates (client derives `Cell` from `LocalTransform` via `BaseGridMath.WorldToCell`). Data-driven `StructureCatalog` buffer (`{byte Type; Entity Prefab; byte CostResourceId; int CostAmount}`, modeled on `AbilityPrefabElement`); M7 adds a recipe column additively. **Occupancy is DERIVED** by scanning live structure ghosts into a Temp `NativeHashSet` (structures are the source of truth — restart/replay-safe), NEVER a mutable buffer on the immutable baked `BaseAnchor`. +- **Co-op placement atomicity:** `BuildPlaceSystem` commits the `StorageMath.Withdraw` + cell-reservation **IN-PLACE inside the RPC foreach** (only the `Instantiate` goes through the ECB) — the `StorageOpReceiveSystem` idiom — so two same-tick `BuildPlaceRequest`s for one cell can't both pass (validated: → exactly one structure + one withdraw). RPC carries `int CellX/CellZ` scalars, not `int2` (scalar-only RPC precedent). +- **Buildable turret = hitscan = reversed `EnemyAISystem`:** snapshot living Husks, nearest-in-same-region-within-Range, on the `NextTick` cooldown append a direct `DamageEvent{Damage, SourceNetworkId=-1}` → reuses `HealthApplyDamageSystem` (despawns at HP≤0). NO projectile → no tunnelling, no friendly-fire/team model. Plain server group `[UpdateAfter(PredictedSimulationSystemGroup)]`. +- **Resource-gated ability tiers reuse `StatModifier` — no new replicated component.** `AbilityUpgradeSystem` spends Aether and grows ONE `StatModifier{Target=Damage, Op=PercentAdd, SourceId=}` on the player (**replace-by-SourceId** so the `[InternalBufferCapacity(8)]` buffer stays bounded — repeated upgrades grow one row, not append); `StatRecomputeSystem` folds it into `EffectiveAbilityStats.Damage` on both worlds (the `UpgradePickup` path). `GoalProgress{[GhostField] int Charge, Target}` lives on the global CycleDirector ghost, single-writer in `CyclePhaseSystem`. **Disk-persistence writer is deferred to post-M7** (in-session-only state, per DR-008); freeze the save schema + bake the structure tick fields now so it's additive. See [[DR-014_M6_Build_Structures_Automation_Foundation]].