acdream/docs/superpowers/plans/2026-06-07-render-unification-outdoor-as-cell.md
Erik 7b3091c44d docs: plan progress — Task 2 done; cutover flip de-risked + precisely specified
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>
2026-06-07 18:36:20 +02:00

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 changesPortalVisibilityBuilder.Build and OutdoorCellNode.Build are correct as-is; cycle termination holds. App.Tests 214/214. (The plan's Task 3 fixture sketch had InsideSide=0; the shipped test uses the correct InsideSide=1 — building interior at Y>5 is the negative half-space. OutdoorCellNode flips it so the outdoor camera passes the side test.)

  • Task 2 — build the outdoor node each frame — DONE (d01fe30). Additive: _outdoorNode built 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.cs just before the branch at the old line ~7341; playerLb is 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 (DrawInsideDrawLandscapeThroughOutsideView, RetailPViewRenderer.cs:79,138). The ONLY new piece is the outdoor-ROOT case: when DrawInside is rooted at the outdoor node, the node's full-screen view region must become an OutsideView slice so terrain draws full-screen. → Read ClipFrameAssembler (how pvFrame.OutsideView becomes OutsideViewSlices; how a full-screen region maps to a no-clip terrain slice), then in PortalVisibilityBuilder.Build (or DrawInside): when the root is the outdoor node (SeenOutside + outdoor id), AddRegion(frame.OutsideView, <full-screen NDC quad>).
    • Then flip: viewerRoot = _outdoorNode when outdoors; clipRoot = viewerRoot always (drop the playerIndoorGate && viewerRoot != null gate at GameWindow.cs:~7346). This routes EVERY frame through _retailPViewRenderer.DrawInside (the else outdoor block becomes dead — leave it for the post-visual-gate delete).
    • Build → launch (ACDREAM_PROBE_FLAP only) → USER VISUAL GATE at the cottage. Then delete BuildFromExterior / DrawPortal / the dead else block / 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.
  • 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 (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<LoadedCell>), 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

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)
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

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>) → 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.