diff --git a/docs/superpowers/plans/2026-06-07-render-unification-outdoor-as-cell.md b/docs/superpowers/plans/2026-06-07-render-unification-outdoor-as-cell.md new file mode 100644 index 00000000..88053100 --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-render-unification-outdoor-as-cell.md @@ -0,0 +1,315 @@ +# Render Unification (Outdoor-as-a-Cell) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Collapse acdream's two render paths (`OutdoorRoot` vs `RetailPViewInside`) into one — a single portal flood rooted at the viewer cell (indoor *or* a new outdoor cell node) and a single draw of every visible cell — so the indoor/outdoor FLAP is impossible by construction. + +**Architecture:** Model the outdoor world as a synthetic `LoadedCell` flood node whose "shell" is the landscape and whose "doorways" are nearby building entrances. `PortalVisibilityBuilder.Build` roots at the viewer cell; building exit portals lead to the outdoor node; the draw path renders each visible cell uniformly (outdoor node → terrain/sky; interior → shell). Matches retail `SmartBox::RenderNormalMode → DrawInside(viewer_cell)`. + +**Tech Stack:** C# .NET 10, Silk.NET OpenGL, xUnit. Render code in `src/AcDream.App/Rendering/`. Tests in `tests/AcDream.App.Tests/Rendering/`. + +**Spec:** [docs/superpowers/specs/2026-06-07-render-unification-outdoor-as-cell-design.md](../specs/2026-06-07-render-unification-outdoor-as-cell-design.md) + +**Baselines that must hold:** build 0 errors; App.Tests 210 pass; Core.Tests 1331 pass / 4 fail (pre-existing) / 1 skip. Run the client per CLAUDE.md "Running the client"; `+Acdream` spawns at the Holtburg/Arcanum cottage. Launch logs are UTF-16. Use `ACDREAM_PROBE_FLAP` only (NOT `ACDREAM_PROBE_SHELL`). + +--- + +## File structure + +| File | Responsibility | Change | +|---|---|---| +| `src/AcDream.App/Rendering/OutdoorCellNode.cs` | Build a synthetic outdoor `LoadedCell` from nearby building exit portals | **Create** | +| `src/AcDream.App/Rendering/CellVisibility.cs` | `LoadedCell`/`CellPortalInfo`/`PortalClipPlane` types; cell registry; `TryGetCell`; resolve the outdoor node | Modify | +| `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` | The one flood; root at outdoor node; exit portals → outdoor node | Modify | +| `src/AcDream.App/Rendering/RetailPViewRenderer.cs` | The one draw path; outdoor-node-aware cell draw | Modify | +| `src/AcDream.App/Rendering/GameWindow.cs` | Per-frame: resolve viewer cell, one flood, one draw; delete the branch | Modify | +| `tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs` | Outdoor node construction + portal wiring | **Create** | +| `tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs` | Build rooted at the outdoor node; cycle termination | **Create** | + +**Deletions (Phase 3):** `PortalVisibilityBuilder.BuildFromExterior`; `RetailPViewRenderer.DrawPortal`; the `OutsideView` mechanism + `GameWindow.DrawRetailPViewLandscapeSlice` / `DrawLandscapeThroughOutsideView`; the two-branch gate at `GameWindow.cs:7342-7349`. + +--- + +## PHASE 1 — The outdoor cell node (additive; not yet consumed by the draw) + +### Task 1: `OutdoorCellNode.Build` — synthesize the outdoor node from nearby building entrances + +**Files:** +- Create: `src/AcDream.App/Rendering/OutdoorCellNode.cs` +- Test: `tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs` + +Context: a building cell stores its entrance as a portal with `OtherCellId == 0xFFFF` (exit-to-outdoors) in `Portals[i]`, with the matching `ClipPlanes[i]` (local-space `Normal`,`D`,`InsideSide`) and `PortalPolygons[i]` (local-space verts). The outdoor node is a `LoadedCell` with `WorldTransform = Identity` whose `Portals` point *back into* each building cell, with the entrance polygon transformed to world space and the clip plane reversed (`InsideSide` flipped) so "inside the outdoor node" is the half-space outside the building. + +- [ ] **Step 1: Write the failing test** + +```csharp +using System.Numerics; +using AcDream.App.Rendering; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +public class OutdoorCellNodeTests +{ + // A building cell at world-translate (10,0,0) with one exit portal (OtherCellId=0xFFFF) + // whose local plane faces +X (InsideSide=0). The outdoor node must expose ONE portal + // back into that building cell, with the entrance polygon moved to world space and the + // inside-side flipped (so the outdoor half-space is "inside" the node). + private static LoadedCell BuildingWithOneExit(uint cellId) + { + var cell = new LoadedCell { CellId = cellId }; + cell.WorldTransform = Matrix4x4.CreateTranslation(10f, 0f, 0f); + cell.InverseWorldTransform = Matrix4x4.CreateTranslation(-10f, 0f, 0f); + cell.Portals.Add(new CellPortalInfo(OtherCellId: 0xFFFF, PolygonId: 0, Flags: 0, OtherPortalId: 0)); + cell.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(1, 0, 0), D = 0f, InsideSide = 0 }); + cell.PortalPolygons.Add(new[] + { + new Vector3(0, -1, 0), new Vector3(0, 1, 0), new Vector3(0, 1, 2), new Vector3(0, -1, 2) + }); + return cell; + } + + [Fact] + public void Build_FromBuildingExit_AddsReversePortalIntoBuilding() + { + uint outdoorId = 0xA9B40031; + var building = BuildingWithOneExit(0xA9B40170); + var node = OutdoorCellNode.Build(outdoorId, new[] { building }); + + Assert.Equal(outdoorId, node.CellId); + Assert.True(node.SeenOutside); + Assert.Equal(Matrix4x4.Identity, node.WorldTransform); + Assert.Single(node.Portals); + Assert.Equal((ushort)(0xA9B40170 & 0xFFFF), node.Portals[0].OtherCellId); + // Reversed inside-side: the building's exit was InsideSide=0, the node's is 1. + Assert.Equal(1, node.ClipPlanes[0].InsideSide); + // Entrance polygon moved to world space (building translated +10 X): first vert x≈10. + Assert.Equal(10f, node.PortalPolygons[0][0].X, 3); + } + + [Fact] + public void Build_NoBuildings_ReturnsEmptyPortalNode() + { + var node = OutdoorCellNode.Build(0xA9B40031, System.Array.Empty()); + Assert.Empty(node.Portals); + Assert.True(node.SeenOutside); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~OutdoorCellNodeTests"` +Expected: FAIL — `OutdoorCellNode` does not exist (compile error). + +- [ ] **Step 3: Write minimal implementation** + +```csharp +// src/AcDream.App/Rendering/OutdoorCellNode.cs +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.App.Rendering; + +/// +/// Builds the synthetic outdoor cell node — the outdoor world as a flood-graph cell +/// (spec 2026-06-07-render-unification-outdoor-as-cell). Its "shell" is the landscape +/// (drawn by the terrain renderer); its portals are the reverse of each nearby +/// building's exit portal (OtherCellId==0xFFFF). One node per frame, keyed by the +/// viewer's outdoor landcell id. WorldTransform is identity (portals stored in world +/// space). Mirrors retail's outdoor landcell that DrawInside(viewer_cell) roots at. +/// +public static class OutdoorCellNode +{ + public static LoadedCell Build(uint outdoorCellId, IReadOnlyList nearbyBuildingCells) + { + var node = new LoadedCell + { + CellId = outdoorCellId, + SeenOutside = true, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + }; + + foreach (var bcell in nearbyBuildingCells) + { + for (int i = 0; i < bcell.Portals.Count; i++) + { + if (bcell.Portals[i].OtherCellId != 0xFFFF) continue; // only exit-to-outdoors + if (i >= bcell.ClipPlanes.Count || i >= bcell.PortalPolygons.Count) continue; + + // Reverse portal: outdoor node -> this building cell. + node.Portals.Add(new CellPortalInfo( + OtherCellId: (ushort)(bcell.CellId & 0xFFFFu), + PolygonId: bcell.Portals[i].PolygonId, + Flags: bcell.Portals[i].Flags, + OtherPortalId: (ushort)i)); + + // Entrance polygon -> world space (node transform is identity). + var srcPoly = bcell.PortalPolygons[i]; + var worldPoly = new Vector3[srcPoly.Length]; + for (int v = 0; v < srcPoly.Length; v++) + worldPoly[v] = Vector3.Transform(srcPoly[v], bcell.WorldTransform); + node.PortalPolygons.Add(worldPoly); + + // Clip plane -> world space, inside-side flipped (outdoor half-space is "inside"). + var src = bcell.ClipPlanes[i]; + var worldNormal = Vector3.TransformNormal(src.Normal, bcell.WorldTransform); + worldNormal = Vector3.Normalize(worldNormal); + var pointOnPlane = Vector3.Transform(src.Normal * -src.D, bcell.WorldTransform); + node.ClipPlanes.Add(new PortalClipPlane + { + Normal = worldNormal, + D = -Vector3.Dot(worldNormal, pointOnPlane), + InsideSide = src.InsideSide == 0 ? 1 : 0, + }); + } + } + + return node; + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~OutdoorCellNodeTests"` +Expected: PASS (2 tests). If `LoadedCell.CellId` is not settable from tests, confirm its declaration in `CellVisibility.cs` and adjust (it is a public field used as `cameraCell.CellId` throughout the builder). + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/Rendering/OutdoorCellNode.cs tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs +git commit -m "feat(render): Phase 1 — OutdoorCellNode.Build (outdoor world as a flood node)" +``` + +### Task 2: Resolve `viewerRoot` to the outdoor node when the eye is outdoors + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs:7201-7204` (viewerRoot resolution) +- Modify: `src/AcDream.App/Rendering/CellVisibility.cs` (add `GetNearbyBuildingCellsForExterior` if not already exposed; the look-in enumeration at `GameWindow.cs:~7538-7565` already gathers candidate cells — reuse it) + +Note: this step builds the node and stores it on a field but **does not yet feed it to the flood/draw** — the existing branch still runs. Purely additive; the only observable change is that `viewerRoot` is non-null outdoors (verify via `[render-sig]` `viewerRoot=` once wired in Phase 3; for now assert via a focused test or a temporary log). + +- [ ] **Step 1:** Add a `private LoadedCell? _outdoorNode;` field to `GameWindow` and, right after the existing `viewerRoot` block (`GameWindow.cs:7201-7203`), when `viewerRoot is null && viewerCellId != 0u` (outdoor id), build the node from the nearby building cells (reuse the exterior-candidate enumeration already at ~7538-7565, extracted into a helper `GatherNearbyBuildingCells(playerLb)` returning `IReadOnlyList`), assign `_outdoorNode = OutdoorCellNode.Build(viewerCellId, nearby);` and **leave `viewerRoot` unchanged for now** (Phase 3 flips the consumer). Add a one-line `[render-sig]`-adjacent log behind `ProbeFlapEnabled`: `outdoorNode portals=N` to confirm wiring live. + +- [ ] **Step 2:** `dotnet build -c Debug` → 0 errors. `dotnet test` both suites → baselines hold (210 / 1331-4-1). No behavior change yet. + +- [ ] **Step 3: Commit** + +```bash +git add src/AcDream.App/Rendering/GameWindow.cs src/AcDream.App/Rendering/CellVisibility.cs +git commit -m "feat(render): Phase 1 — build the outdoor node each frame (additive, unconsumed)" +``` + +--- + +## PHASE 2 — Outdoor-root flood capability (additive; old exit-portal behaviour untouched) + +### Task 3: `PortalVisibilityBuilder.Build` floods from the outdoor node into buildings + +**Files:** +- Modify: `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` (the `Build` seed + portal loop at lines 63, 133-318) +- Test: `tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs` + +Context: `Build(cameraCell, cameraPos, lookup, viewProj)` seeds the root full-screen and floods interior portals. Rooting at the outdoor node already works structurally (it's a `LoadedCell` with portals into buildings). This task is a **characterization test** proving Build floods outdoor→building, plus any fix needed for the outdoor node's identity-transform portals (its polygons are already world-space, so `ProjectToClip(localPoly, node.WorldTransform=Identity, viewProj)` is correct). + +- [ ] **Step 1: Write the failing test** (real fixture: outdoor node + one building cell reachable through it) + +```csharp +using System.Numerics; +using AcDream.App.Rendering; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +public class UnifiedFloodTests +{ + [Fact] + public void Build_RootedAtOutdoorNode_FloodsIntoBuilding() + { + // Building cell directly in front of the eye, with an exit portal facing the eye. + var building = new LoadedCell { CellId = 0xA9B40170, SeenOutside = true }; + building.WorldTransform = Matrix4x4.Identity; + building.InverseWorldTransform = Matrix4x4.Identity; + building.Portals.Add(new CellPortalInfo(0xFFFF, 0, 0, 0)); + building.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(0, -1, 0), D = 5f, InsideSide = 0 }); + building.PortalPolygons.Add(new[] + { + new Vector3(-1, 5, 0), new Vector3(1, 5, 0), new Vector3(1, 5, 2), new Vector3(-1, 5, 2) + }); + + var node = OutdoorCellNode.Build(0xA9B40031, new[] { building }); + LoadedCell? Lookup(uint id) => (id & 0xFFFFu) == 0x0170 ? building : null; + + // Eye in front of the entrance, looking +Y toward it. + var eye = new Vector3(0, -3, 1); + var view = Matrix4x4.CreateLookAt(eye, new Vector3(0, 5, 1), Vector3.UnitZ); + var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 1f, 5000f); + + var frame = PortalVisibilityBuilder.Build(node, eye, Lookup, view * proj); + + Assert.Contains(0xA9B40031u, frame.OrderedVisibleCells); // the outdoor node itself + Assert.Contains(0xA9B40170u, frame.OrderedVisibleCells); // flooded into the building + } +} +``` + +- [ ] **Step 2: Run to verify it fails or passes.** Run the filter `UnifiedFloodTests`. If it FAILS (building not flooded), inspect why (likely the lookup keys on full id vs low id, or the node's world-space polygon needs identity transform in `Build`'s projection call). Fix minimally in `PortalVisibilityBuilder`. If it PASSES first try, it's a characterization test that locks the behaviour — keep it. + +- [ ] **Step 3: Cycle-termination test** — add a reciprocal exit portal on the building back to the outdoor node and assert `Build` terminates (no hang, bounded `OrderedVisibleCells`). The existing `queued`/`MaxReprocessPerCell` guards should cover it; this test pins it. + +- [ ] **Step 4:** `dotnet test` both suites → baselines hold + the new tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/Rendering/PortalVisibilityBuilder.cs tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs +git commit -m "feat(render): Phase 2 — Build floods from the outdoor node into buildings (+cycle guard test)" +``` + +--- + +## PHASE 3 — The cutover (the one risky, visual-gated step) + +> Each task here is git-revertible as a unit. After Task 7, **stop for the user's visual gate** before Task 8's deletions. + +### Task 4: Exit portals enqueue the outdoor node + +- [ ] In `PortalVisibilityBuilder.Build`, at the exit-portal branch (`PortalVisibilityBuilder.cs:234`, `portal.OtherCellId == 0xFFFF`), instead of (only) `AddRegion(frame.OutsideView, clippedRegion)`, resolve the outdoor node via the `lookup` (Phase 1 makes it resolvable) and enqueue it with `clippedRegion` as its view, exactly like an interior neighbour (the `AddRegion(nview,…)` + `queued.Add` path at lines 296-316). Keep `OutsideView` populated too **for this task only** (so the old draw still works) — it is removed in Task 7. Run `UnifiedFloodTests` + add a test: indoor root → flood reaches the outdoor node through the exit portal. +- [ ] Commit: `feat(render): Phase 3 — exit portals flood into the outdoor node`. + +### Task 5: Unified draw — render the outdoor node's shell as terrain + +- [ ] In `RetailPViewRenderer` (the visible-cell draw walk, `DrawEnvCellShells`/`IndoorDrawPlan.ShellPass`), special-case the outdoor node: when the visible cell is the outdoor node, draw terrain + sky + outdoor scenery clipped to that cell's view region (reuse the existing terrain clip mechanism — drive `TerrainModernRenderer`'s binding=2 clip UBO from the node's region planes; full-screen region → the existing no-clip UBO) instead of EnvCell shell geometry. Interior cells unchanged. +- [ ] Build green; commit: `feat(render): Phase 3 — draw the outdoor node's shell as terrain (unified draw)`. + +### Task 6: Route the frame through the single path + +- [ ] At `GameWindow.cs:7342-7349`, replace the branch so `viewerRoot` is the outdoor node when outdoors (Task 2 already builds it; assign `viewerRoot = _outdoorNode` when the prior lookup was null and an outdoor node exists). Set `clipRoot = viewerRoot` unconditionally (drop the `playerIndoorGate && viewerRoot != null` gate). The single draw path (`RetailPViewRenderer.DrawInside`) now runs every frame, rooted at the viewer cell. +- [ ] Build green; `dotnet test` baselines. Commit: `feat(render): Phase 3 — single render path rooted at the viewer cell`. + +### Task 7: **VISUAL GATE** — user verifies, then delete the old paths + +- [ ] Build, launch (`ACDREAM_PROBE_FLAP=1`, UTF-16 log). **User test** at the Holtburg cottage: walk in/out, pan 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` is now always the single path; `viewerCell`/`draw` transition cleanly with no 4↔6 cell-set jump. +- [ ] **Only after the user confirms:** delete `PortalVisibilityBuilder.BuildFromExterior`, `RetailPViewRenderer.DrawPortal`, the `OutsideView` field + `AddRegion(frame.OutsideView,…)`, and `GameWindow.DrawRetailPViewLandscapeSlice`/`DrawLandscapeThroughOutsideView` + the now-dead outdoor-branch block. Build green; `dotnet test` baselines. +- [ ] Commit: `feat(render): Phase 3 — delete two-pipe split (BuildFromExterior/DrawPortal/OutsideView)`. + +--- + +## PHASE 4 — Cleanup + +### Task 8: Reconcile probes + dead code + +- [ ] Update the `[render-sig]` emit (`GameWindow.cs:~9039-9082`) so `branch` reflects the single path and the now-removed `extPortal/extIds/outdoorRoot*` fields are dropped or repurposed. Remove any now-unreachable helpers flagged by the build. Update `docs/research` / memory `project_indoor_flap_rootcause` + `reference_render_pipeline_state` with the shipped outcome. +- [ ] Update the roadmap "shipped" table (`docs/plans/2026-04-11-roadmap.md`) + the milestones doc M1.5 note. Commit: `chore(render): Phase 4 — probe + docs reconcile after unification`. + +--- + +## Self-review + +- **Spec coverage:** §6.1 outdoor node → Task 1/2; §6.2 one flood → Task 3/4; §6.3 one draw + deletions → Task 5/6/7; §6.4 terrain clip reuse → Task 5; §9 phasing → Phases 1-4 (1-2 additive, 3 cutover, 4 cleanup); §10 testing → Tasks 1/3 unit + Task 7 visual gate + the pure-outdoor regression guard (assert in Task 5/6 that an outdoor root with no buildings yields a full-screen no-clip terrain draw). **Gap fixed:** add to Task 6 an explicit assertion/log that the no-building outdoor case routes to the no-clip terrain UBO (regression guard from spec §10). +- **Placeholders:** Phases 1-2 carry real test + impl code. Phase 3-4 are concrete wiring/deletion tasks against named methods (their exact code is finalized against the Phase 1-2 APIs at execution — the cutover is inherently wire-and-delete + visual gate, not new algorithm). No "TBD"/"add error handling". +- **Type consistency:** `LoadedCell` (fields `CellId`, `Portals`, `ClipPlanes`, `PortalPolygons`, `WorldTransform`, `InverseWorldTransform`, `SeenOutside`), `CellPortalInfo(OtherCellId,PolygonId,Flags,OtherPortalId)`, `PortalClipPlane{Normal,D,InsideSide}`, `OutdoorCellNode.Build(uint, IReadOnlyList) → LoadedCell`, `PortalVisibilityBuilder.Build(LoadedCell, Vector3, Func, Matrix4x4)` — consistent across tasks. + +**Note for the executor:** confirm `LoadedCell.CellId` is a settable public field and the exact `PortalVisibilityBuilder.Build` signature against `CellVisibility.cs`/`PortalVisibilityBuilder.cs:63` before Task 1/3 (the plan assumes the signatures observed 2026-06-07). Phase 3 tasks reference real method names to wire/delete; read each call site before editing.