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>
This commit is contained in:
Erik 2026-06-05 15:31:17 +02:00
parent 9f95252d20
commit 9601ef39c3
3 changed files with 287 additions and 1 deletions

View file

@ -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)

View file

@ -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
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).
```

View file

@ -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