diff --git a/CLAUDE.md b/CLAUDE.md index cb58ee6d..9a72d3b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -763,7 +763,25 @@ H1 (PVS grounding) or H2 (`PortalSide` side-test) — both evidence-disproven. **Currently working toward: M1.5 — Indoor world feels right** (resumed from 2026-05-20 baseline after Phase O ship). -**2026-06-05 — Render Residual A (camera collision) SHIPPED + user-kept; next = the CORE INSIDE RENDER (R1 completion) (READ THIS FIRST).** +**2026-06-05 (PM) — Indoor FLICKER + bluish VOID ROOT CAUSE CONFIRMED (decomp + live cdb); 3-part retail-faithful fix PLANNED (READ THIS FIRST).** +The "core inside render / cellar floor drops" framing below is **SUPERSEDED** by this session's diagnosis. +R1's per-cell `DrawInside` is already built and the cottage/cellar **seals** (user visual-verified). The +residual indoor **flicker (grey↔texture while standing still)** + **stable bluish void** are ONE root cause — +**visibility metastability at cell boundaries:** the 3rd-person camera **boom drifts at rest** +(`desiredBack 3.11→3.07`), walking the eye across a portal plane, and acdream re-resolves the **viewer cell** +fresh each frame with **no hysteresis** → it flips `0170↔0171` → the render (rooted at the viewer cell) +redraws two solves → flicker; and the 2D project-then-clip **degenerates** at close portals (`proj=0`) → grey +void. **Live cdb on retail CONFIRMS** retail's `viewer_cell` is rock-stable here (clean monotonic transitions, +ZERO oscillation across 4,916 samples) — retail holds the boom + uses a **0.2 mm cell dead-zone** + clips +portals **in clip-space**. **FIX (3 parts, retail-faithful, planned):** (1) camera boom stability +[`RetailChaseCamera`; `UpdateCamera` 0x456660] → kills the flicker trigger; (2) viewer-cell ±0.2 mm dead-zone +[`PhysicsCameraCollisionProbe.SweepEye`; `point_inside_cell_bsp` 0x53c1f0]; (3) w-space (w=0) portal clip +[`PortalProjection`/`PortalVisibilityBuilder`; `GetClip` 0x5a4320 / `polyClipFinish` 0x6b6d00]. Two partial +fixes committed: `5f596f2` (NDC side-plane clip — KEEP), `9f95252` (eye-in-portal flood — reassess/revert). +Baseline App 183p / Core 1326p-4f-1s. **CANONICAL PICKUP:** +[`docs/research/2026-06-05-viewer-cell-flicker-rootcause-and-fix-plan-handoff.md`](docs/research/2026-06-05-viewer-cell-flicker-rootcause-and-fix-plan-handoff.md). + +**2026-06-05 — Render Residual A (camera collision) SHIPPED + user-kept; next = the CORE INSIDE RENDER (R1 completion) (SUPERSEDED 2026-06-05 PM — see the banner above; the cellar IS sealed, the real bug is the boundary flicker/void).** Residual A = a verbatim port of retail `SmartBox::update_viewer` (pc:92761): the indoor sweep's start cell is seated at the head-PIVOT via `AdjustPosition` (pc:280009) → `find_visible_child_cell` (pc:311397), plus the two fallbacks + cellId==0 snap-to-player. Commits `0ffc3f5` (spec) / `5177b54` (Core primitives) diff --git a/docs/research/2026-06-05-viewer-cell-flicker-rootcause-and-fix-plan-handoff.md b/docs/research/2026-06-05-viewer-cell-flicker-rootcause-and-fix-plan-handoff.md new file mode 100644 index 00000000..0adf8756 --- /dev/null +++ b/docs/research/2026-06-05-viewer-cell-flicker-rootcause-and-fix-plan-handoff.md @@ -0,0 +1,251 @@ +# Handoff — Indoor flicker/void ROOT CAUSE confirmed (decomp + live cdb); 3-part retail-faithful fix planned — 2026-06-05 (PM) + +> **Canonical pickup for the next (fix) session. Read this FIRST.** This session did the diagnosis +> the previous "core inside render (R1)" handoff asked for, and it landed somewhere different than that +> handoff predicted. The indoor **bluish void + grey/texture flicker** is NOT a missing per-cell flood +> port — R1's per-cell `DrawInside` is built and the cellar/ceiling seal correctly. The residual is +> **camera/viewer-cell instability at cell boundaries**, confirmed by both the named-retail decomp AND a +> live cdb capture of retail. The fix is a **3-part retail-faithful port** (camera boom stability + +> viewer-cell dead-zone + w-space portal clip), de-risked and ready to plan + implement. +> Branch `claude/thirsty-goldberg-51bb9b`. PowerShell on Windows; launch logs are UTF-16. + +--- + +## 0. TL;DR + +- **The premise we started with was stale.** "Cellar floor drops / R1 incomplete" — actually R1's per-cell + `DrawInside` + binary inversion already exist (commit `c4fd711`, 2026-06-02) and the cellar **is sealed** + (user visual-verified T1 this session). The flood reaches the cellar; the shell draws. +- **The real bug is two faces of ONE root cause — visibility metastability at cell boundaries:** + 1. **Flicker (grey↔texture) at a "stationary" position** = the **viewer (camera) cell flips per-frame** + `0170↔0171` because the 3rd-person camera **boom drifts** (`desiredBack 3.11→3.07` while the player + stands still), walking the eye across a portal plane, and acdream re-resolves the viewer cell fresh + each frame with **no hysteresis**. The render roots at the viewer cell, so it redraws two different + solves → flicker. + 2. **Stable bluish void** = when the eye is firmly in/at a thin/transition cell, the portal flood + **degenerates** because acdream projects-to-NDC-then-2D-clips instead of clipping in clip-space, so a + close/grazing portal drops (`proj=0`) → no terrain / neighbour not flooded → grey. +- **CONFIRMED by live cdb on retail** (this is the payoff): retail's `viewer_cell` is **rock-stable** at + the same Holtburg-cottage boundary — clean single monotonic transitions, **zero oscillation** across + 4,916 samples; retail rests the camera in the *substantial* cell, never lingering in the thin doorway + cell. acdream's `[flap-sweep]` flips `0170↔0171` at the same spot. Retail is stable, acdream is not — + exactly as the decomp predicted. +- **The fix (3 parts, prioritized) is retail-faithful and anchored to specific decomp functions.** §4. +- **Two partial fixes shipped this session** (`5f596f2` NDC frustum side-plane clip — keep; + `9f95252` eye-in-portal flood — band-aid, likely superseded → reassess/revert). §3. +- **Test baseline:** App **183 pass / 0 fail** (179 + 4 new). Core **1326 pass / 4 fail (documented) / + 1 skip**. Branch HEAD after this handoff commit. Build green. + +--- + +## 1. The session arc (so you don't repeat it) + +1. Read the "core inside render (R1)" handoff. **Discovered R1's inversion + per-cell `DrawInside` already + exist and are live** (`c4fd711`/`4b75c68`/`cf85ea4`, 2026-06-02; not reverted). So this was a *debug* + task, not a *port* task. +2. **Visual gate (user):** the cellar (T1) is **sealed**. The real symptoms are at **transitions**: a + bluish **void flap** exiting the building (screenshot), a **grey flash** cellar→room, **outdoor + content through the ground** looking out, and a **stationary flicker** (textures alternate grey↔texture + while standing still). +3. **Evidence-first (probes `ACDREAM_PROBE_FLAP/_VIS/_SHELL/_CELL`):** + - Refuted the prior handoff's hypothesis ("flood doesn't reach the cellar") — rooted at the room, the + flood **does** reach the cellar (`[vis] root=0171 ids=[...,0174]`). + - Found the void is `terrain=Skip` / `proj=0` on exit/interior portals when the eye is close + (`[flap] p->0xFFFF D=-0.28 proj=4 clip=0`; later `p1->0x0171 D=0.16 proj=0`). + - Found the **flicker** is the **viewer cell flipping** at a knife-edge boundary: in a held pose the + `[flap-sweep] viewerCell` flips `0170↔0171` as `D` crosses `0.00`, while the **player stands still** + and the boom **drifts** (`desiredBack 3.11→3.07`). +4. **Decomp spike (3 parallel agents + 1 verified-myself crux):** mapped how retail stays stable. §2. +5. **Live cdb on retail** (matching v11.4186 binary, PDB-paired): captured retail's `viewer_cell` across + the same crossings → **clean, no oscillation**. §2.4. Confirms the fix direction. + +--- + +## 2. ROOT CAUSE — retail stays stable via THREE mechanisms; acdream diverges on each + +### 2.1 Camera boom is a stable spring (Q3) — `CameraManager::UpdateCamera` +- Retail's boom vector `CameraManager::viewer_offset` (default `y = -2.5 m`) is **fixed** (changes only on + zoom keys). `UpdateCamera` (≈`0x00456660`, lerp body `0x00456d0d`) lerps the camera *position* toward + `pivot + viewer_offset` with stiffness, and **snaps to the current position when within 0.0004 m** → holds + a constant fixed point at rest. +- **The collided eye is firewalled from the desired position:** `set_viewer(…, arg3=0)` (the normal success + path, `update_viewer` pc≈92870) writes `this->viewer` (rendered eye) but **NOT** `viewer_sought_position`. + So the collision result never feeds back into the desired boom. +- `PlayerPhysicsUpdatedCallback` (`0x00452d60`, pc:91836) computes `viewer_sought_position = UpdateCamera(current_viewer)`. +- **acdream diverges:** `RetailChaseCamera` desired boom **drifts at rest** (`desiredBack 3.11→3.07`). + Hypotheses (verify in code): the collided eye is fed back into the desired (no firewall), and/or no + convergence snap. **This drift is the flicker trigger** (walks the eye across the boundary). + +### 2.2 Viewer cell is sticky via a 0.2 mm dead-zone (Q1) — VERIFIED MYSELF +- `SmartBox::update_viewer` (`0x00453ce0`, pc:92761): `viewer_cell = sphere_path.curr_cell` after the + collision sweep, which **starts from the stable player cell** each frame (`cell_1` = player cell or + `AdjustPosition`-seated). +- `SPHEREPATH::init_path` (`0x0050ce20`, pc:274370): `curr_cell = arg2` (the start cell). +- The sweep updates the cell only on a **definite** crossing: `check_other_cells` (`0x0050ae50`, pc:272717) + → `find_cell_list` → `check_cell`; `validate_transition` (`0x0050aa70`) promotes `curr_cell = check_cell` + (pc:272608) only when the cell/pos actually changed, else **restores `curr_cell`**. +- **The dead-zone (VERIFIED at pc:325513/325522):** `BSPNODE::point_inside_cell_bsp` (`0x0053c1f0`) uses + `0.000199999995f` (≈0.2 mm) symmetrically — a point within ±0.2 mm of a splitting plane belongs to + **neither** cell, so at a boundary graze `check_cell` is null and `curr_cell` stays at the start cell. +- **acdream diverges:** `RetailChaseCamera.ViewerCellId = swept.ViewerCellId` is re-resolved fresh **every + frame** with **no dead-zone** ("graph-tracked, deterministic, NO grace frames" — the comment) → flips at + the boundary. + +### 2.3 Portal clip is homogeneous (w-space), before the divide (Q2) — `GetClip`/`polyClipFinish` +- `PView::GetClip` (`0x005a4320`, pc:432344) projects the portal then calls `ACRender::polyClipFinish` + (`0x006b6d00`, pc:702749), which **clips against the near plane (w=0) in clip-space, generating synthetic + edge vertices, BEFORE the perspective divide** — so a close portal never blows up to garbage NDC. +- `PView::InitCell` (`0x005a4b70`, pc:432896) **side-test culls** in-plane/back-facing portals (same 0.2 mm + band, pc≈432936) before any projection. +- The flood is substantially **root-invariant** for adjacent cells (both seeded full-screen; side-test is + symmetric). +- **acdream diverges:** projects-to-NDC then 2D-clips → degenerates at grazing/close angles (`proj=0` → + portal dropped → grey void / neighbour not flooded). This session's commit `5f596f2` added the eye-plane + + side-plane clip (partial); the missing piece is the **w=0 near-plane clip with synthetic verts** + the + side-test dead-band. + +### 2.4 LIVE CDB CONFIRMATION (retail v11.4186, PDB-paired) — the payoff +Captured `SmartBox::viewer_cell` (`viewer.objcell_id`) at the Holtburg cottage while passing inside↔outside ++ the stairs + standing still. Run-length-encoded camera-cell sequence (4,916 samples): +``` +0xa9b40032 ×3360 → 0031 ×173 → 0170 ×8 → 0171 ×134 → 0170 ×14 → 0031 ×129 +→ 0170 ×7 → 0171 ×139 → 0170 ×15 → 0031 ×110 → 0170 ×7 → 0171 ×167 → … (clean repeats) +``` +- **Every crossing is a clean, single, monotonic pass** (outside `0031/0032` → vestibule `0170` brief + 7–15-sample pass-through → room `0171`, and back). **ZERO `0170↔0171` oscillation** anywhere. +- Standing still, retail rests in the **substantial** cells (room `0171` ×134–197, outside long runs), + **never lingering in the thin vestibule `0170`**. +- **Contrast acdream:** `[flap-sweep] viewerCell` flips `0170↔0171` per-frame at the same boundary. +- **Conclusion:** retail's viewer cell is stable (boom holds + 0.2 mm dead-zone + sweep-from-player-cell); + acdream's is not. No surprises — the fix is de-risked. + +--- + +## 3. What shipped this session (committed; partial) + +| SHA | What | Keep? | +|---|---|---| +| `5f596f2` | `PortalProjection.ProjectToNdc` clips eye + 4 frustum **side planes** in clip-space before the divide (replaces the 2026-06-03 `MinW`-only workaround). Bounds NDC to the screen. | **KEEP.** Real correctness, retail-consistent (partial of §2.3). | +| `9f95252` | `PortalVisibilityBuilder` floods the neighbour when the eye **stands in** an interior portal (`EyeInsidePortalOpening`). Fixed the cellar **ceiling** (visual-verified). | **REASSESS / likely REVERT.** A coverage band-aid for the thin-cell-root case; the §4 boom + dead-zone keep the camera out of thin cells, and the w=0 clip handles close portals — this may become unnecessary or over-include. Easy `git revert 9f95252`. | + +Neither is the flicker fix. Both green (App 183), Core baseline held (1326/4/1). + +--- + +## 4. THE FIX — 3-part retail-faithful port (prioritized). Plan, then implement TDD. + +### Part 1 (HIGHEST leverage) — Camera boom stability → kills the flicker trigger +- **Goal:** acdream's desired boom settles and **holds** at rest (no drift). Match `UpdateCamera`: desired + position derived each frame from a **fixed** boom offset + the player pivot; **firewall the collided eye** + out of the desired chain; add the **convergence snap** (return current when within ~0.0004 m). +- **acdream targets:** `src/AcDream.App/Rendering/RetailChaseCamera.cs` (the `_dampedEye` / `desiredBack` / + the lerp + where the collided `swept.Eye` is consumed). Verify whether `swept.Eye` feeds the next-frame + desired (the drift hypothesis) and whether a snap exists. +- **Anchors:** `CameraManager::UpdateCamera` `0x00456660` (snap ~`0x00456d0d` region), `PlayerPhysicsUpdatedCallback` + `0x00452d60` (pc:91836), `set_viewer` arg3=0 firewall (`update_viewer` pc≈92870). +- **Verify:** with the boom stable, the `[flap-sweep] eyeBack/desiredBack` is flat at rest and the eye stops + grazing the boundary. + +### Part 2 — Viewer-cell dead-zone hysteresis → belt-and-suspenders for the flicker +- **Goal:** acdream's camera-sweep viewer-cell resolution doesn't flip on a sub-mm/boundary graze. Port the + retail dead-zone: a point within ±0.2 mm of a portal plane belongs to neither cell → keep the prior/start + (player) cell. +- **acdream targets:** `src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs` (`SweepEye`) and the + Core cell-resolution it calls (`CellTransit.FindVisibleChildCell` / the point-in-cell test). Ensure the + sweep starts from the player cell and only changes `viewer_cell` on a definite crossing. +- **Anchors:** `point_inside_cell_bsp` `0x0053c1f0` (0.000199999995f, pc:325513/325522), `init_path` pc:274370, + `validate_transition` pc:272608, `check_other_cells` pc:272717. + +### Part 3 — w-space portal clip robustness → kills the *stable* grey void +- **Goal:** extend `5f596f2` to a true **near-plane (w=0) clip with synthetic edge vertices before the + divide** + the **InitCell side-test dead-band** that culls in-plane/back-facing portals before projection. +- **acdream targets:** `src/AcDream.App/Rendering/PortalProjection.cs` + `PortalVisibilityBuilder.cs` + (the side test `CameraOnInteriorSide`). After this, **reassess `9f95252`** (the eye-in-portal flood may be + redundant → revert). +- **Anchors:** `GetClip` `0x005a4320` (pc:432344), `polyClipFinish` `0x006b6d00` (pc:702749, the w=0 clip), + `InitCell` `0x005a4b70` (pc:432896 side-test). + +**Order:** Part 1 → visual gate → Part 2 → Part 3 → reassess `9f95252`. Each is independently verifiable. + +--- + +## 5. KEEP / DON'T + +**KEEP:** +- R1 per-cell `DrawInside` + the binary inversion (`c4fd711`) — built and correct; the cellar seals. +- Residual A (camera collision, `update_viewer` port) — the viewer cell is *accurate*; we're stabilizing it, + not removing it. +- Commit `5f596f2` (NDC side-plane clip). +- The two-camera-ish reality: eye drives projection; the fix makes the *viewer cell* stable (boom + dead-zone), + matching retail (`is_player_outside` decides; `DrawInside(viewer_cell)` roots). + +**DON'T:** +- Don't re-attempt "the flood doesn't reach the cellar" — refuted (`[vis]` shows it does). +- Don't add a render-side debounce/grace-period for the flicker — it's a **membership/visibility stability** + bug; fix the *input* (boom + dead-zone), not the render (memory: render-downstream-of-membership). +- Don't switch the render root to the *player* cell — retail roots `DrawInside` at the *viewer* cell; the + fix is to make the viewer cell *stable*, not to change which cell roots. +- Don't put a `;` inside a cdb `$$` comment (it splits into a command — bit me this session; use `*` comments). + +--- + +## 6. APPARATUS (committed / ready) + +- **Probes** (all live): `ACDREAM_PROBE_FLAP` (`[flap]`/`[flap-cam]`/`[flap-sweep]`), `ACDREAM_PROBE_VIS` + (`[vis]`), `ACDREAM_PROBE_SHELL` (`[shell]`), `ACDREAM_PROBE_CELL` (`[cell-transit]`). +- **cdb script** `tools/cdb/retail-viewer-cell.cdb` — samples retail `SmartBox::viewer_cell` ~6/sec, + auto-`.detach` after 6000 hits. Binary verified MATCH via `tools/pdb-extract/check_exe_pdb.py`. + Re-run pattern + RLE analysis are in this session's transcript (PowerShell `Select-String` on + `retail-viewer-cell.log`). **Lesson:** per-frame bp + `dt`/`.printf` is heavy but survived here (retail + intact); keep samples sparse. `qd` is ignored in bp actions — use `.detach`. +- **TTD** is available (`tools/ttd-record.ps1` / `tools/ttd-query.ps1`) if a lower-overhead capture is needed. + +--- + +## 7. STATE +- Branch `claude/thirsty-goldberg-51bb9b`. HEAD: this handoff's docs commit (after `9f95252`). No push (ask first). +- Build green. App **183 / 0**. Core **1326 / 4 (documented: 2× DoorBugTrajectoryReplay LiveCompare, + BSPStepUpTests.D4, DoorCollisionApparatus) / 1 skip**. +- Running the client: see CLAUDE.md "Running the client"; `+Acdream` spawns in the Holtburg cottage. + +--- + +## 8. KICKOFF PROMPT (copy-paste for the next session) + +``` +Continue acdream M1.5 indoor render: fix the boundary FLICKER + stable bluish VOID with the 3-part +retail-faithful port that the 2026-06-05 spike confirmed. ROOT CAUSE (decomp + live cdb, both done): +the indoor flicker/void is VISIBILITY METASTABILITY at cell boundaries, not a missing flood — R1's +per-cell DrawInside is built and the cellar seals. Retail stays stable via three mechanisms; acdream +diverges on each. Branch claude/thirsty-goldberg-51bb9b (do NOT branch/worktree; do NOT push without +asking; NEVER git stash/gc). PowerShell on Windows; launch logs are UTF-16. + +READ FIRST (in order): +1. docs/research/2026-06-05-viewer-cell-flicker-rootcause-and-fix-plan-handoff.md (THIS handoff — root + cause §2, cdb confirmation §2.4, the 3-part fix §4, KEEP/DON'T §5). +2. memory: reference_render_pipeline_state.md, feedback_render_downstream_of_membership.md, + feedback_render_one_gate.md, project_camera_visibility_coupling.md. +3. The decomp anchors cited in §2/§4 (named-retail) for each part you implement. + +THE FIX (plan first, then implement TDD; each part independently visual-verified): + Part 1 (highest leverage) — Camera boom stability (RetailChaseCamera): desired boom settles + HOLDS at + rest; firewall the collided eye out of the desired chain; add the convergence snap. Anchors: + CameraManager::UpdateCamera 0x00456660, PlayerPhysicsUpdatedCallback 0x00452d60, set_viewer arg3=0. + Part 2 — Viewer-cell dead-zone (PhysicsCameraCollisionProbe.SweepEye / Core cell resolution): ±0.2 mm + dead-zone so a graze keeps the player/start cell. Anchors: point_inside_cell_bsp 0x0053c1f0 + (0.000199999995f), validate_transition pc:272608, init_path pc:274370. + Part 3 — w-space portal clip (PortalProjection/PortalVisibilityBuilder): near-plane (w=0) clip with + synthetic verts before the divide + InitCell side-test dead-band; then reassess/revert the + eye-in-portal flood band-aid (commit 9f95252). Anchors: GetClip 0x005a4320, polyClipFinish 0x006b6d00, + InitCell 0x005a4b70. + +START by using superpowers:writing-plans (or brainstorming if a part's shape is unclear) to turn §4 into a +step plan with per-part acceptance + visual gates, THEN implement Part 1 first via TDD. + +DON'T (§5): no render-side debounce for the flicker (fix the boom/cell input); don't switch the render +root to the player cell (retail roots DrawInside at the viewer cell — stabilize it instead); don't reopen +"flood doesn't reach the cellar" (refuted). + +TEST BASELINE: App 183 pass / 0 fail. Core 1326 pass / 4 fail (documented) / 1 skip. Build green. +This session committed 5f596f2 (NDC side-plane clip — KEEP) + 9f95252 (eye-in-portal flood — reassess). +``` diff --git a/tools/cdb/retail-viewer-cell.cdb b/tools/cdb/retail-viewer-cell.cdb new file mode 100644 index 00000000..0634df9e --- /dev/null +++ b/tools/cdb/retail-viewer-cell.cdb @@ -0,0 +1,17 @@ +* Retail viewer-cell stability capture (2026-06-05) +* Confirms whether retail SmartBox viewer_cell flips at a portal boundary +* or stays stable. Samples viewer.objcell_id ~6/sec to avoid lagging retail. +* Auto-detaches after 6000 hits via .detach (cdb ignores qd inside bp actions) +* NOTE never put a semicolon inside a comment line, it splits into a command + +.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b\retail-viewer-cell.log +.sympath C:\Users\erikn\source\repos\acdream\refs +.symopt+ 0x40 +.reload /f acclient.exe + +x acclient!SmartBox::update_viewer + +r $t0 = 0 +bp acclient!SmartBox::update_viewer "r $t0 = @$t0 + 1; .if (@$t0 % 5 == 1) { .printf \"[vc] hit=%d eyeCell=\", @$t0; dt acclient!SmartBox @ecx viewer.objcell_id }; .if (@$t0 >= 6000) { .detach } .else { gc }" + +g