Docs: align CLAUDE.md + vault to scene-split / Automation / Saves / UITK
CLAUDE.md: rewrite Bootstrap&worlds (scene split + on-demand frontend), add M7 Automation + disk-persistence + UITK HUD bullets, new build-gotchas, note new folders fit existing asmdefs. Vault: Milestones (M6 + polish pass -> Done; HUD + cleanup rows), Backlog (ConnectionUI done + cleanup notes), Home/Systems_Index dates. Add the 3 prior session logs + DR-019/020/021 + the 2026-06-06 cleanup log + screenshots. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,7 @@ Root namespace: **`ProjectM`**. Code lives under `Assets/_Project/Scripts/` in f
|
||||
|
||||
- **Simulation** = components + systems shared by both worlds (most gameplay). **Client/Server** = world-specific. **Authoring** = `…Authoring` MonoBehaviours + `Baker<T>`.
|
||||
- Other folders: `Assets/_Project/Subscenes/` (baked entity subscenes), `Assets/_Project/Prefabs/`, `Assets/_Project/Tests/EditMode/`.
|
||||
- Feature folders added since (`Client/UI`, `Client/Settings`, `Server/Automation`, `Server/Persistence`, `Simulation/Automation`, `Simulation/Persistence`) live **inside the existing four asmdefs — no new assemblies**.
|
||||
|
||||
## Build gotchas (distilled)
|
||||
|
||||
@@ -65,6 +66,7 @@ Long-form originals + the milestone each came from: `Docs/Vault/_Meta/CLAUDE_Bui
|
||||
- **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<StorageEntry>`** when a second `StorageEntry` buffer exists elsewhere → "multiple instances" throw.
|
||||
- **Frontend world lifecycle (menu → on-demand worlds) ★:** `CreateLocalWorld` is `internal` in 1.13.2 — use the public `CreateClientWorld` / `CreateServerWorld` (they register the `ServerWorld` / `ClientWorld` statics the connection UI/overlay read); for the menu world use `DefaultWorldInitialization.Initialize(name, false)` (or `return false` from `GameBootstrap.Initialize`). **Never dispose/create worlds inside an ECS system** — run all create/dispose/scene-load on a frame-boundary coroutine (`SessionRunner`, `DontDestroyOnLoad`). The gameplay subscene streams into an on-demand world ONLY if a netcode world is the `DefaultGameObjectInjectionWorld` at `LoadScene` time (dispose the menu world first → set the default to the server world → `LoadScene(Game)`). See [[DR-019_Frontend_Menu_Settings_Saves_Build]].
|
||||
|
||||
### Physics & character controller
|
||||
- **Unity Physics 1.x bakes built-in `UnityEngine` colliders + `Rigidbody`** (the Physics-0.x `PhysicsShapeAuthoring`/`PhysicsBodyAuthoring` are gone). Static collider (no Rigidbody) → baked into the subscene PhysicsWorld, deterministic, no replication. `Rigidbody.FreezeRotation` is **NOT** honored by the baker — zero angular velocity + write rotation each tick, or set `PhysicsMass.InverseInertia = float3.zero`.
|
||||
@@ -78,17 +80,21 @@ Long-form originals + the milestone each came from: `Docs/Vault/_Meta/CLAUDE_Bui
|
||||
- **`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<int2>`, 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=<sentinel>}` (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.
|
||||
- **Resource-gated ability tiers reuse `StatModifier`** — grow ONE `StatModifier{Target=Damage, Op=PercentAdd, SourceId=<sentinel>}` (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 shipped** — see the Automation + persistence bullets below.
|
||||
- **M7 Automation (server-only, never predicted) ★:** `Harvester` / `Conveyor` / `Fabricator` are buildable machines on the same `PlacedStructure` ghost; each stores `PeriodTicks` + **server-only** `MachineInput` / `MachineOutput` buffers (NOT `[GhostField]`). Production runs in the plain server `SimulationSystemGroup` `[UpdateAfter(PredictedSimulationSystemGroup)]` (Harvester→Conveyor→Fabricator) and replicates only via the global ledger + `PlacedStructure`. Deterministic catch-up via `ProductionMath.CyclesDue` (**lower-bound 0, never 1** — a `1` premature-mints a restored `remaining==0` machine; period-0 guarded). Byte-only pure math (`ProductionMath` / `ConveyorMath.ResolveMoves` / `MachineSlotMath`) is EditMode-tested; `ConveyorMath` is order-independent (snapshot → stable-sort by `CellKey` → at-most-one destination claim → losers stall, no loss). `RuntimePlacedTag` marks player-built machines for the save-scan; `BuildPlaceSystem` stamps `LastProcessedTick=0` so runtime-placed machines hit `NeedsInit`. See [[DR-020_M7_Automation_Production_Chains]].
|
||||
- **Disk persistence (`SaveData`, single-slot atomic JSON at `persistentDataPath`) ★:** versioned, null on bad version, schema **additive** (bump the version, don't break it). **Born-correct load** — `CycleDirectorSpawnSystem` applies a staged `PendingSave` AT SPAWN so the director ghost never replicates a default first. Autosave on the Siege→Calm checkpoint + on quit-to-menu (`WorldLauncher.TrySaveFromServer`, host-only); `BaseRestoreSystem` replays saved structures **charge-free** with epoch-independent REMAINING-tick cooldowns + re-tags them. Shared `SaveStructureScan.Collect` (autosave + quit use ONE scan path). See [[DR-019_Frontend_Menu_Settings_Saves_Build]].
|
||||
|
||||
### 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<T>()` — 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`).
|
||||
- **Asset-free presentation:** procedural `AudioClip.Create` SFX; runtime `ParticleSystem` pool (Sprites/Default + HDR start color); code-built **UI Toolkit** HUD / menus (runtime `UIDocument` + shared `RuntimePanelSettings`; see the UITK bullet below). 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).
|
||||
- **UITK HUD + menus (in-game UI, on UI Toolkit) ★:** `MenuUi` owns the shared palette + element factories + `PanelSettings` / `EventSystem` plumbing and the **canonical `Round`/`Border` helpers**; `HudUi` is a thin extension (bars / labels). `HudSystem` is a `PresentationSystemGroup` observe-only `SystemBase` owning a runtime `UIDocument` (`sortingOrder 50`, behind the pause overlay's 100); it builds the tree on the first frame `rootVisualElement != null`, root `pickingMode = Ignore` so the HUD never eats world clicks (only palette buttons opt back in). **Runtime UITK needs a `PanelSettings` WITH a `themeStyleSheet`** (a `.tss` importing `unity-theme://default`) **AND** an `EventSystem` + `InputSystemUIInputModule` (Input System project) or buttons are silently dead. The **build palette** (lazy-built from the client `StructureCatalog`) drives click-to-place: ground-ghost preview (green/red via `BuildPreviewMath`, the client mirror of the server legality check), left-click places via the `BuildPlaceRequest` RPC, right-click/Esc cancels, `[`/`]`/R rotates a conveyor; `Fire` is suppressed while build mode is active. See [[DR-021_HUD_UITK_BuildPalette]].
|
||||
|
||||
### 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<T>()` does NOT persist** (serializes `{fileID:0}` on save) — use `AssetDatabase.AddObjectToAsset(component, profile)` + `SaveAssets`, verify on disk.
|
||||
- **A reverted engine/URP upgrade can stamp `UniversalRenderPipelineGlobalSettings.asset` with `m_AssetVersion` AHEAD of the package's `k_LastVersion`** (here 11 > 10, from the reverted 6.6 alpha). URP migrates **forward only**, so `URPPreprocessBuild` rejects the "from-the-future" asset (*"not at last version"*) — **blocks player builds but NOT editor Play** (lurks until the first build). Fix: reflection-set `m_AssetVersion` back to `k_LastVersion`, `SetDirty` + `SaveAssets` (data already renders under the current package, only the stamp is wrong).
|
||||
- **`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.
|
||||
@@ -99,7 +105,7 @@ Long-form originals + the milestone each came from: `Docs/Vault/_Meta/CLAUDE_Bui
|
||||
- **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`.
|
||||
|
||||
### 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.)
|
||||
- **Edit Assets `.cs` ONLY via MCP `apply_text_edits` / `create_script`** (Unity's scripting pipeline) — the raw `Write` tool does NOT reliably trigger a recompile on an unfocused editor → tests/`execute_code` run a **stale assembly**. A raw-`Write`-created NEW `.cs` is worse — it gets **no `.meta` / no test-discovery** until `refresh_unity scope=all mode=force` (it compiles, but EditMode silently won't run it). (`Write`/`Edit` are fine for non-asset files: this vault, asmdef JSON, etc.) For comment/string-precise edits to existing scripts, `script_apply_edits` **`anchor_replace`** (regex anchor) + **`delete_method`** work cleanly even on a `struct : ISystem` (unlike `replace_method`).
|
||||
- **`apply_text_edits` with MULTIPLE non-adjacent edits in one call can MISALIGN** (a paired replace+delete hit the line *above* the target). One edit per call (or strict bottom-first), always with `precondition_sha256` (it returns the current SHA on mismatch). **`create_script` won't overwrite** an existing path; full-file rewrites = whole-span `apply_text_edits` (its brace-balance validator guards botched spans) or `manage_script delete`+`create_script` (NON-GUID-referenced files only — systems/tests, never authoring MonoBehaviours). `script_apply_edits replace_method` is safe for class methods but still **can't target a `struct : ISystem`** (use whole-span). [[DR-017_Persistent_Base_Player_Driven_Pacing]]
|
||||
- **`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.
|
||||
@@ -109,9 +115,9 @@ Long-form originals + the milestone each came from: `Docs/Vault/_Meta/CLAUDE_Bui
|
||||
|
||||
## Bootstrap & worlds
|
||||
|
||||
- `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]].
|
||||
- `ProjectM.Simulation.GameBootstrap : ClientServerBootstrap` overrides `Initialize` with `AutoConnectPort = 0` (M4 — listen/connect is explicit via the `ConnectionConfig` singleton + the per-world ConnectionControlSystems, no auto-connect). **Editor default = instant-into-game + MPPM** (creates `ServerWorld` (`WorldFlags.GameServer`) + `ClientWorld` (`WorldFlags.GameClient`)); the `ProjectM/Boot Into Menu (Editor)` EditorPref flips the MAIN editor to the frontend path. **Player builds boot the UITK frontend menu** (`return false` → one menu world, no netcode worlds until a menu choice). See [[DR-019_Frontend_Menu_Settings_Saves_Build]].
|
||||
- **Scenes:** `Assets/Scenes/MainMenu.unity` (build index 0) boots the UITK frontend (menu world only, no netcode); `Assets/Scenes/Game.unity` (index 1) holds gameplay with `Assets/_Project/Subscenes/Gameplay.unity` wired in as the baked subscene (GameObject `GameplaySubScene`). `SampleScene` / `DevSandbox` are kept as reference / dev scenes. The on-demand lifecycle (`WorldLauncher` / `SessionRunner` / `MainMenuController`) creates the right worlds per menu choice (Single / Host / Join), THEN `LoadScene(Game)` — the subscene streams into the on-demand world only if a netcode world is the `DefaultGameObjectInjectionWorld` when the scene loads.
|
||||
- **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 the Game scene at the +1000 offset; gameplay nodes/gates are baked subscene entities. See [[DR-013_M6_Aether_Cycle_Region_Split]].
|
||||
|
||||
## DOTS / ECS conventions (authoritative summary)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user