diff --git a/CLAUDE.md b/CLAUDE.md index 9a72d3b8..1c3c2e9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -763,6 +763,28 @@ 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-08 (evening) — FLAP ROOT CAUSE SETTLED BY LIVE-RETAIL MEASUREMENT; full retail render +port DECIDED (Option A). READ THIS FIRST — it supersedes EVERY flap banner below, including the +bounded-propagation/churn direction (REFUTED by measurement: `maxPop=1`, 0 churn).** We attached cdb +to the **live 2013 retail client** at the Holtburg doorway + read the decomp. Findings (measured, not +inferred): **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` (outdoor `CLandCell` → indoor `CEnvCell`); same path before/after the +threshold → no seam → no flap. **Retail's eye JITTERS ~36 µm at rest** (so a byte-stable eye is the +WRONG target — my render-position rest-snap fix `cd974b2` failed + regressed, reverted `9b1857a`); +retail's membership is stable anyway because it does **many small per-building floods** (~7/frame, +~2 cells each, via the terrain BSP → `DrawPortal` → `ConstructView(CBldPortal)`), not one giant +unified flood. **Our 3 divergences:** (D1) we invented an inside/outside branch +(`GameWindow.cs:7498`, `clipRoot = viewerRoot ?? _outdoorNode` :7396); (D2) a synthetic `_outdoorNode`; +(D3) one unified flood. **Decision (user-approved): Option A — rip out the branch + outdoor node, root +always at the real `viewer_cell`, one `DrawInside`, per-building rendering.** DO NOT retry: byte-stable +eye, bounded-propagation/churn, physics rest-jitter, viewer-cell dead-zone, two-pipe split (all +evidence-disproven). **CANONICAL PICKUP (exhaustive — read top-to-bottom before any code):** +[`docs/research/2026-06-08-full-retail-render-port-OPTION-A-handoff.md`](docs/research/2026-06-08-full-retail-render-port-OPTION-A-handoff.md). +Close its §8 open traces (viewer_sought_position write site; ClipPortals/AddViewToPortals; how +`DrawInside` handles an outdoor `CLandCell` root) BEFORE writing the implementation plan. + **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 diff --git a/docs/research/2026-06-08-full-retail-render-port-OPTION-A-handoff.md b/docs/research/2026-06-08-full-retail-render-port-OPTION-A-handoff.md new file mode 100644 index 00000000..4ab224eb --- /dev/null +++ b/docs/research/2026-06-08-full-retail-render-port-OPTION-A-handoff.md @@ -0,0 +1,545 @@ +# 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 92635–92702):** + +```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 433793–433823) — 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 433750–433789) — 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 326940–326953):** + +```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 433895–433933) 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 92761–92892) — 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 + +0x060 cell_draw_num : Uint4B (THE membership count) + +0x064 cell_todo_list : DArray + +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`, ~235–244, 793–833) | Retail's 3D clip needs no such special case. | +| D6 | **Reciprocal clip on `ProjectToNdc` not `ProjectToClip`** | `PortalVisibilityBuilder.ApplyReciprocalClip` (~697–747) | 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. | + +D1–D3 are the **primary** structural divergences Option A removes. D4–D8 are accumulated band-aids / +secondary; most fall away once D1–D3 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, ~7180–7800. `RetailChaseCamera.Update` produces `Position` + (eye) + `ViewerCellId`. `viewerRoot` resolved ~7209–7211; `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:7613–7690`). 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-A1–R-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 -cf