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

34 KiB
Raw Blame History

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

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:

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:

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

// 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.DrawInsidePortalVisibilityBuilder.Build (RetailPViewRenderer.cs:43); look-in via DrawPortalBuildFromExterior (RetailPViewRenderer.cs:92) Many small per-building floods via terrain BSP → DrawPortalConstructView(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 + ClipToRegionfaithful 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):
    & "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.