diff --git a/docs/research/2026-06-07-render-unification-cutover-flip-handoff.md b/docs/research/2026-06-07-render-unification-cutover-flip-handoff.md new file mode 100644 index 00000000..13f5188f --- /dev/null +++ b/docs/research/2026-06-07-render-unification-cutover-flip-handoff.md @@ -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 `0xA9B4016F–0175`, 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 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`), `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. +```