Shell pass is a safe no-op for the node id (no exclusion needed); indoor->outdoor terrain already works via OutsideView; the only new piece is feeding the outdoor ROOT node's full-screen region to OutsideView. Remaining = OutsideView integration (read ClipFrameAssembler) + clipRoot flip + launch + visual gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
24 KiB
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
Progress (2026-06-07)
-
Task 1 —
OutdoorCellNode.Build— DONE (2a2cc97). 2 unit tests; App.Tests 212/212. -
Task 3 — outdoor-root flood — DONE (
c5b4f77, done before Task 2 to de-risk the core hypothesis). KEY RESULT: the flood roots at the outdoor node and reaches buildings with ZERO production changes —PortalVisibilityBuilder.BuildandOutdoorCellNode.Buildare correct as-is; cycle termination holds. App.Tests 214/214. (The plan's Task 3 fixture sketch hadInsideSide=0; the shipped test uses the correctInsideSide=1— building interior at Y>5 is the negative half-space.OutdoorCellNodeflips it so the outdoor camera passes the side test.) -
Task 2 — build the outdoor node each frame — DONE (
d01fe30). Additive:_outdoorNodebuilt each outdoor frame from nearby building-entrance portals (Chebyshev ≤1), with an[outdoor-node]probe (ACDREAM_PROBE_FLAP) reporting the live portal count. Not yet rooted → behaviour unchanged. App.Tests 214/214, build green. (Insertion:GameWindow.csjust before the branch at the old line ~7341;playerLbis in scope there.) -
NEXT — THE CUTOVER FLIP (the remaining risky, launch-gated chunk), INLINE. Now fully de-risked by reading the draw path:
- The shell pass is a safe no-op for the synthetic node id —
DrawEnvCellShells→_envCells.Render(pass, {id})renders nothing for an id with no prepared geometry (RetailPViewRenderer.cs:190-202). So no explicit shell-exclusion is needed. - Indoor→outdoor terrain already works via the existing
OutsideView→ terrain-slice path (DrawInside→DrawLandscapeThroughOutsideView,RetailPViewRenderer.cs:79,138). The ONLY new piece is the outdoor-ROOT case: whenDrawInsideis rooted at the outdoor node, the node's full-screen view region must become anOutsideViewslice so terrain draws full-screen. → ReadClipFrameAssembler(howpvFrame.OutsideViewbecomesOutsideViewSlices; how a full-screen region maps to a no-clip terrain slice), then inPortalVisibilityBuilder.Build(orDrawInside): when the root is the outdoor node (SeenOutside+ outdoor id),AddRegion(frame.OutsideView, <full-screen NDC quad>). - Then flip:
viewerRoot = _outdoorNodewhen outdoors;clipRoot = viewerRootalways (drop theplayerIndoorGate && viewerRoot != nullgate atGameWindow.cs:~7346). This routes EVERY frame through_retailPViewRenderer.DrawInside(theelseoutdoor block becomes dead — leave it for the post-visual-gate delete). - Build → launch (
ACDREAM_PROBE_FLAPonly) → USER VISUAL GATE at the cottage. Then deleteBuildFromExterior/DrawPortal/ the deadelseblock /OutsideView-only plumbing (Task 7) + cleanup (Phase 4). - WARNING: this is coordinated surgery (Build + ClipFrameAssembler + GameWindow) that ends at a launch + visual gate; a first attempt rarely renders right. Do it with adequate context headroom (the dead-zone regression came from rushing a render change before a visual gate). Verify the [outdoor-node] probe shows real portals FIRST.
- The shell pass is a safe no-op for the synthetic node id —
-
Tree clean; HEAD
d01fe30; baselines App 214 / Core 1331-4-1.
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
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<LoadedCell>());
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
// src/AcDream.App/Rendering/OutdoorCellNode.cs
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.Rendering;
/// <summary>
/// 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.
/// </summary>
public static class OutdoorCellNode
{
public static LoadedCell Build(uint outdoorCellId, IReadOnlyList<LoadedCell> 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
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(addGetNearbyBuildingCellsForExteriorif not already exposed; the look-in enumeration atGameWindow.cs:~7538-7565already 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 toGameWindowand, right after the existingviewerRootblock (GameWindow.cs:7201-7203), whenviewerRoot 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 helperGatherNearbyBuildingCells(playerLb)returningIReadOnlyList<LoadedCell>), assign_outdoorNode = OutdoorCellNode.Build(viewerCellId, nearby);and leaveviewerRootunchanged for now (Phase 3 flips the consumer). Add a one-line[render-sig]-adjacent log behindProbeFlapEnabled:outdoorNode portals=Nto confirm wiring live. -
Step 2:
dotnet build -c Debug→ 0 errors.dotnet testboth suites → baselines hold (210 / 1331-4-1). No behavior change yet. -
Step 3: Commit
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(theBuildseed + 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)
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 inBuild's projection call). Fix minimally inPortalVisibilityBuilder. 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
Buildterminates (no hang, boundedOrderedVisibleCells). The existingqueued/MaxReprocessPerCellguards should cover it; this test pins it. -
Step 4:
dotnet testboth suites → baselines hold + the new tests pass. -
Step 5: Commit
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 thelookup(Phase 1 makes it resolvable) and enqueue it withclippedRegionas its view, exactly like an interior neighbour (theAddRegion(nview,…)+queued.Addpath at lines 296-316). KeepOutsideViewpopulated too for this task only (so the old draw still works) — it is removed in Task 7. RunUnifiedFloodTests+ 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 — driveTerrainModernRenderer'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 soviewerRootis the outdoor node when outdoors (Task 2 already builds it; assignviewerRoot = _outdoorNodewhen the prior lookup was null and an outdoor node exists). SetclipRoot = viewerRootunconditionally (drop theplayerIndoorGate && viewerRoot != nullgate). The single draw path (RetailPViewRenderer.DrawInside) now runs every frame, rooted at the viewer cell. - Build green;
dotnet testbaselines. 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]—branchis now always the single path;viewerCell/drawtransition cleanly with no 4↔6 cell-set jump. - Only after the user confirms: delete
PortalVisibilityBuilder.BuildFromExterior,RetailPViewRenderer.DrawPortal, theOutsideViewfield +AddRegion(frame.OutsideView,…), andGameWindow.DrawRetailPViewLandscapeSlice/DrawLandscapeThroughOutsideView+ the now-dead outdoor-branch block. Build green;dotnet testbaselines. - 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) sobranchreflects the single path and the now-removedextPortal/extIds/outdoorRoot*fields are dropped or repurposed. Remove any now-unreachable helpers flagged by the build. Updatedocs/research/ memoryproject_indoor_flap_rootcause+reference_render_pipeline_statewith 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(fieldsCellId,Portals,ClipPlanes,PortalPolygons,WorldTransform,InverseWorldTransform,SeenOutside),CellPortalInfo(OtherCellId,PolygonId,Flags,OtherPortalId),PortalClipPlane{Normal,D,InsideSide},OutdoorCellNode.Build(uint, IReadOnlyList<LoadedCell>) → LoadedCell,PortalVisibilityBuilder.Build(LoadedCell, Vector3, Func<uint,LoadedCell?>, 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.