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

18 KiB
Raw Blame History

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_listcheck_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).