From bdeee3c51aa8f738dc3ccbfff93ded6eb97cf227 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 13 Aug 2026 22:39:30 -0700 Subject: [PATCH] =?UTF-8?q?Docs:=202026-08-13=20session=20=E2=80=94=20comb?= =?UTF-8?q?at=20freeze=20diagnosis=20+=20Track=20B=20allocation=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session log for the "freezes on a kill" diagnosis (the cause is the camera, not CPU: shake is integrated by the follow filter, the hold is frame-counted, and the hit + kill packages stack on the lethal frame) and for the Track B allocation work the operator selected. Gotchas archive gains nine dated entries, incl. two that changed how the work was ranked: GC.GetTotalMemory quantises to 4 KB and is useless for allocation attribution (use ProfilerRecorder), and an editor-only IMGUI dev tool self-spawns into Game.unity and dominated every editor-side measurement. CLAUDE.md: one new pooling/per-frame-cost line, paid for under the net-zero rule by dropping the DR-051 build-palette text (that code died with the purge), the two purge enumerations now carried by their DRs, and a duplicated ACES clause. 40183 bytes, under the 40960 budget. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 9 +- ...bat_Freeze_Diagnosis_Track_B_Allocation.md | 194 ++++++++++++++++++ .../_Meta/CLAUDE_Build_Gotchas_Archive.md | 63 ++++++ 3 files changed, 262 insertions(+), 4 deletions(-) create mode 100644 Docs/Vault/07_Sessions/2026/2026-08-13_Combat_Freeze_Diagnosis_Track_B_Allocation.md diff --git a/CLAUDE.md b/CLAUDE.md index a983699fb..ed3787833 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,15 +88,16 @@ Long-form originals + the milestone each came from: `Docs/Vault/_Meta/CLAUDE_Bui ### Build / structures / grid - **Grid math** (`BaseGridMath`, still live for spawn rings/respawn/lights): corner-origin, center-returning, **half-open** cell bounds, `math.floor`; lock cell size as a coordinate space once. Structures/placement themselves are deleted — recipe + atomicity rules in [[DR-014_M6_Build_Structures_Automation_Foundation]] if buildables return. - **Ledger spends:** afford→act else SOFT-FAIL (no cooldown-burn), read LIVE in-loop (no hoist); a Health-less entity silently drops OUT of an aggro snapshot (snapshot ABOVE the early-return). -- **DR-051 purge (07-15) ★:** siege/cycle/core/turret/automation + legacy `AbilityRef` path + onboarding **DELETED** (git = the archive). **Retired byte VALUES stay reserved, never renumbered** (`StructureType` 1-4, `ResourceId.Charge`, `DebugOp` 3/10/11, `TuningKnob` 20-23); `DebugOp.SpawnWave`/`EndSiege` RE-MEANT (force-wave / quiet-arena). **Waves UNGATED** — a baked `WaveDirectorAuthoring` decides by placement. Sockets are THE ability model (frame loadout seeded unconditionally at spawn) — **the Spark defs must be in EVERY gameplay subscene's `AbilityDatabaseAuthoring`; a socket whose SparkId is missing from the baked blob silently reads Damage/Range/Cooldown 0** (audit H2, live-proven). `FrameKind` = Bathynaut(2)/Harpooner(3); `PlayerClass` is gone (FrameId is the single frame identity). [[DR-051_Lantern_Realignment_Purge]] · **2026-08-07 audit purge deleted the whole base/expedition shell** (run FSM, meta shop, prep, boons, build/structures, storage, inventory/equipment, enemy variants + boss): [[DR-054_Audit_Purge_2026-08]]. +- **DR-051 purge (07-15) ★:** the dead direction was **DELETED** (git = the archive; enumerated in the DR). **Retired byte VALUES stay reserved, never renumbered** (`StructureType` 1-4, `ResourceId.Charge`, `DebugOp` 3/10/11, `TuningKnob` 20-23); `DebugOp.SpawnWave`/`EndSiege` RE-MEANT (force-wave / quiet-arena). **Waves UNGATED** — a baked `WaveDirectorAuthoring` decides by placement. Sockets are THE ability model (frame loadout seeded unconditionally at spawn) — **the Spark defs must be in EVERY gameplay subscene's `AbilityDatabaseAuthoring`; a socket whose SparkId is missing from the baked blob silently reads Damage/Range/Cooldown 0** (audit H2, live-proven). `FrameKind` = Bathynaut(2)/Harpooner(3); `PlayerClass` is gone (FrameId is the single frame identity). [[DR-051_Lantern_Realignment_Purge]] · **2026-08-07 audit purge deleted the whole base/expedition shell**: [[DR-054_Audit_Purge_2026-08]]. - **Harvest is single-sink** (→ the shared ledger, via `HarvestMath.DepositYield`). The personal-bag/equipment layer was deleted 2026-08-07; reintroduce LANTERN's carried-vs-banked split *inside HarvestMath*, not at its two call sites. - **Disk persistence (`SaveData`, single-slot atomic JSON, versioned) ★:** **born-correct load** — `CycleDirectorSpawnSystem` (now the ledger host only) applies the menu-staged `PendingSave` AT SPAWN. **v7 = a FRESH EPOCH: `MinLoadableVersion = CurrentVersion = 7`**; additive going forward — the save now carries only the ledger (structure/meta fields persist empty so v7 files still load). See [[DR-019_Frontend_Menu_Settings_Saves_Build]]. ### Presentation / juice / VFX - **All juice/HUD = client-only observe-only `SystemBase` in `PresentationSystemGroup`** (once/frame, no rollback double-fire), never mutates the sim. Read ECS via `SystemAPI.Query` + `EntityManager.CompleteDependencyBeforeRO()` — NOT MonoBehaviour `LateUpdate` (job-safety throw). `Entity` = a stable client dict key per ghost lifetime — **prune the cache each frame** (a pruned ghost = a kill/loss → death VFX); **never `DestroyEntity` a ghost client-side** (`GhostDespawnSystem` owns despawn). Hit-stop = camera punch, **never `Time.timeScale`**. -- **Asset-free presentation:** procedural `AudioClip.Create` SFX; runtime `ParticleSystem` pool; code-built **UI Toolkit**. Prefab-asset edits: `LoadPrefabContents`→modify→`SaveAsPrefabAsset`→`Unload`. Watch shared-material bleed on re-tint; ACES needs URP grading mode HDR. Detail → archive 07-16. +- **Asset-free presentation:** procedural `AudioClip.Create` SFX; runtime `ParticleSystem` pool; code-built **UI Toolkit**. Prefab-asset edits: `LoadPrefabContents`→modify→`SaveAsPrefabAsset`→`Unload`. Watch shared-material bleed on re-tint. Detail → archive 07-16. - **Prototype glue lives in `ProjectM.Client` as MonoBehaviours:** `PrototypeCameraRig` (player-following ARPG cam), `VFXConfig` (static `Instance` + prefab fields bridging authored VFX to `CombatFeedbackSystem`; keep a procedural fallback). A **static presentation bridge must reset on play-enter** via `[RuntimeInitializeOnLoadMethod(SubsystemRegistration)]` (statics survive fast-enter-playmode reloads → stale flash). -- **UITK HUD + menus ★:** `MenuUi` owns the palette/factories/`PanelSettings`/`EventSystem` plumbing; `HudSystem` = a `PresentationSystemGroup` observe-only `SystemBase` owning a runtime `UIDocument` (`sortingOrder 50`, root `pickingMode = Ignore`, tree built once `rootVisualElement != null`). **Runtime UITK needs `PanelSettings` WITH a `themeStyleSheet` AND an `EventSystem` + `InputSystemUIInputModule`** or buttons are silently dead. The build palette (lazy from the client `StructureCatalog`) drives click-to-place: green/red `BuildPreviewMath` ghost → `BuildPlaceRequest` RPC, right-click/Esc cancel, `[`/`]`/R rotate. See [[DR-021_HUD_UITK_BuildPalette]]. +- **UITK HUD + menus ★:** `MenuUi` owns the palette/factories/`PanelSettings`/`EventSystem` plumbing; `HudSystem` = a `PresentationSystemGroup` observe-only `SystemBase` owning a runtime `UIDocument` (`sortingOrder 50`, root `pickingMode = Ignore`, tree built once `rootVisualElement != null`). **Runtime UITK needs `PanelSettings` WITH a `themeStyleSheet` AND an `EventSystem` + `InputSystemUIInputModule`** or buttons are silently dead. See [[DR-021_HUD_UITK_BuildPalette]] (its build-palette half died with DR-051). +- **Pooling + per-frame cost ★:** a per-frame value-CHANGE gate must derive its rendered text FROM the quantised key (`Mathf.RoundToInt` = half-to-EVEN vs `ToString("0.0")` = half-away → the label latches stale). A pooled `AudioSource` ≠ `PlayClipAtPoint`: set `spatialBlend=1` (default is 2D), `dopplerLevel=0` (pooled voices teleport), root `DontDestroyOnLoad` + a `SubsystemRegistration` reset. `AudioClip.Create` clips aren't owned by the FX root — `FeedbackFx.DestroyClip`. **`GC.GetTotalMemory` quantises to 4 KB; attribute with `ProfilerRecorder("GC Allocated In Frame")`.** → archive 2026-08-13. - **HUD skin = build-safe `HudTheme` SO of serialized sprite refs** (runtime `Resources.Load` by name is build-stripped); tint MULTIPLIES, never set `unitySlice*` on 9-slices → archive 2026-07-06 + [[DR-024_HUD_Synty_Skin_Theme]]. ### Art import (HDRP store packs → URP) @@ -107,7 +108,7 @@ Long-form originals + the milestone each came from: `Docs/Vault/_Meta/CLAUDE_Bui - **VolumeProfile.Add persistence + the URP `m_AssetVersion` build blocker** → archive 2026-07-06 (+ native memory `urp-global-settings-version-blocks-build`). - **`LocalTransform.FromPosition()` resets Scale=1** — server spawners read the prefab's baked `LocalTransform`, override only Position (Scale is a `[GhostField]` → consistent-but-wrong). - **Static decor → gameplay subscene** (EG renders only baked entities); **strip colliders from cosmetic props** + no `GhostAuthoring` on scenery (classic-URP colliders are inert to the DOTS PhysicsWorld). **World collision = subscene-only ★:** `Environment`-layer boundary ring (`SM_Env_Rock_Cliff` rim) + landmark colliders, player blocked via the layer matrix; enemies slide via a server `CollisionWorld.SphereCast` in `EnemyAISystem`. **★ that slide has NO pathfinding — re-validate movers aren't frozen whenever you add Environment cover** (`EnemyMoveUtil.Depenetrate` + tangent-slide + a COVER-AWARE `EnemyNavState` nudge; full 07-07/07-10 history → archive). See [[2026-06-08_World_Collision_HUD_Scaling]]. -- **A GA "projectile" prefab self-propels** — strip to particles before `Start` (`CombatFeedbackSystem.StripCosmetic`); verify *components*, not the name. +- **A GA "projectile" prefab self-propels** — `CombatFeedbackSystem.StripCosmetic` strips every MonoBehaviour/Rigidbody/Collider at pool-fill, under an INACTIVE root so `Start` never runs. ### Aim / facing (SoD model — DR-052) ★ - **`PlayerFacing` is body-yaw ONLY** (moves→face movement; cast window→turn to aim; idle→hold; never passively track the cursor). **Every gameplay direction reads `FacingMath.ResolveAim(PlayerInput.Aim, facing)`** (all AbilityFireSystem archetypes + assist seed + melee cleave) and aim-readout presentation reads the SAME resolver; Movement-archetype sockets never open a cast window (`TickWindowMath`); PlayerAimSystem stays UN-gated (integrator over the snapshot-restored [GhostField]). Scheme byte KBM=0/Gamepad=1; KBM reticle re-raycasts. [[DR-052_SoD_Facing_Underwater_Feel]] + archive 2026-07-06. diff --git a/Docs/Vault/07_Sessions/2026/2026-08-13_Combat_Freeze_Diagnosis_Track_B_Allocation.md b/Docs/Vault/07_Sessions/2026/2026-08-13_Combat_Freeze_Diagnosis_Track_B_Allocation.md new file mode 100644 index 000000000..7a5ee6a12 --- /dev/null +++ b/Docs/Vault/07_Sessions/2026/2026-08-13_Combat_Freeze_Diagnosis_Track_B_Allocation.md @@ -0,0 +1,194 @@ +--- +date: 2026-08-13 +topic: Combat "freeze on kill" diagnosis + Track B (allocation / pooling) +status: shipped (code uncommitted at time of writing) +--- + +# 2026-08-13 — Combat freeze diagnosis + Track B allocation pass + +Operator report: *"on a kill the combat feels like it freezes up the game and overall doesn't feel smooth."* + +## Part 1 — Diagnosis (measured, not inferred) + +Instrumented Play with an `EditorApplication.update` guardian injecting **real lethal `DamageEvent`s** +server-side (the true death path — setting `Health.Current = 0` directly does NOT work, because +`HealthApplyDamageSystem` skips any entity whose `DamageEvent` buffer is empty and so never stamps `Dying`). + +**Ruled out by measurement:** + +| Hypothesis | Verdict | +|---|---| +| CPU hitch on kill | **No.** Median frame 12.2 ms, p99 20.1 ms; a kill costs ~15 ms. | +| Wave respawn stalls on the last kill | **No.** Enemies drip back ~1 per 29 frames, no spike. | +| Corpses block movement for the 0.9 s `Dying` window | **No.** Enemies carry **no `PhysicsCollider` at all**. | +| `Time.timeScale` abuse | **No.** The codebase is disciplined about this. | + +**What it actually is — the camera.** With a *stationary* player (so ideal camera motion is exactly zero, +making every measured value an artifact), one kill produces: + +``` +f51 shake=0.301 hold=2 fovKick=2.20 dev=0.111 <- kill lands +f52 step=0.283 hold=1 dev=0.269 <- 28 cm camera jump in ONE frame +f58 dev=0.447 <- peak, 45 cm off-frame +f75 dev=0.199 <- still 20 cm off, 24 frames later +``` + +Three root causes, all in `PrototypeCameraRig.LateUpdate`: + +1. **Shake is integrated by the follow filter.** `basePos = Vector3.Lerp(transform.position, desired, k)` reads + back a `transform.position` that already contains last frame's random shake, so the smoother treats shake as + real positional error and corrects only ~9 %/frame (`FollowSharpness 8` at 12 ms → k≈0.09). Shake is never + subtracted, only slowly lerped out while new shake is added. In sustained combat the camera random-walks + around its ideal framing and never settles. **This is the "doesn't feel smooth".** +2. **The hit package and the kill package both fire on the lethal frame** — measured `shake = 0.301` + (`HitShakeRemote 0.10` + `KillShake 0.20` stacking) and `fovKick = 2.20` (the hit punch winning over the + kill's 1.0). One kill fires 2 SFX + 4 particle bursts + 2 light flashes + a damage number + 2 FOV punches + + a shake + a hold + a rumble, simultaneously. +3. **`PrototypeCameraRig.Hold()` freezes the follow, counted in FRAMES.** `HitStopMaxFrames = 2` on every kill, + `FinisherHoldFrames = 7`. The comment claims "~117 ms" but that assumes 60 fps — it is 49 ms at 144 fps and + 233 ms at 30 fps. The hold branch also does `basePos = transform.position`, permanently baking in the shake + offset (that is the 0.283-unit single-frame step at f52). + +Plus a **one-off 41 ms spike on the first kill** (+44 KB) — first-use warmup of the death VFX/audio path; +later kills cost ~15 ms. + +A full remediation plan was presented (Track A camera, Track B allocation, Track C feel). **The operator chose +Track B.** Track A and C remain open — see *Next session*. + +## Part 2 — Track B shipped + +### Measurement instrument matters (a retracted result) + +The first attribution run used `System.GC.GetTotalMemory(false)` deltas per frame and produced a per-system +table that was **entirely noise** — that API quantises to 4 KB pages, so every per-system median came back as 0 +or exactly one page and the "savings" column was negative nonsense. **Discarded.** `ProfilerRecorder( +ProfilerCategory.Memory, "GC Allocated In Frame")` is the correct instrument and gives real bytes/frame. + +The corrected bulk measurement then reframed the whole track: + +| Condition | median B/frame | p90 | +|---|---|---| +| All on | 15 151 | 49 073 | +| **All 29 `ProjectM.Client` systems OFF** | **11 586** | 12 125 | + +With *every* client system disabled the editor still allocates ~11.6 KB/frame. Our entire presentation layer is +only ~10–25 % of the median — but it owns the **spiky tail** (p90 49 KB → 12 KB with our systems off), and that +tail is what triggers collections in bursts. A design-review critic also found the reason the floor is so high: +`PixelArtDevControls` is an **editor-only** dev tool that self-spawns via `[RuntimeInitializeOnLoadMethod]` + +`DontDestroyOnLoad` into **Game.unity**, and its `OnGUI` draws a `GUI.Button` on every IMGUI pass *before* its +early-out. It is a MonoBehaviour, so disabling ECS systems never touched it. It does not ship — but it polluted +every editor-side allocation number taken this session. + +### B1 — pooled one-shot SFX (`OneShotAudioPool.cs`, new) + +All 21 one-shot call sites funnel through the single line `FeedbackFx.PlayClip` → +`AudioSource.PlayClipAtPoint`, which allocates a `GameObject` + `AudioSource` **per call** and schedules a +delayed `Destroy` — ~20–33 calls/s in light combat. Replaced with a 32-voice ring; `PlayClip`'s signature is +unchanged so **all 20 consuming call sites compile untouched**. + +Parity traps the design review caught before they shipped: +- **`spatialBlend = 1`** — a fresh `AudioSource` defaults to **2D**; `PlayClipAtPoint` is the only thing setting + it. Missing it would silently make every combat cue non-positional. +- **`dopplerLevel = 0`** — pooled voices *teleport* between events; at the stock `1` a 20 m jump pitch-bends the + clip. This bug cannot exist when the source is created at the position and never moves. +- **`DontDestroyOnLoad`** — `WorldLauncher` does `LoadScene(..., Single)` while the client world is alive; a + scene-parented pool would be destroyed mid-session and every SFX would go silently dead. +- **`[RuntimeInitializeOnLoadMethod(SubsystemRegistration)]` reset** — statics survive fast-enter-playmode but + the `UnityEngine.Object`s they point at do not; session two would hold an array of destroyed voices and throw + on the first cue. +- Recycle deadline from the actual `clip.length` (cues span 0.05 s–0.45 s), steal-oldest when saturated, + `pitch` reset per rent, `GameVolume.Sfx` read at play time (never cached, never double-applying master). + +### B2 — pooled authored VFX (`CombatFeedbackSystem`) + +Per-impact `Object.Instantiate`/`Destroy` replaced with a per-prefab pool (`RentVfx`/`FillVfx`/`ReturnVfx`). +Traps handled, all from the risk pass: +- Component arrays cached **per instance**, not per prefab (component refs are instance-scoped; arrays captured + off the prefab asset would drive the asset). +- `main.stopAction = None` forced at fill — a prefab set to `Destroy`/`Disable` would silently drain the pool. +- Instances fill under an **inactive** root so `Awake`/`Start` never run, which is what makes `DestroyImmediate` + in `StripCosmetic` safe (a deferred `Destroy` would hand out an instance still carrying a live Rigidbody + + Collider for one frame). +- `StripCosmetic` now disables **all** MonoBehaviours, not two name substrings — a pooled instance re-runs + `OnEnable` on every rent, so a surviving helper would re-arm each time. +- Transform (position/rotation/**scale**/parent) rewritten per rent; `ps.Clear(true)` before `Play` (a + world-space system would re-show the previous burst); `TrailRenderer.Clear()` after the reposition. +- `Rented` flag = at-most-once guard against a double `Return` aliasing one instance to two callers. +- Returned by the prefab stored **on the record**, never a re-read of `VFXConfig` (an inspector swap mid-play + would file it under the wrong effect). In-flight cap `MaxActiveVfx = 40` unchanged; separate retained cap + `VfxPerPrefabRetain = 10`. + +### B3 — per-frame allocation + +- `BuildSlashInto` + `BuildDangerMesh`: four arrays each (~1.7 KB / ~840 B) allocated on **every** call — the + first runs twice a frame while a swing arc is alive, the second once per winding enemy per frame. Hoisted to + scratch fields; UVs/triangles are argument-independent so they now upload to a mesh only on its first fill. +- `HudSystem`, `AbilityBarSystem`: unguarded per-frame `Label.text` builds gated on a changed value. +- `CombatFeedbackSystem.AnimateNumbers`: legacy `TextMesh` bakes colour into vertex colours, so every colour + write rebuilds the text mesh — fade quantised to 12 steps. +- `EnemyHealthBarSystem`: per-bar uGUI writes epsilon-gated (an `anchorMax` write triggers + `OnRectTransformDimensionsChange`). +- `EnemyHitFlashSystem` / `NodeFeedbackSystem`: stopped playing back an **empty** `EntityCommandBuffer` (a + structural-change sync point) every frame. +- **`AudioClip` leak, closed across all seven clip-owning systems.** An `AudioClip.Create`d clip is a standalone + `UnityEngine.Object` — destroying a system's FX-root does **not** take it with it, so every client-world + teardown leaked its native audio buffer (`MusicSystem` alone ~6.8 MB, `AmbientAudioSystem` ~2 MB). Helper + promoted to `FeedbackFx.DestroyClip(ref AudioClip)`. + +## Validation + +- **L1** console clean (0 errors) against the session baseline. +- **L2** EditMode **304/304 green** (304 is the expected post-purge count, matching 2026-08-07). +- **L3 live**, the structural proofs that are immune to editor noise: + - **`oneShotAudioObjectsSeen = 0`** — `PlayClipAtPoint`'s signature `"One shot audio"` GameObject never + appeared once across 270 frames of combat with kills. + - VFX pool filled to its `VfxPerPrefabRetain = 10` cap and **stabilised** (bounded reuse, not churn). + - Audio verified live end-to-end: real cues (`husk_hit`, `strike`, `growl_grunt` ×2) serviced through the + ring within 2 s of a non-lethal injected hit. + - Clean-run frame time **median 8.6 ms / p99 13.2 ms** (pre-change runs measured 12.2–13.3 ms / 20–21 ms). + Caveat: the two harnesses are not byte-identical (the pre-change one built a string per frame), so treat + the direction as solid and the exact delta as indicative. + - Allocation medians stayed inside the editor noise band — expected, given our layer is a minority of it. + **A player build is required for a true allocation number.** + +### A false negative worth remembering + +An intermediate audio check reported `framesWithAudio = 0/280` and looked exactly like a silent-audio +regression. It was a **dead window** — the previous run had killed every enemy, so nothing was cueing. Direct +probing (`OneShotAudioPool.Play` → `isPlaying = true`, plus `s_freeAt` showing four voices serviced in the last +0.4 s) disproved it. *Rule: before believing a "feature is dead" measurement, prove the stimulus actually +occurred.* + +## Reviews + +- **Pre-code audit** (4 lenses + completeness critic + refactor-risk verifier, 6/6 agents, 0 failures): produced + the parity spec and the trap list above. Caught `spatialBlend`, Doppler, `LoadScene(Single)`, and the + play-enter static reset **before** they shipped, plus the `PixelArtDevControls` measurement contaminant. +- **Post-impl diff review** (3 lenses + 19 adversarial verifiers, 22/22 agents, 0 failures): **19 findings → 5 + confirmed**, all fixed: + 1. **Real regression I introduced** — `Mathf.RoundToInt` rounds **half-to-even** while `ToString("0.0")` + rounds **half-away-from-zero**, so my ability-bar gate key and the string it guarded disagreed at + midpoints; the label latched a stale, too-high reading and skipped a tenth on every cooldown. Fixed by + single-sourcing the text from the quantised integer and switching to `CeilToInt` (a countdown should never + read lower than the true remainder), with the branch bool cached so the format still switches at 597 ticks. + 2. The clip-leak fix was **half-closed** — only `CombatFeedbackSystem`'s ten clips. Completed across all seven + owners. + 3–5. Two duplicated comment blocks left by structured edits, and a doc-accuracy nit on the ring-size rationale. + +## Notes / loose ends + +- **`Assets/_Project/Shaders/PixelOutline.mat` is modified in the working tree and is NOT mine to commit.** + `PixelArtDevControls` writes straight to the shared material asset, and `_MasterEnabled` flipped 0→1 with + posterize/normal-edges off during the Play sessions. Left untouched in case the toggle was deliberate; + excluded from the Track B commits. It is a real look change — decide before committing. +- **CLAUDE.md is at 39 850 / 40 960 bytes.** A condensation pass is due; this session's long-form lessons went + to the gotchas archive rather than inline. + +## Next session + +1. **Track A — the camera** (this is what the operator's report actually describes, and it is still unfixed): + split the base/shake channels, make the hold time-based and shake-safe, de-stack the lethal frame, prewarm + the first-kill VFX. Open fork: keep the camera hold at all, or drop it and carry impact on FOV + shake. +2. **Track C — feel**: enemies have **no collider** (you walk through them); FOV pumps on every hit; the 0.9 s + corpse window wants re-judging once the camera is fixed. +3. Measure allocation in a **player build** to get a number free of editor contamination. diff --git a/Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md b/Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md index 5cca8eadd..30d0a43e1 100644 --- a/Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md +++ b/Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md @@ -553,3 +553,66 @@ long forms are preserved here. shaking."* An `EditorApplication.update` trace quantified it instantly (y 1.157↔13.559, 7.43 units/tick; after the fix span 0.051, max step 0.004). Gate such effects on the presence of the owning component (`GetComponent()`), never on a scene name. + +## 2026-08-13 — Combat-freeze diagnosis + Track B (allocation / pooling) + +Session log: [[2026-08-13_Combat_Freeze_Diagnosis_Track_B_Allocation]]. Payment for the CLAUDE.md line added +this session (net-zero rule): the six items below stay here, one condensed pointer lives inline. + +1. **`GC.GetTotalMemory(false)` is USELESS for per-frame allocation attribution — it quantises to 4 KB pages.** + A 29-system toggle sweep produced medians of exactly `0` or `4096` and a "savings" column of pure noise; I + nearly shipped a refactor ranked off it. The correct instrument is + `ProfilerRecorder.StartNew(ProfilerCategory.Memory, "GC Allocated In Frame")`, which reports real + bytes/frame. Retract and re-measure rather than reasoning over a quantised signal. + +2. **Attribute allocation by BULK toggle before ranking per-system work.** Disabling all 29 `ProjectM.Client` + systems at once dropped the median from ~15.1 KB to ~11.6 KB/frame — i.e. the whole presentation layer was + only ~10–25 % of the editor-side number, while owning the spiky tail (p90 49 KB → 12 KB). Per-system deltas + were inside the noise; only the bulk delta was legible. Rank work off the bulk number, not the per-item one. + +3. **★ An editor-only IMGUI dev tool can dominate your allocation measurement.** `PixelArtDevControls` + self-spawns via `[RuntimeInitializeOnLoadMethod(AfterSceneLoad)]` + `DontDestroyOnLoad` into **Game.unity** + (not just DevSandbox) and its `OnGUI` draws a `GUI.Button` on EVERY IMGUI pass *before* its `if (!_open) + return`. It is a MonoBehaviour, so an ECS-system toggle sweep never touches it. Exclude it before + attributing any editor-measured allocation to the game. + +4. **★ A value-CHANGE gate must be single-sourced with the value it renders.** Gating a label on + `Mathf.RoundToInt(x)` while the string is built by `ToString("0.0")` is a live bug: `Mathf.RoundToInt` is + round-half-to-**EVEN**, custom numeric formats round half-**AWAY-from-zero**. They disagree at midpoints, so + the gate latches and the label holds a stale, too-high reading and visibly skips a value. Derive the rendered + text FROM the quantised key, never re-derive it from the raw input. For a countdown prefer `CeilToInt` so the + readout never reads lower than the true remainder. (Caught by the post-impl review, not by 304 green tests.) + +5. **Pooling an `AudioSource` is not a drop-in for `AudioSource.PlayClipAtPoint` — three properties bite.** + (a) a fresh `AudioSource` defaults to `spatialBlend = 0` (**2D**); PlayClipAtPoint is the only thing setting + it to 1, so a naive pool silently makes every cue non-positional and no test catches it. (b) `dopplerLevel` + defaults to 1 and a pooled voice **teleports** between events — a 20 m jump pitch-bends the clip, a failure + that cannot exist when the source is created at the position and never moves; set 0. (c) `playOnAwake` + defaults true, which is harmless for a one-frame object and wrong for a long-lived one. Also: the pool root + needs `DontDestroyOnLoad` (WorldLauncher does `LoadScene(..., Single)` while the client world is alive) and a + `[RuntimeInitializeOnLoadMethod(SubsystemRegistration)]` reset, or session two holds destroyed voices. + +6. **Pooling a ParticleSystem prefab: `SetActive(false)/(true)` does NOT reset it.** Rent order is transform → + `SetActive(true)` → `ps.Clear(true); ps.Play(true)` (a world-space system otherwise re-shows the previous + burst at its OLD positions) and `TrailRenderer.Clear()` AFTER the reposition (else a streak draws from the + last despawn point). Force `main.stopAction = None` at fill — a prefab set to `Destroy` silently drains the + pool. Cache component arrays **per instance** (component refs are instance-scoped; arrays captured off the + prefab asset drive the asset). Fill under an **inactive** root so `Awake`/`Start` never run, which is what + makes `DestroyImmediate` of the Rigidbody/Collider safe — a deferred `Destroy` would hand out an instance + still carrying them for one frame. Guard `Return` with a `Rented` flag: a double return aliases one instance + to two callers. + +7. **An `AudioClip.Create`d clip is NOT owned by the GameObject that played it.** Destroying a system's FX-root + in `OnDestroy` leaves every procedural clip alive — seven presentation systems here leaked their native audio + buffers on every client-world teardown (`MusicSystem` ~6.8 MB, `AmbientAudioSystem` ~2 MB), growing unbounded + across menu → game → menu cycles. Shared helper: `FeedbackFx.DestroyClip(ref AudioClip)`. + +8. **Before believing a "the feature is dead" measurement, prove the STIMULUS occurred.** A 280-frame watch + reported `framesWithAudio = 0` and looked exactly like a silent-audio regression from the new pool. It was a + dead window — the prior run had killed every enemy, so nothing was cueing. Direct probing (a synthetic + `PlayClip` → `isPlaying = true`, plus `s_freeAt` showing four voices serviced within 0.4 s) disproved it in + one call. The same false-negative class as the 07-21 auto-recast retraction. + +9. **Injecting a kill: append a lethal `DamageEvent`, never write `Health.Current = 0`.** + `HealthApplyDamageSystem` early-`continue`s on an empty `DamageEvent` buffer, so a direct Health write is + never seen by the death branch and no `Dying` is ever stamped.