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>
283 lines
19 KiB
Markdown
283 lines
19 KiB
Markdown
# 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<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.
|
||
```
|