acdream/docs/research/2026-06-07-render-unification-cutover-flip-handoff.md
Erik 9bc0db9351 docs: handoff — render unification CUTOVER FLIP (canonical pickup)
Super-detailed pickup for the one remaining step that fixes the flap: the cutover
flip (terrain via OutsideView for the outdoor root + clipRoot=viewerRoot??_outdoorNode
+ launch + visual gate + delete old paths). Exact steps, current line numbers, the
de-risking already done (shell no-op, flood validated, OutsideView mechanism), the
4 render cases, the Step-B integration checklist, do/don't, and a kickoff prompt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:42:04 +02:00

19 KiB
Raw Blame History

Handoff — Render Unification CUTOVER FLIP (the one step that fixes the flap) — 2026-06-07

CANONICAL PICKUP. Read this first. Worktree thirsty-goldberg-51bb9b, branch claude/thirsty-goldberg-51bb9b. PowerShell on Windows; launch logs are UTF-16; build before launch; acceptance is the user's eyes at the Holtburg/Arcanum cottage. Do NOT branch/worktree, push, git stash/gc, or revert the dirty tree (it has pre-existing untracked files — leave them). Live ACE 127.0.0.1:9000, testaccount/testpassword, char +Acdream (spawns at the cottage, landblock 0xA9B4, cottage cells 0xA9B4016F0175, outdoor cell id near spawn 0xA9B40031).


0. TL;DR — you are ONE step from fixing the flap

The indoor render FLAP (textures "battle"/oscillate at every transition) is the two-branch render split (OutdoorRoot vs RetailPViewInside) toggling as the 3rd-person eye crosses the indoor/outdoor boundary. The fix (user-approved): make the outdoor world a flood-graph cell so there is one render path (retail's DrawInside(viewer_cell)), with no branch to flip.

~70% is built, validated, and committed. The remaining step is the CUTOVER FLIP: root the one draw path at the viewer cell (the outdoor node when the eye is outdoors), make terrain draw via the existing OutsideView mechanism, then launch → user visual gate → delete the dead old paths. This doc gives the exact, de-risked steps. Do the flip with adequate context headroom — it is coordinated surgery ending at a launch + visual gate, and a first attempt rarely renders right. Rushing a render change before a visual gate is how the dead-zone regression happened on the morning of 2026-06-07.


1. State — what is committed (branch HEAD 7b3091c)

Commit What
bb64a67 Spec: docs/superpowers/specs/2026-06-07-render-unification-outdoor-as-cell-design.md
06666b7 Plan: docs/superpowers/plans/2026-06-07-render-unification-outdoor-as-cell.md (Progress section is current)
2a2cc97 Task 1OutdoorCellNode.Build (src/AcDream.App/Rendering/OutdoorCellNode.cs) + 2 tests
c5b4f77 Task 3 — outdoor-root flood VALIDATED (tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs)
d01fe30 Task 2 — outdoor node built live each frame, additive (_outdoorNode in GameWindow.cs)
7b3091c plan progress (cutover de-risked)

Baselines (MUST hold): build 0 errors; App.Tests 214 pass; Core.Tests 1331 pass / 4 fail (pre-existing door/step-up: 2× DoorBugTrajectoryReplay LiveCompare, BSPStepUpTests.D4, DoorCollisionApparatus) / 1 skip. Tree: no uncommitted tracked changes; pre-existing untracked files (*.txt/*.png/*.jsonl/*.py/*.log/*.ps1, lip-cells/) are NOT ours — leave them.

Verify on pickup: git log --oneline -6 shows the above; dotnet build -c Debug green; dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj -c Debug → 214/0.


2. Why this design (don't relitigate — these are evidence-disproven)

The flap was pinned 2026-06-07 with live ACDREAM_PROBE_FLAP [render-sig]. The branch at GameWindow.cs:7384-7388 picks the path:

bool playerIndoorGate = RenderingDiagnostics.ShouldRenderIndoor(playerCellId, playerRoot is not null);
var clipRoot = playerIndoorGate && viewerRoot is not null ? viewerRoot : null;   // line 7387
string renderBranch = clipRoot is null ? "OutdoorRoot" : "RetailPViewInside";

viewerRoot is null when the eye is outdoors → clipRoot null → OutdoorRoot. The two branches draw differently (terrain full vs door-clipped; 4 look-in cells vs 6 flood cells; depth-clear on/off), so the eye crossing the boundary toggles them → the flap. When the eye stays indoor (01700171) BOTH draw the same 6 cells → no flap — proving it's specifically the indoor/outdoor branch switch.

DO NOT retry (all failed/dead-ends, with evidence):

  • Viewer-cell dead-zone (±0.2 mm in PointInsideCellBsp): the eye crosses by METRES; zero effect; it REGRESSED the cellar roof (shifted the flood root via the pick). Reverted 2a2cc97's predecessor.
  • Gating the branch on the PLAYER cell: documented dead-end at GameWindow.cs:7207-7211 — forcing an indoor draw while the camera is outside "drops the outdoor pass and leaves clear color around a floating doorway slice." When the eye is genuinely outside, the outdoor view IS correct.
  • Render-side debounce/grace on the branch: forbidden (no-workarounds rule).
  • Part 1 (camera boom snap, d2212cf) + Part 3 (w-space portal clip, ProjectToClip/ClipToRegion) are ALREADY shipped — the 2026-06-05 3-part viewer-cell-stability plan is exhausted.

Full root-cause memory: project_indoor_flap_rootcause. Retail oracle: SmartBox::RenderNormalMode (0x00453aa0, pc:92635) → RenderDeviceD3D::DrawInside (0x0059f0d0) → PView::DrawInside (0x005a5860, pc:433793). Retail ALWAYS calls DrawInside(viewer_cell); the outdoor world is a cell whose stab list carries the landscape. ONE path, no inside/outside branch.


3. What's already built + VALIDATED (so you trust it)

  • OutdoorCellNode.Build(uint outdoorCellId, IReadOnlyList<LoadedCell> nearbyBuildingCells) (src/AcDream.App/Rendering/OutdoorCellNode.cs) → a LoadedCell with WorldTransform=Identity, SeenOutside=true, and Portals/ClipPlanes/PortalPolygons that point BACK into each building cell (reverse of the building's OtherCellId==0xFFFF exit portal; entrance polygon → world space; InsideSide flipped). Unit-tested.
  • The flood roots at the outdoor node with ZERO production changes (Task 3, UnifiedFloodTests.cs): PortalVisibilityBuilder.Build(node, eye, lookup, viewProj) returns the node
    • the buildings reached through its portals; the outdoor↔building cycle terminates (existing queued HashSet + MaxReprocessPerCell). This is the de-risk: the core hypothesis is proven.
  • _outdoorNode is built live each outdoor frame (Task 2, GameWindow.cs just before the branch, ~line 7360) from nearby building cells (Chebyshev ≤1 landblocks). It is NOT yet consumed (behaviour unchanged). An [outdoor-node] probe (under ACDREAM_PROBE_FLAP) prints cell=0x.. nearbyCells=N portals=M.

4. THE FLIP — exact steps (in order). Each builds green; the launch is the gate.

Pre-flight (do FIRST — confirms the node finds real entrances)

Launch with ACDREAM_PROBE_FLAP=1 (see §6), stand at the cottage, read the log:

Get-Content launch-*.log | Select-String "outdoor-node" -SimpleMatch | Select-Object -Last 5

Expect portals=M with M ≥ 1 when standing outside near the cottage (the node found the cottage's exit portals). If portals=0 everywhere, STOP — the nearby-building enumeration or the exit-portal detection is wrong; fix that before flipping (the flip is pointless if the node has no doorways).

Step A — terrain for the outdoor-ROOT case (the only genuinely new draw code)

Indoor→outdoor terrain ALREADY works via the OutsideView→terrain-slice path (RetailPViewRenderer.DrawInside line 79 → DrawLandscapeThroughOutsideView line 138; the assembler turns pvFrame.OutsideView.Polygons into OutsideViewSlices at ClipFrameAssembler.cs:134-165; outdoorVisible = OutsideViewSlices.Length > 0 → terrain draws). The ONLY new piece: when Build is rooted at the outdoor node, outdoors is visible full-screen, so add a full-screen region to frame.OutsideView.

In PortalVisibilityBuilder.Build (src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:63), right after the root is seeded full-screen (frame.CellViews[cameraCell.CellId] = CellView.FullScreen(), ~line 77), add:

// Render unification: an OUTDOOR root (synthetic outdoor node, low cell id < 0x100) sees outdoors
// FULL-SCREEN. Feed that to OutsideView so DrawLandscapeThroughOutsideView draws the landscape as the
// node's shell (full-screen here; the doorway region when an interior root reaches outdoors via an exit
// portal — that path already exists at the OtherCellId==0xFFFF branch below).
if ((cameraCell.CellId & 0xFFFFu) < 0x0100u)
    AddRegion(frame.OutsideView, /* full-screen region */);

You must confirm the exact full-screen call. Read CellView.FullScreen() and AddRegion(...) in PortalView.cs / PortalVisibilityBuilder.cs. AddRegion(CellView, List<...>) takes a region (list of NDC polygons); the root seed uses CellView.FullScreen(). The full-screen NDC quad is [(-1,-1),(1,-1),(1,1),(-1,1)]. Use whatever representation AddRegion/CellView expects (mirror how CellView.FullScreen() builds its polygon). ClipFrameAssembler handles a screen-covering OutsideView poly as either 4 edge planes (clips nothing) or cps.Count==0 → scissor fallback (full-screen) — both yield terrainMode != Skip → terrain draws everywhere. Either is fine.

Alternative if the OutsideView call proves fiddly (fallback, less unified but lower-risk): in GameWindow, when clipRoot is the outdoor node, draw terrain full-screen BEFORE DrawInside (the way the old else block does at the current _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb) call), and let DrawInside draw only the flooded building shells. Prefer the OutsideView approach (one mechanism); use this only if blocked.

Build green. (No behaviour change yet — nothing roots at the node until Step B.)

Step B — flip the routing (the behaviour change)

At GameWindow.cs:7387 replace the gate so the eye's cell always roots the one path:

// Render unification: ONE path rooted at the viewer cell. Eye indoors → its interior cell; eye
// outdoors → the synthetic outdoor node (built above). No inside/outside branch → no flap.
var clipRoot = viewerRoot ?? _outdoorNode;

i.e. drop playerIndoorGate && and fall back to _outdoorNode. Keep renderBranch for the probe (clipRoot is null ? "OutdoorRoot" : "RetailPViewInside" — now OutdoorRoot only when _outdoorNode is also null, e.g. legacy camera). The else (outdoor) block becomes dead when clipRoot is non-null — leave it for now (delete in Step D after the visual gate).

The 4 cases this produces (all one path, no flap):

  • player out / eye out → root = node → terrain full + flood into visible buildings (the look-in). ✓
  • player in / eye in → root = interior cell → flood + terrain through door (as today). ✓
  • player in / eye out (the flap case) → root = node → terrain + flood into the building incl. the player's cell. Same path as case 1 → no flap. ✓
  • player out / eye in (eye pokes through a doorway) → root = interior cell → drawn from inside. ✓

Step B integration checklist (verify each — these are where it can "screw up")

  • ComputeVisibilityFromRoot(viewerRoot, ...) at GameWindow.cs:7204 returns null for a null root. After the flip you pass clipRoot (= node) into DrawInside via RootCell, but the separate visibility = ComputeVisibilityFromRoot(viewerRoot, ...) call still uses viewerRoot (the interior one). Decide: either also feed the node to that call, or confirm cameraInsideCell/rootSeenOutside still behave. rootSeenOutside = viewerRoot?.SeenOutside ?? true (line 7211) → with the node it'd be true (node.SeenOutside) IF you point it at the node; with the interior viewerRoot (null outdoors) it's true. Either way renderSky (line 7314 viewerRoot is null || rootSeenOutside) stays true outdoors. Verify sky still draws outdoors after the flip.
  • DrawInside is rooted at clipRoot (RetailPViewDrawContext.RootCell = clipRoot, line 7455) — already correct; it just now receives the node sometimes.
  • Shell pass is a safe no-op for the node (DrawEnvCellShells_envCells.Render(pass, {nodeId}) renders nothing for an id with no prepared EnvCell geometry, RetailPViewRenderer.cs:190-202). No exclusion needed — confirmed.
  • PrepareRenderBatches(filter: drawableCells) will include the node id; it should no-op for an unknown EnvCell id. Confirm no throw.
  • Entities: InteriorEntityPartition.Partition(drawableCells, ...) with the node id in the set — outdoor scenery/buildings are entities; confirm they still draw (membership-gated). The old else block drew outdoor entities via _interiorRenderer.DrawEntityBucket(... outdoorPartition.Outdoor ...) — make sure outdoor entities still draw under the unified path (they may need the node id in their membership, or a dedicated outdoor bucket draw inside the DrawInside path).

Step C — BUILD → LAUNCH → USER VISUAL GATE (do not skip; do not delete anything yet)

dotnet build -c Debug green, then launch (ACDREAM_PROBE_FLAP=1, §6). Hand to the user at the cottage: walk in/out, pan the camera at the threshold, cellar down/up, look at the cottage from outside. Acceptance: no flap; no missing wall/roof textures; terrain + sky correct; no see-through walls; pure-outdoor FPS unchanged. Capture [render-sig]: branch should be RetailPViewInside continuously (no OutdoorRoot toggling) and viewerCell/draw transition cleanly with no 4↔6 cell-set jump. If broken, iterate Steps A/B — do NOT proceed to deletes.

Step D — only AFTER the user confirms: delete the dead paths (Task 7 + Phase 4)

Delete PortalVisibilityBuilder.BuildFromExterior; RetailPViewRenderer.DrawPortal; the dead else (outdoor) block in GameWindow (the look-in enumeration + _exteriorPortalCandidateCells plumbing + DrawPortal call); and, if now unused, the OutsideView-only helpers. Reconcile the [render-sig] probe (GameWindow.cs:~9039-9082) to the single path (drop extPortal/extIds/outdoorRoot*). Build green; tests baseline. Update memory project_indoor_flap_rootcause + reference_render_pipeline_state

  • the roadmap/milestones with the shipped outcome. Commit per step.

5. Pure-outdoor regression guard (spec §10 — don't skip)

The open-world case (no building in view) MUST stay byte-identical to today: full-screen terrain, no clip. After Step A/B, when the outdoor node has zero portals (no building nearby), the flood is just {node} and OutsideView is the full-screen region → terrain draws full-screen, no interior cells → same as today. Add/keep a unit test asserting: Build(emptyPortalNode, ...)OrderedVisibleCells == {node} and OutsideView is full-screen (so terrainMode != Skip). Visual-gate the open field too, not just the cottage.


6. Launch (PowerShell; UTF-16 log; background)

$env:ACDREAM_DAT_DIR   = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE      = "1"; $env:ACDREAM_TEST_HOST = "127.0.0.1"; $env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"; $env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_PROBE_FLAP = "1"   # ONLY this probe. NOT ACDREAM_PROBE_SHELL (it stalls on I/O).
dotnet build -c Debug   # MUST be green before launch
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch-flip.log"

Run in the background; give it ~12 s to reach in-world. Read with Get-Content launch-flip.log -Tail N and Select-String. The client exits cleanly (exit 0) when the user closes the window → ACE session clears. Probes: [outdoor-node] (node portal count), [render-sig] (branch/viewer/player/draw/miss per frame), [flap-sweep] (camera sweep). RLE the viewerCell/branch sequences to check for clean monotonic transitions vs toggling.


7. Files & anchors (current line numbers, HEAD 7b3091c)

  • GameWindow.cs: _outdoorNode field + helpers ~line 188; node build ~7355-7380 (before the branch); viewerRoot/viewerCellId resolve 7192-7204; clipRoot/renderBranch 7384-7388 (the flip); indoor DrawInside block 7448-7515; dead-after-flip else (outdoor) block 7516-7614 (terrain _terrain?.Draw ~7425, look-in DrawPortal ~7570); DrawRetailPViewLandscapeSlice ~9239; [render-sig] emit ~9039-9082.
  • RetailPViewRenderer.cs: DrawInside 39; DrawPortal 88 (delete in D); DrawLandscapeThroughOutsideView 138; DrawEnvCellShells 180 (node no-op); shells use _envCells.Render(pass, {id}).
  • PortalVisibilityBuilder.cs: Build 63 (root seed ~77 → add full-screen OutsideView for outdoor root); exit-portal branch 234 (OtherCellId==0xFFFFAddRegion(frame.OutsideView, ...) — the indoor→outdoor path that already works); BuildFromExterior 339 (delete in D); CameraOnInteriorSide 664.
  • ClipFrameAssembler.cs: Assemble 78; OutsideView→slices 134-165 (outdoorVisible = slices.Length>0).
  • OutdoorCellNode.cs: Build. CellVisibility.cs: LoadedCell (class, CellId field line 29), CellPortalInfo/PortalClipPlane, TryGetCell 276, GetCellsForLandblock 266 (returns IReadOnlyList<LoadedCell>), ComputeVisibilityFromRoot 338 (null root → null).

8. DO / DON'T

DO: flip in order A→B→C (gate)→D; build green between steps; verify [outdoor-node] portals≥1 BEFORE flipping; keep the else block until the user confirms; keep the pure-outdoor case byte-identical. DON'T: retry dead-zone / player-cell branch-gating / debounce (§2); delete the old paths before the visual gate; switch the FLOOD root to the player cell (root at the VIEWER cell — the node when eye outdoors); use ACDREAM_PROBE_SHELL (I/O stall); rush the flip on low context (visual-gated render surgery — the dead-zone regression came from exactly that).


9. Copy-paste kickoff prompt

Continue acdream M1.5 render unification: do the CUTOVER FLIP that fixes the indoor FLAP. Worktree
thirsty-goldberg-51bb9b, branch claude/thirsty-goldberg-51bb9b. PowerShell; launch logs UTF-16; build
before launch; acceptance is the user's eyes at the Holtburg cottage. Do NOT branch/worktree, push,
git stash/gc, or revert the dirty tree.

READ FIRST: docs/research/2026-06-07-render-unification-cutover-flip-handoff.md (THIS doc — exact steps,
de-risking, do-not list). Then the spec (2026-06-07-render-unification-outdoor-as-cell-design.md) and the
plan Progress section (2026-06-07-render-unification-outdoor-as-cell.md).

State: ~70% built + validated (HEAD 7b3091c). Outdoor node builder (2a2cc97), outdoor-root flood proven
with zero prod changes (c5b4f77), node built live each frame (d01fe30). Baselines: App 214, Core 1331/4/1,
build green.

DO THE FLIP (handoff §4), in order, building green between steps: A) feed a full-screen region to
frame.OutsideView when Build roots at the outdoor node ((CellId & 0xFFFF) < 0x100) so terrain draws
full-screen — confirm the exact CellView.FullScreen()/AddRegion call; B) at GameWindow.cs:7387 flip to
`clipRoot = viewerRoot ?? _outdoorNode` (drop the playerIndoorGate gate) — work the Step-B integration
checklist (sky, ComputeVisibilityFromRoot, outdoor entities); C) build → launch (ACDREAM_PROBE_FLAP only)
→ USER VISUAL GATE at the cottage; D) ONLY after the user confirms, delete BuildFromExterior/DrawPortal/
the dead else block/OutsideView-only plumbing + cleanup. Pre-flight: verify [outdoor-node] portals≥1
before flipping. Keep the pure-outdoor case byte-identical (regression guard, §5).

DON'T (§2/§8): retry dead-zone / player-cell branch-gating / debounce (evidence-disproven); delete old
paths before the visual gate; root the flood at the player cell; use ACDREAM_PROBE_SHELL.