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>
19 KiB
Handoff — Render Unification CUTOVER FLIP (the one step that fixes the flap) — 2026-06-07
CANONICAL PICKUP. Read this first. Worktree
thirsty-goldberg-51bb9b, branchclaude/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 ACE127.0.0.1:9000,testaccount/testpassword, char+Acdream(spawns at the cottage, landblock0xA9B4, cottage cells0xA9B4016F–0175, outdoor cell id near spawn0xA9B40031).
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 1 — OutdoorCellNode.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 (0170↔0171) 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). Reverted2a2cc97'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) → aLoadedCellwithWorldTransform=Identity,SeenOutside=true, andPortals/ClipPlanes/PortalPolygonsthat point BACK into each building cell (reverse of the building'sOtherCellId==0xFFFFexit portal; entrance polygon → world space;InsideSideflipped). 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
queuedHashSet +MaxReprocessPerCell). This is the de-risk: the core hypothesis is proven.
- the buildings reached through its portals; the outdoor↔building cycle terminates (existing
_outdoorNodeis built live each outdoor frame (Task 2,GameWindow.csjust before the branch, ~line 7360) from nearby building cells (Chebyshev ≤1 landblocks). It is NOT yet consumed (behaviour unchanged). An[outdoor-node]probe (underACDREAM_PROBE_FLAP) printscell=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, ...)atGameWindow.cs:7204returns null for a null root. After the flip you passclipRoot(= node) intoDrawInsideviaRootCell, but the separatevisibility = ComputeVisibilityFromRoot(viewerRoot, ...)call still usesviewerRoot(the interior one). Decide: either also feed the node to that call, or confirmcameraInsideCell/rootSeenOutsidestill behave.rootSeenOutside = viewerRoot?.SeenOutside ?? true(line 7211) → with the node it'd betrue(node.SeenOutside) IF you point it at the node; with the interiorviewerRoot(null outdoors) it'strue. Either wayrenderSky(line 7314viewerRoot is null || rootSeenOutside) stays true outdoors. Verify sky still draws outdoors after the flip.DrawInsideis rooted atclipRoot(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 oldelseblock 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:_outdoorNodefield + helpers ~line 188; node build ~7355-7380 (before the branch);viewerRoot/viewerCellIdresolve 7192-7204;clipRoot/renderBranch7384-7388 (the flip); indoorDrawInsideblock 7448-7515; dead-after-flipelse(outdoor) block 7516-7614 (terrain_terrain?.Draw~7425, look-inDrawPortal~7570);DrawRetailPViewLandscapeSlice~9239;[render-sig]emit ~9039-9082.RetailPViewRenderer.cs:DrawInside39;DrawPortal88 (delete in D);DrawLandscapeThroughOutsideView138;DrawEnvCellShells180 (node no-op); shells use_envCells.Render(pass, {id}).PortalVisibilityBuilder.cs:Build63 (root seed ~77 → add full-screen OutsideView for outdoor root); exit-portal branch 234 (OtherCellId==0xFFFF→AddRegion(frame.OutsideView, ...)— the indoor→outdoor path that already works);BuildFromExterior339 (delete in D);CameraOnInteriorSide664.ClipFrameAssembler.cs:Assemble78; OutsideView→slices 134-165 (outdoorVisible = slices.Length>0).OutdoorCellNode.cs:Build.CellVisibility.cs:LoadedCell(class,CellIdfield line 29),CellPortalInfo/PortalClipPlane,TryGetCell276,GetCellsForLandblock266 (returnsIReadOnlyList<LoadedCell>),ComputeVisibilityFromRoot338 (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.