acdream/docs/research/2026-06-08-full-retail-render-port-OPTION-A-handoff.md
Erik fe87e9794a docs(render): FLAP settled by live-retail measurement — full retail port DECIDED (Option A) + exhaustive handoff
Attached cdb to the live 2013 retail client at the Holtburg doorway + read the decomp.
The indoor flap is a STRUCTURAL divergence, settled by measurement (not inference):

- Retail has ONE render path: DrawInside(viewer_cell) every frame. NO inside/outside
  branch (RenderNormalMode's outside branch is dead code; is_player_outside only gates
  sky/lighting). "Entering a building" is not a render event — only the camera sweep
  resolving a different viewer_cell. Same path before/after threshold -> no seam.
- Retail's eye JITTERS ~36um at rest yet membership is stable -> robustness is
  STRUCTURAL: many small per-building floods (~7/frame, ~2 cells each, via terrain BSP
  -> DrawPortal -> ConstructView(CBldPortal)), not one giant knife-edge flood.
- Our 3 divergences: (D1) invented inside/outside branch (GameWindow.cs:7498,
  clipRoot = viewerRoot ?? _outdoorNode :7396); (D2) synthetic _outdoorNode; (D3) one
  unified flood.

DECISION (user-approved): Option A — rip out branch + outdoor node, root always at the
real viewer_cell, one DrawInside, per-building rendering. Phased, conformance-tested,
visual-gated.

REFUTED by measurement (do not retry): bounded-propagation/churn (maxPop=1, 0/63k
reciprocals empty); byte-stable eye (retail's jitters ~36um — rest-snap cd974b2 failed +
regressed, reverted 9b1857a).

Lands the canonical exhaustive handoff for a FRESH session
(docs/research/2026-06-08-full-retail-render-port-OPTION-A-handoff.md), the CLAUDE.md
READ-THIS-FIRST banner, and reusable cdb apparatus. No project code changed; working tree
at the known-good baseline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:19:34 +02:00

545 lines
34 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 — Full Retail Render Port (Option A): one `DrawInside(viewer_cell)` path, no inside/outside branch
**Date:** 2026-06-08 (evening)
**Branch:** `claude/thirsty-goldberg-51bb9b` (HEAD `9b1857a`)
**Status:** Design DECIDED (Option A). No implementation started. This is the canonical pickup
document for a FRESH session. Read it top-to-bottom before touching code.
**Author's note to the next session:** this is the payoff of a ~4-week saga + one long
measurement session. The information below was *expensive* to obtain (live cdb on the real
2013 retail client). Do not re-derive it; do not re-guess. Build from it.
---
## 0. TL;DR (read this, then read the rest)
The indoor "flap"/flicker is **not a bug to be fixed with a point change.** It is the symptom
of a **structural divergence** from how retail renders. We confirmed this by attaching cdb to
the **live retail client** and reading the decompilation. The findings are unambiguous:
- **Retail has exactly ONE render path: `DrawInside(viewer_cell)`, every frame.** There is **no
inside/outside render branch.** The "outside" branch in `RenderNormalMode` is dead code
(compiler-constant). `is_player_outside` only gates sky/weather/lighting, never the render path.
- **"Entering a building" is NOT a rendering event in retail.** It is *only* the camera sweep
resolving a different `viewer_cell` (an outdoor `CLandCell` → an indoor `CEnvCell`). The render
code never asks "am I inside?". Same path before and after the threshold → **no seam → no flap.**
- **Retail's eye JITTERS ~36 µm at rest** (measured, live). Retail's membership is stable anyway.
So retail's stability is **structural** (coarse per-building visibility robust to jitter), NOT
from a stable eye. **Chasing a byte-stable eye is the wrong target** — retail itself doesn't
have one.
- **We diverged in three ways:** (1) we invented an inside/outside branch + a synthetic
`_outdoorNode`; (2) we do ONE giant unified flood where retail does many small per-building
floods; (3) our camera boom jitters ~36× more than retail's.
**The decision (user-approved 2026-06-08): Option A — full retail structural port.** Rip out the
branch and the outdoor node. Always root at the real `viewer_cell`. One `DrawInside`. Render
terrain + per-building interiors from *within* that path the way retail does. Phase it; conformance-
test each phase against the measured retail values in this doc; visual-gate.
**Next session's first move:** run `superpowers:brainstorming` is NOT needed (design is decided);
go to `superpowers:writing-plans` to turn §6 (the design) into a phased implementation plan, then
`superpowers:executing-plans`/`subagent-driven-development`. But FIRST close the open traces in §8.
---
## 1. The decision and its scope
**Option A — Full retail structural port.** In scope:
1. **Remove the inside/outside render branch.** Today `GameWindow.cs:7498` does
`if (clipRoot is not null) { DrawInside } else { DrawPortal }`, where
`clipRoot = viewerRoot ?? _outdoorNode` (`GameWindow.cs:7396`). Retail has no such branch.
2. **Root always at the real `viewer_cell`** (the cell the camera-collision sweep resolves — an
outdoor `CLandCell` or an indoor `CEnvCell`), never a synthetic outdoor node.
3. **One `DrawInside(viewer_cell)` per frame.** Terrain + sky draw from *within* it when the flood
"sees outside"; per-building interiors draw per-portal via the terrain BSP.
4. **Per-building view construction** (retail does ~7 small per-building floods/frame), replacing
our single unified flood.
Explicitly NOT the goal: "make the eye byte-stable" (retail's isn't); "add hysteresis/dead-zone to
the clip" (band-aid, forbidden); "bound the portal re-enqueue churn" (there is no churn — measured).
The user's words: *"Yes lets do full retail! A!"* and earlier *"NO code of the project is frozen so
all options on the table."* Nothing is frozen. This is a render-orchestration rewrite, done
retail-faithfully, in phases.
---
## 2. Why this took ~4 weeks (the pattern is the diagnosis)
Over ~4 weeks the "root cause" was declared, with apparatus, **at least seven times**: two-pipe
split → root-at-player-cell → viewer-cell metastability → camera-boom drift → physics rest-jitter →
portal-flood re-enqueue churn → render-position jitter. **Every one was a real, measured
perturbation. Every fix failed or moved the symptom.** That pattern is the signature of a
**system-level problem attacked one stage at a time** (systematic-debugging skill, Phase 4.5:
"3+ fixes failed → question the architecture").
**The fundamental issue.** The flicker is a **binary** decision ("is this cell visible: yes/no")
made at a **grazing knife-edge** (the doorway portal, near-zero-area sliver), fed by a **long,
coupled chain that amplifies**:
```
physics body → render-position interpolation → camera boom → camera-collision sweep
→ viewer cell → render branch → portal flood → clip → VISIBLE / NOT VISIBLE
```
Measured amplification: physics body byte-stable → render position jitters µm → eye jitters
~1.3 mm → at the end the continuous wobble is forced into a yes/no at a knife-edge → cell pops in
and out. **It is a pencil balanced on its tip:** it doesn't matter which draft of air tips it,
there's always another. You cannot stabilize a pencil-on-tip by hunting individual air currents.
Every "I found the jitter source!" fix closed one draft while the pencil stayed on its point.
**Why retail has the same knife-edge but doesn't flicker:** retail uses the *exact same* grazing
clip (we ported it). Retail doesn't flicker because **its structure is robust to the jitter**
many small per-building visibility decisions, not one giant knife-edge flood. Retail did NOT remove
the jitter (its eye jitters ~36 µm too); it made the *decision* robust to it. **That is the thing
we never did, because we kept patching the noise instead of the structure.**
---
## 3. THE ORACLE — how retail actually renders (measured + decompiled GROUND TRUTH)
This is the irreplaceable part. It was obtained from **the live retail client** (cdb) + the named
decomp. Cite it; do not re-derive it.
### 3.1 Retail render architecture: ONE path
`SmartBox::RenderNormalMode` (`0x453aa0`, decomp line 92635) **always** calls
`DrawInside(viewer_cell)`. The "outside" branch (`LScape::draw`) is **dead code** — the branch
predicate is the Binary-Ninja artifact `edi_2 = -((edi - edi))` = `xor edi,edi; neg edi` = **always
0**, so the inside branch is taken every frame. `is_player_outside` (`0x451e80`, line 90996) returns
nonzero for an outdoor land cell (low 16 bits of `objcell_id` in `[1, 0xFF]`) but is **not called
from the render dispatch** — only from `GameSky::Draw`, UI, and lighting. **There is no
inside/outside render branch in retail.**
### 3.2 Call graph (from the decomp-flow research agent, verified against addresses)
```
SmartBox::RenderNormalMode (0x453aa0, line 92635)
└─ ALWAYS: RenderDevice::vtable->DrawInside(viewer_cell)
→ RenderDeviceD3D::DrawInside (0x59f0d0, line 427843)
→ PView::DrawInside(indoor_pview, viewer_cell) (0x5a5860, line 433793)
→ CEnvCell::curr_view_push(viewer_cell)
→ PView::add_views(this, cell->num_stabs, cell->stab_list)
→ Render::copy_view(cell->portal_view[-1], null, 4)
→ PView::ConstructView(this, viewer_cell, 0xffff) [CEnvCell overload, 0x5a57b0]
→ PView::DrawCells(this, result) (0x5a4840, line 432709)
├─ if outside_view.view_count > 0: LScape::draw(lscape) [terrain + sky]
└─ for each cell in cell_draw_list: draw portals, env geometry, objects
PView::DrawCells → LScape::draw (0x506330, line 267912)
→ GameSky::Draw
→ for each land block: RenderDeviceD3D::DrawBlock (0x5a17c0, line 430027)
→ DrawLandCell (terrain) ; DrawSortCell → DrawBuilding (0x59f2a0, line 427938)
outdoor_pview->outdoor_portal_list = building->portals <<< KEY
→ terrain BSP walk reaches BSPPORTAL leaves (magic "PORT" 0x504f5254):
BSPPORTAL::portal_draw_portals_only (0x53d870, line 326881)
→ for i in num_portals: RenderDevice::vtable->DrawPortal(in_portals[i], frame, 1)
→ RenderDeviceD3D::DrawPortal (0x59f0e0, line 427852)
→ PView::DrawPortal(outdoor_pview, portalPoly, ...) (0x5a5ab0, line 433895)
CBldPortal* bp = outdoor_portal_list[portalPoly->portal_index]
PView::add_views(this, bp->num_stabs, bp->stab_list)
PView::ConstructView(this, bp, portal, ...) [CBldPortal overload, 0x5a59a0]
viewpoint side-test vs portal plane
PView::GetClip(...) ; CEnvCell::GetVisible(bp->other_cell_id)
Render::copy_view(...)
PView::ConstructView(this, other_cell, bp->other_portal_id) [recurse]
if result: PView::DrawCells(this, ...) [draw that building's interior]
SmartBox::update_viewer (0x453ce0, line 92761)
→ compute pivot from part_array + camera_manager->pivot_offset
→ choose start cell: indoor (objcell low16 >= 0x100) → AdjustPosition(pivot); outdoor → player->cell
→ CTransition: init_object(player, 0x5c) ; init_sphere(1, viewer_sphere, 1.0) ; init_path(cell)
→ find_valid_position:
success → set_viewer(sphere_path.curr_pos, 0) ; viewer_cell = sphere_path.curr_cell
else AdjustPosition(sought_eye) → set_viewer ; viewer_cell = that cell
else set_viewer(player->m_position, 1) ; viewer_cell = null
NO snap / NO quantize / NO dead-zone. (The eye jitters anyway — see §3.4.)
```
### 3.3 Verbatim decomp excerpts (the load-bearing ones)
**(a) `RenderNormalMode` branch — the "always DrawInside" proof (lines 9263592702):**
```c
this = RenderDevice::render_device->m_bOpenScene;
if (this != 0) {
int32_t edi_2 = -((edi - edi)); // == 0 ALWAYS (xor edi,edi; neg edi)
int32_t ebx_1 = (edi_2 != 0 || this_1->viewer_cell->seen_outside != 0) ? 1 : 0;
// ... FOV ...
if (edi_2 == 0) { // ALWAYS taken — the INSIDE path
if (ebx_1 != 0) { // viewer cell can see outside →
uint32_t eax_1 = Position::get_outside_cell_id(&this_1->viewer);
LScape::update_viewpoint(this_1->lscape, eax_1); // aim terrain viewpoint outside
}
Render::update_viewpoint(&this_1->viewer);
RenderDevice::render_device_2->vtable->DrawInside(rd2, this_1->viewer_cell);
} else { // DEAD CODE — edi_2 is constant 0
LScape::update_viewpoint(...); Render::update_viewpoint(...);
Render::set_default_view(); Render::useSunlightSet(1);
LScape::draw(this_1->lscape);
}
}
```
**(b) `PView::DrawInside` (lines 433793433823) — how the indoor flood is set up:**
```c
void PView::DrawInside(PView* this, CEnvCell* arg2) {
CEnvCell::curr_view_push(arg2);
PView::add_views(this, arg2->num_stabs, arg2->stab_list);
Render::copy_view(arg2->portal_view.data[arg2->num_view - 1], null, 4);
edx_2 = PView::ConstructView(this, arg2, 0xffff); // flood from viewer_cell
PView::DrawCells(this, edx_2);
PView::remove_views(this, arg2->num_stabs, arg2->stab_list);
}
```
**(c) `PView::ConstructView(CEnvCell*, 0xffff)` (lines 433750433789) — the flood loop:**
```c
void PView::ConstructView(PView* this, CEnvCell* arg2, uint16_t arg3) {
this->outside_view.view_count = 0;
PView::master_timestamp += 1;
this->cell_todo_num = 0;
this->cell_draw_num = 0;
PView::InitCell(this, arg2, arg3);
PView::InsCellTodoList(this, arg2, 0.0);
while (this->cell_todo_num > 0) {
CEnvCell* cell = cell_todo_list.data[this->cell_todo_num - 1]->cell;
if (cell == 0) return;
this->cell_todo_num -= 1;
cell_draw_list.data[this->cell_draw_num++] = cell; // <- membership append
cell->portal_view.data[cell->num_view - 1]->cell_view_done = 1;
if (PView::ClipPortals(this, cell, 0) != 0) // clip → enqueue neighbours
PView::AddViewToPortals(this, cell);
}
}
```
**(d) Per-building portal loop — `BSPPORTAL::portal_draw_portals_only` (lines 326940326953):**
```c
// Reached at each BSPPORTAL leaf during the terrain BSP walk (front-to-back vs viewer):
int32_t i = 0;
if (this_1->num_portals > 0) do {
int32_t edx_4 = this_1->in_portals[i]; // CPortalPoly*
RenderDevice::render_device->vtable->DrawPortal(/*portal*/edx_4, /*frame*/arg2, /*mode*/1);
i += 1;
} while (i < this_1->num_portals);
```
…and `PView::DrawPortal` (lines 433895433933) looks up `outdoor_portal_list[portalPoly->portal_index]`,
`add_views`, then `ConstructView(CBldPortal*)` → if non-empty, `DrawCells` that building's interior.
**This is the ~7 `cv-bld` calls/frame we measured. Per-building, small, robust.**
**(e) `update_viewer` eye-set (lines 9276192892) — NO stabilization:** see the call graph §3.2.
The eye is the result of a per-frame `CTransition::find_valid_position` sweep from the pivot to the
sought eye. **No snap / quantize / dead-zone.** (The research agent *inferred* "stable because inputs
stable"; the LIVE trace contradicts that — the eye jitters ~36 µm — see §3.4. The agent did NOT trace
where `viewer_sought_position` is written; that is open trace #1 in §8.)
### 3.4 LIVE MEASUREMENTS (cdb on retail at the Holtburg cottage doorway, 2026-06-08)
Retail binary: `C:\Turbine\Asheron's Call\acclient.exe`, **MATCHES** our PDB
(`refs/acclient.pdb`, GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`). PID this session: 32360.
- **Membership at rest is STABLE.** Camera held still: `PView.cell_draw_num` settled to a long
unbroken run of **2** (brief `4` only at startup). `SmartBox.viewer_cell` pointer = **1 distinct
value** across the whole capture (byte-stable cell). Retail does NOT flap at rest.
- **Retail does PER-BUILDING floods.** `ConstructView(CBldPortal*)` (`0x5a59a0`) fired ~7×/frame,
each `cell_draw_num ≈ 2`. The `CEnvCell` overload (`0x5a57b0`) fired far less. Retail does NOT do
one unified flood.
- **Retail's EYE JITTERS ~36 µm at rest** — the decisive measurement. Reading
`SmartBox.viewer.frame.m_fOrigin` (raw float bits) with the camera held still:
```
pub=(431a51ab, 41d1d3c4, 42c0a914) x≈154.32 y≈26.23 z≈96.33 (raw IEEE-754 hex)
pub=(431a51ab, 41d1d3c1, 42c0a914)
pub=(431a51ac, 41d1d3bf, 42c0a914) ← X flips 1 ULP
pub=(431a51ab, 41d1d3cc, 42c0a915) ← Z flips 1 ULP
pub=(431a51ac, 41d1d3b9, 42c0a913) ← Y spans ~19 ULPs
```
Decoded jitter: **X ~15 µm, Y ~36 µm, Z ~8 µm.** `pub == sought` (eye uncollided at the open door,
so the jitter is the camera boom itself, not the collision sweep). **Retail's eye is NOT byte-stable.**
- **Compare to acdream** (measured earlier this session via `[pv-input]` at the same doorway): our eye
jitters **~1.3 mm in Y** (≈36× retail), our `RenderPosition` shows 15 distinct values at rest, our
membership oscillates (flood `8↔3`, `6↔3`, etc.). Our physics body (`rawPlayer`) IS byte-stable —
the jitter enters in the camera chain, NOT physics.
### 3.5 Struct offsets + symbols (from `flap-render-lookup.cdb` / `flap-pos-lookup.cdb`)
```
acclient!SmartBox::update_viewer @ 0x453ce0
acclient!SmartBox::RenderNormalMode @ 0x453aa0
acclient!SmartBox::is_player_outside @ 0x451e80
acclient!PView::ConstructView(CEnvCell*, ushort) @ 0x5a57b0
acclient!PView::ConstructView(CBldPortal*, CPolygon*,int,int) @ 0x5a59a0
acclient!PView::DrawInside(CEnvCell*) @ 0x5a5860
acclient!RenderDeviceD3D::DrawInside @ 0x59f0d0
struct PView:
+0x000 outside_view : portal_view_type
+0x048 draw_landscape : Int4B
+0x04c outdoor_portal_list : CBldPortal** (set per-building by DrawBuilding)
+0x050 cell_draw_list : DArray<CEnvCell*>
+0x060 cell_draw_num : Uint4B (THE membership count)
+0x064 cell_todo_list : DArray<CellListType*>
+0x074 cell_todo_num : Uint4B
+0x078 lscape : LScape*
struct SmartBox:
+0x008 viewer : Position (the published eye)
+0x050 viewer_cell : CObjCell* (the cell the eye occupies)
+0x058 viewer_sought_position : Position (pre-sweep desired eye)
+0x0f8 player : CPhysicsObj*
struct Position: +0x004 objcell_id:Uint4B +0x008 frame:Frame
struct Frame: +0x000 qw,qx,qy,qz:Float +0x010 m_fl2gv[9]:Float +0x034 m_fOrigin:Vector3
⇒ SmartBox.viewer.objcell_id = +0x0c ; viewer origin x/y/z = +0x44 / +0x48 / +0x4c
⇒ SmartBox.viewer_sought_position.origin = +0x94 / +0x98 / +0x9c
```
---
## 4. Our divergences (precise, with file:line)
| # | Divergence | Where (acdream) | Retail truth |
|---|---|---|---|
| D1 | **Inside/outside render branch** | `GameWindow.cs:7498` `if (clipRoot is not null){DrawInside}else{DrawPortal}`; root at `GameWindow.cs:7396` `clipRoot = viewerRoot ?? _outdoorNode` | No branch. Always `DrawInside(viewer_cell)`. |
| D2 | **Synthetic `_outdoorNode`** (outdoor-as-cell) as root when eye outside | `GameWindow.cs:7396`, `OutdoorCellNode.cs`, `PortalVisibilityBuilder.Build` `if (cameraCell.IsOutdoorNode)` (`PortalVisibilityBuilder.cs:88`) | Root is the real outdoor `CLandCell` the eye occupies. |
| D3 | **One unified flood** (`PortalVisibilityBuilder.Build` from one root) | `RetailPViewRenderer.DrawInside` → `PortalVisibilityBuilder.Build` (`RetailPViewRenderer.cs:43`); look-in via `DrawPortal` → `BuildFromExterior` (`RetailPViewRenderer.cs:92`) | Many small per-building floods via terrain BSP → `DrawPortal` → `ConstructView(CBldPortal)`. |
| D4 | **`MaxReprocessPerCell = 16` cap** (re-enqueue band-aid) | `PortalVisibilityBuilder.cs:51` | No cap; bounded structurally. (And: there is no re-enqueue *churn* — measured `maxPop=1`.) |
| D5 | **`EyeInsidePortalOpening` guard** (degenerate-portal hack) | `PortalVisibilityBuilder.cs` (`EyeInsidePortalOpening`, ~235244, 793833) | Retail's 3D clip needs no such special case. |
| D6 | **Reciprocal clip on `ProjectToNdc` not `ProjectToClip`** | `PortalVisibilityBuilder.ApplyReciprocalClip` (~697747) | acdream split to dodge drift. |
| D7 | **Render-position interpolation layer** (ours, not retail) | `PlayerMovementController.ComputeRenderPosition` (`PlayerMovementController.cs:810`) `Lerp(prev, curr, accumFrac)` | Retail renders at the authoritative position; the only nearby retail cite is the 30 Hz *physics* tick gate (`CPhysicsObj::update_object` :283950), NOT a render-interp. |
| D8 | **Camera boom ~36× looser than retail** | `RetailChaseCamera` (`RetailChaseCamera.cs`) damping + `ApplyConvergenceSnap` (SnapEpsilon 0.0004 m); collision sweep `PhysicsCameraCollisionProbe.SweepEye` | Retail boom jitters ~36 µm; no snap in `update_viewer`. SECONDARY — fix the structure first. |
D1D3 are the **primary** structural divergences Option A removes. D4D8 are accumulated band-aids /
secondary; most fall away once D1D3 are done, but each must be removed deliberately (each was added
to paper over a real problem — see §7 DO-NOT and the in-code comments).
---
## 5. The render pipeline as it exists today (so you know what you're rewriting)
- Entry: `GameWindow.cs` render loop, ~71807800. `RetailChaseCamera.Update` produces `Position`
(eye) + `ViewerCellId`. `viewerRoot` resolved ~72097211; `clipRoot = viewerRoot ?? _outdoorNode`
(7396). Branch at 7498: `DrawInside` (indoor/unified) vs `DrawPortal` (exterior look-in).
- `RetailPViewRenderer` (`src/AcDream.App/Rendering/RetailPViewRenderer.cs`):
`DrawInside(ctx)` → `PortalVisibilityBuilder.Build(rootCell, eye, lookup, viewProj)` (line 43);
`DrawPortal(ctx)` → `PortalVisibilityBuilder.BuildFromExterior(candidateCells, …)` (line 92).
Post-flood: `ClipFrameAssembler.Assemble`, then `DrawLandscapeThroughOutsideView`,
`DrawExitPortalMasks`, `DrawEnvCellShells`, `DrawCellObjectLists`.
- `PortalVisibilityBuilder` (`src/AcDream.App/Rendering/PortalVisibilityBuilder.cs`): the flood. Ports
`ConstructView`/`ClipPortals`/`AddViewToPortals` BUT as ONE flood with the `MaxReprocessPerCell`
cap, the `EyeInsidePortalOpening` guard, and the NDC reciprocal. `OutsideView` is the
terrain-through-door region. `IsOutdoorNode` special-cases the synthetic outdoor root.
- `PortalProjection` (`PortalProjection.cs`): `ProjectToClip` + `ClipToRegion` — **faithful** port of
retail `PView::GetClip` (`0x5a4320`/`:432344`) + `ACRender::polyClipFinish` (`:702749`, the w=0
clip). KEEP THIS — it is correct; the problem is never the clip math, it's what feeds it.
- `CellVisibility` (`CellVisibility.cs`): cell membership / `stab_list` / `seen_outside` / InsideSide
side-test — faithful to `CellManager::ChangePosition` (`0x4559B0`) + `grab_visible_cells` (`:311878`).
- Camera: `RetailChaseCamera.cs` (boom, `ApplyConvergenceSnap` from `d2212cf`),
`PhysicsCameraCollisionProbe.cs` (`SmartBox::update_viewer` sweep port), `CameraController.cs`
(picks RetailChaseCamera vs legacy `ChaseCamera`).
---
## 6. The design — Option A (phased; each phase conformance-tested + visual-gated)
> The fresh session should run `superpowers:writing-plans` to expand this into a task plan. The
> phases below are the architecture; the plan adds the bite-sized steps.
**Guiding invariant (retail):** every frame, root the render at the *real* cell the camera eye is
in (`viewer_cell`), and run ONE `DrawInside`. Outdoor terrain + per-building interiors are products
of that single path, not of a separate branch.
**Phase R-A1 — Collapse to one root, one path (remove D1 + D2).**
- Make `clipRoot` = the real cell the camera-collision sweep resolved (`RetailChaseCamera.ViewerCellId`
→ the actual `LoadedCell`, outdoor `CLandCell` or indoor `CEnvCell`). Delete the `?? _outdoorNode`
fallback and the `IsOutdoorNode` special-case in `PortalVisibilityBuilder`.
- Delete the `else { DrawPortal(...) }` branch (`GameWindow.cs:76137690`). One call site:
`DrawInside(viewer_cell)` every frame.
- Requires: an outdoor `CLandCell` must be a valid `DrawInside` root whose flood immediately "sees
outside" (`OutsideView` full) so terrain draws. This is the retail behavior (`viewer_cell` is a
land cell when outside). **Open design point:** acdream's `LoadedCell` model may not currently
represent the outdoor land cell as a floodable cell — see open trace #3 (§8). Resolve before coding.
- Conformance: at the doorway, the frame *before* and *after* crossing the threshold run the same
code path; `[pv-input]` `outRoot` stops toggling (there is no outRoot concept anymore).
**Phase R-A2 — Per-building floods (remove D3).**
- Replace the single unified `Build` (when looking at buildings from outside) with retail's
per-building constructions: during the terrain/landblock draw, for each visible building portal,
run a small `ConstructView` rooted at that building portal (the `CBldPortal` overload), flooding
only that building's cells. Port `BSPPORTAL::portal_draw_portals_only` (`0x53d870`) →
`PView::DrawPortal` (`0x5a5ab0`) → `ConstructView(CBldPortal)` (`0x5a59a0`).
- This is the **robustness mechanism** (small coarse per-building visibility absorbs eye jitter).
- Conformance: capture `cell_draw_num` per building ≈ 2 (matches retail §3.4); membership stable as
the (jittering) eye moves within a cell.
**Phase R-A3 — Remove the band-aids (D4, D5, D6) made dead by R-A1/R-A2.**
- With per-building bounded floods, `MaxReprocessPerCell` (D4), `EyeInsidePortalOpening` (D5), and the
NDC reciprocal (D6) should be removable. Remove each deliberately, re-running the conformance + the
existing `PortalVisibilityBuilderTests`. Do NOT remove `ProjectToClip`/`ClipToRegion` (faithful).
**Phase R-A4 (optional, secondary) — Tighten the camera boom toward retail (D8); reconsider the
render-position interpolation (D7).**
- Only if, after R-A1R-A3, residual flicker remains AND it correlates with eye jitter > retail's
~36 µm. Match retail's boom damping/snap. Do NOT chase byte-stable (retail isn't). Treat the
render-position interpolation as suspect but DO NOT rip it out blindly (it prevents 30 Hz judder;
removing it regressed the branch last time — see §7).
**Testing strategy (critical — this is how we stop shipping unverified changes):**
- **Conformance tests against the measured retail values in §3.4** (cell_draw_num per building ≈ 2,
membership stable under eye jitter, one path across the threshold). These run WITHOUT the live
client.
- Keep all existing `PortalVisibilityBuilderTests` green where still applicable.
- Keep the 14 `PlayerMovementControllerTests` green.
- **Visual gate is the acceptance test** (the user at the doorway). But the conformance tests are the
PRE-gate — never ship to the visual gate on a red/absent conformance test again.
- Re-attach cdb to retail to capture any NEW retail value the implementation needs (the workflow in
§7 is proven and fast).
---
## 7. DO NOT RETRY (every one of these is evidence-disproven — re-trying wastes days)
- **"Make the eye byte-stable at rest."** Retail's eye jitters ~36 µm (§3.4, MEASURED). Byte-stable
is the wrong target. My render-position rest-snap fix this session did this, **failed (no change)
AND regressed the inside/outside flap**, and was reverted (`cd974b2` → revert `9b1857a`). The
jitter source is also NOT `RenderPosition` (stabilizing it changed nothing) — it is downstream in
the camera boom / sweep. Don't re-snap RenderPosition.
- **"Bound the portal re-enqueue churn" / bounded-propagation / enqueue-once.** There is **no churn**:
measured `maxPop = 1` across 13k oscillating frames; 0 of 63k reciprocals ever clipped empty
(`ACDREAM_PROBE_PORTAL_CHURN`, this session). The whole bounded-propagation plan
(`docs/superpowers/plans/2026-06-08-portal-flood-bounded-propagation.md` + the
`2026-06-08-portal-flood-enqueue-once-port-design.md` spec) is REFUTED by measurement. The
apparatus commits (`687040b`, `e6fe4c6`, `a866c51`) are fine to keep as probes; the *fix premise*
is dead.
- **Physics rest µm-jitter** (`d6aa526` era). Refuted: 216k standstill records, 0 re-snaps; body
byte-stable. 4 GREEN rest-stability tests prove it.
- **Camera-drift / viewer-cell metastability dead-zone** (2026-06-05 3-part plan). The dead-zone is
±0.2 mm in `point_inside_cell_bsp`; the eye crosses by metres, not sub-mm — irrelevant to this
symptom. The boom snap (Part 1, `d2212cf`) is already shipped and KEPT.
- **Two-pipe inside/outside split** — that was the ORIGINAL approach, abandoned 2026-05-30. Do not
resurrect it. Retail has neither two pipes NOR a branch — it has ONE path (§3.1).
- **Render-side debounce/grace/hysteresis on the branch or the clip** — forbidden band-aid
(`feedback_no_workarounds`).
- **Trusting a decomp INFERENCE about runtime behavior without a live trace.** This session burned a
fix on the inference "RenderPosition jitter → eye jitter." The cdb-on-retail workflow (§ below) is
the antidote: MEASURE, don't infer.
---
## 8. OPEN TRACES — finish these BEFORE writing the implementation plan
The oracle is ~90% complete. Three things must be traced/decided first (each is a focused cdb capture
and/or decomp read; the workflow below makes each ~10 min):
1. **Where `SmartBox::viewer_sought_position` is written** (the camera boom that produces the
~36 µm-jittering sought eye). The decomp agent did NOT find the write site (it's in the
`camera_manager` / spring-arm chain). Trace it (cdb bp on writes to `SmartBox+0x58`, or read
`CameraManager` methods) to know exactly how tight retail's boom is and what to match in D8.
2. **`PView::ClipPortals` (`0x5a4...`) and `PView::AddViewToPortals` (`0x5a52d0` :433446)** —
the per-cell flood propagation. Not yet read in detail. Needed for a faithful per-building
`ConstructView` port (Phase R-A2). Read both.
3. **How retail's `DrawInside`/`ConstructView` handle a `CLandCell` (outdoor) `viewer_cell`** — i.e.
the pure-outdoor and outside-looking-in root. Confirm the outdoor land cell floods such that
`outside_view` is full and per-building portals render via the terrain BSP. AND decide how
acdream's `LoadedCell`/cell model represents the outdoor land cell as a floodable root (Phase R-A1
open design point). This is the single biggest unknown for the rewrite.
Also nice-to-have: `viewer_sphere` radius used in `update_viewer` (the agent didn't look it up; our
port uses 0.3 m — `PhysicsCameraCollisionProbe.ViewerSphereRadius`).
---
## 9. Apparatus (this session — REUSE IT, don't rebuild it)
**Retail-debugger toolchain (PROVEN this session):**
- Binary: `C:\Turbine\Asheron's Call\acclient.exe` — verified pairs with `refs/acclient.pdb`
(`py tools/pdb-extract/check_exe_pdb.py "C:/Turbine/Asheron's Call/acclient.exe"` → MATCH).
- cdb: `C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe`.
- Attach + capture pattern (background, tee to a log):
```powershell
& "C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe" -p <PID> -cf <script>.cdb *>&1 |
Tee-Object -FilePath <log>
```
- **Watchouts learned this session:** (1) inside a bp action use **`.detach`**, NOT `qd` (cdb ignores
`qd` in bp actions — per `retail-viewer-cell.cdb`); top-level `qd` is fine. (2) The captures exit
with **code 5** but still produce the full log — exit 5 is expected here, not a failure. (3) The
bps fire every render frame regardless of camera motion, so a counter-bounded capture auto-completes
fast; cue the user to be doing the thing (sweeping) the whole window, or just capture the still pose.
(4) Read floats as raw hex (`%08x` of `poi(addr)`) to see byte-stability directly. (5) `dt
acclient!Type @ecx field` reads a named field with no manual offset.
**cdb scripts created (in `tools/cdb/`):**
- `flap-render-lookup.cdb` — symbol + PView/SmartBox type dump (the source of §3.5).
- `flap-pos-lookup.cdb` — Position/Frame type dump (eye-origin offsets).
- `flap-render-capture.cdb` — per-frame `cell_draw_num` + `viewer_cell` ptr (both ConstructView
overloads). The membership-stability capture. (`.logopen` path is hardcoded — edit per run.)
- `flap-eye-stability.cdb` — per-frame eye origin (raw hex) pub + sought. The ~36 µm finding.
**Capture logs (worktree root, UNTRACKED — large; gitignore or delete after):**
- `flap-render-retail.log` (still pose), `flap-render-retail-sweep.log`, `flap-eye-stability.log`,
`flap-render-lookup.log`, `flap-pos-lookup.log`, and the `*-cdb-console.log` files.
**acdream-side probes (still in code, useful for the diff):**
- `ACDREAM_PROBE_PVINPUT=1` → `[pv-input]` (eye/player/rawPlayer/yaw + flood count, 6dp) — the acdream
eye/membership signal. `launch-flap-verify.ps1` runs it light.
- `ACDREAM_PROBE_PORTAL_CHURN=1` → `[portal-churn]` (per-Build re-enqueue + reciprocal pre/post) — the
apparatus that proved churn=0. Heavy (1.5 KB/line) — lags the client; use sparingly.
- `ACDREAM_PROBE_FLAP=1` → `[flap]`/`[flap-cam]`/`[flap-sweep]`/`[pv-trace]` — per-frame side-test +
projection. Heavy.
- All gated in `AcDream.Core.Rendering.RenderingDiagnostics` (and `PhysicsDiagnostics`).
- **These are throwaway apparatus.** Strip them once the port ships and is visual-gated.
**Launch scripts:** `launch-flap-verify.ps1` (light pv-input), `launch-flap-churn.ps1`,
`launch-flap-capture.ps1`. acdream live-launch env vars: see CLAUDE.md "Running the client".
---
## 10. Git state & test baseline (start point for the fresh session)
- Branch `claude/thirsty-goldberg-51bb9b`, HEAD **`9b1857a`** = revert of the failed rest-snap fix.
Working tree is at the **known-good baseline** (the cutover-flip state: the *huge* inside↔outside
flap is GONE; only the grazing-doorway flicker residual remains).
- This session's commits (newest first): `9b1857a` (revert) ← `cd974b2` (the bad rest-snap fix,
reverted) ← `b3a9884` (launch-flap-churn.ps1) ← `a866c51` (churn anchor test) ← `e6fe4c6`
(churn probe) ← `687040b` (churn flag) ← `a3dadbf` (bounded-propagation plan — now REFUTED) ←
`ab6ed90`/`6c3a96b`/`d6aa526`/… (the refuted-diagnosis trail).
- **The churn probe (`687040b`/`e6fe4c6`/`a866c51`) is fine to keep** (inert when off; it's the
apparatus that disproved the churn hypothesis). The bounded-propagation *plan/spec* are refuted —
mark them superseded by this handoff.
- Test baseline (post-revert, verified): `PlayerMovementControllerTests` 14/14 green;
`PortalVisibilityBuilderTests` (App) green; build green. (Full-suite has documented static-leak
flakiness — run targeted.)
- The two NEW tests I added for the rest-snap were removed by the revert (correct — the fix was wrong).
---
## 11. How the fresh session should start (concrete)
1. **Read this whole doc**, then §3 (the oracle) again. Read the two memory entries
`project_indoor_flap_rootcause` (will be updated to point here) and
`reference_render_pipeline_state`.
2. **Confirm the baseline:** `git log --oneline -3` (HEAD `9b1857a`); `dotnet build` green;
the targeted tests green.
3. **Close the §8 open traces** (viewer_sought_position write site; ClipPortals/AddViewToPortals;
the outdoor `CLandCell` root). Use the §9 cdb workflow on live retail if the user can run it; else
decomp-read. **Do not start the implementation plan until these three are answered** — they
determine the Phase R-A1 design (especially how to represent the outdoor land cell as a floodable
root).
4. **`superpowers:writing-plans`** → expand §6 into a phased task plan (R-A1 → R-A4) with conformance
tests against §3.4's measured values.
5. Implement phase-by-phase; conformance test PRE-gate; visual gate is acceptance; cdb-measure any new
retail value you need. **Never ship to the visual gate on an unverified change again.**
6. When it lands and is visual-verified: strip the apparatus (§9 probes), update the roadmap +
milestones (M1.5), update memory, mark the flap CLOSED.
---
## 12. The one-sentence version (for when you're tired)
Retail draws inside and outside with **one path rooted at whatever cell the eye is in**, and it
survives a jittering eye because its **per-building visibility is coarse and robust** — so stop
patching the noise, delete our inside/outside branch and unified flood, and **build the one retail
path.**