acdream/docs/research/2026-06-05-viewer-cell-flicker-rootcause-and-fix-plan-handoff.md
Erik 9601ef39c3 docs: indoor flicker/void root cause (decomp + live cdb) + 3-part fix plan handoff
Diagnosis session: the indoor bluish void + grey/texture flicker is visibility metastability at cell boundaries, not a missing flood (R1's per-cell DrawInside is built; the cellar seals). Confirmed by named-retail decomp AND a live cdb capture of retail (viewer_cell rock-stable: clean monotonic transitions, zero oscillation across 4916 samples). Retail stays stable via boom stability + a 0.2mm viewer-cell dead-zone + clip-space portal clipping; acdream diverges on all three. Handoff documents the root cause, the cdb evidence, and the prioritized 3-part retail-faithful fix (boom stability -> dead-zone -> w-space clip) with decomp anchors + a planning/implementation kickoff prompt. Adds the reusable retail viewer-cell cdb capture script and the superseding CLAUDE.md banner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:31:17 +02:00

251 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
715-sample pass-through → room `0171`, and back). **ZERO `0170↔0171` oscillation** anywhere.
- Standing still, retail rests in the **substantial** cells (room `0171` ×134197, 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).
```