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>
This commit is contained in:
Erik 2026-06-07 18:42:04 +02:00
parent 7b3091c44d
commit 9bc0db9351

View file

@ -0,0 +1,283 @@
# 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](../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](../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:
```csharp
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). 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:
```csharp
// 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:
```csharp
// 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)
```powershell
$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==0xFFFF``AddRegion(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.
```