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

283 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.
```