diff --git a/src/AcDream.App/Rendering/CellVisibility.cs b/src/AcDream.App/Rendering/CellVisibility.cs
index 80477c0e..7cabee61 100644
--- a/src/AcDream.App/Rendering/CellVisibility.cs
+++ b/src/AcDream.App/Rendering/CellVisibility.cs
@@ -104,6 +104,16 @@ public sealed class LoadedCell
/// grab_visible_cells decomp:311878). The stable anchor for the terrain-draw test.
///
public bool SeenOutside;
+
+ ///
+ /// Render unification (2026-06-07): true for the synthetic OUTDOOR cell node built by
+ /// — the outdoor world modelled as a flood-graph cell whose
+ /// shell is the landscape. seeds OutsideView
+ /// full-screen when the root carries this flag (so terrain/sky/scenery draw as the node's shell).
+ /// An explicit flag, not a cell-id heuristic: interior EnvCell ids are >= 0x100 in production but
+ /// test fixtures use low ids for interior cells, so keying on the id would misfire.
+ ///
+ public bool IsOutdoorNode;
}
///
diff --git a/src/AcDream.App/Rendering/OutdoorCellNode.cs b/src/AcDream.App/Rendering/OutdoorCellNode.cs
index 0830f096..6ce08e3a 100644
--- a/src/AcDream.App/Rendering/OutdoorCellNode.cs
+++ b/src/AcDream.App/Rendering/OutdoorCellNode.cs
@@ -20,6 +20,7 @@ public static class OutdoorCellNode
{
CellId = outdoorCellId,
SeenOutside = true,
+ IsOutdoorNode = true,
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
};
diff --git a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs
index 461d1145..e421e3a4 100644
--- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs
+++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs
@@ -76,6 +76,18 @@ public static class PortalVisibilityBuilder
frame.CellViews[cameraCell.CellId] = CellView.FullScreen();
+ // Render unification (outdoor-as-cell, 2026-06-07): when the root IS the synthetic outdoor
+ // node, the landscape is visible FULL-SCREEN, so seed OutsideView with the full-screen NDC
+ // quad. ClipFrameAssembler turns that into a full-screen OutsideView slice, so DrawInside's
+ // DrawLandscapeThroughOutsideView draws terrain/sky/scenery/weather as the node's "shell" —
+ // the very same callback that already draws the doorway slice when an INTERIOR root reaches
+ // outdoors. Keyed on the explicit IsOutdoorNode flag (set by OutdoorCellNode.Build), NOT a
+ // cell-id heuristic: production EnvCell ids are >= 0x100 but test fixtures use low interior
+ // ids, so an id test would misfire. An interior root never sets this flag, so the indoor
+ // exit-portal path (OtherCellId==0xFFFF below) still owns the doorway OutsideView region.
+ if (cameraCell.IsOutdoorNode)
+ frame.OutsideView.Add(new ViewPolygon((Vector2[])FullScreenQuad.Clone()));
+
// Distance-priority work list (retail PView::cell_todo_list). Cells pop closest-first;
// each cell carries the camera→nearest-portal-vertex distance that put it on the list
// (retail keys on InitCell's per-portal min-vertex distance, decomp 432988-433004). The
diff --git a/tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs b/tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs
index 1129b295..17faa55e 100644
--- a/tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs
@@ -33,6 +33,7 @@ public class OutdoorCellNodeTests
Assert.Equal(outdoorId, node.CellId);
Assert.True(node.SeenOutside);
+ Assert.True(node.IsOutdoorNode); // the flag PortalVisibilityBuilder keys the full-screen OutsideView on
Assert.Equal(Matrix4x4.Identity, node.WorldTransform);
Assert.Single(node.Portals);
Assert.Equal((ushort)(0xA9B40170 & 0xFFFF), node.Portals[0].OtherCellId);
@@ -48,5 +49,6 @@ public class OutdoorCellNodeTests
var node = OutdoorCellNode.Build(0xA9B40031, System.Array.Empty());
Assert.Empty(node.Portals);
Assert.True(node.SeenOutside);
+ Assert.True(node.IsOutdoorNode);
}
}
diff --git a/tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs b/tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs
index 75323d17..4beede61 100644
--- a/tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs
@@ -60,4 +60,50 @@ public class UnifiedFloodTests
var frame = PortalVisibilityBuilder.Build(node, eye, Lookup, view * proj);
Assert.True(frame.OrderedVisibleCells.Count < 10); // bounded, no runaway
}
+
+ // Step A (cutover): rooting at the outdoor node must seed OutsideView with the FULL-SCREEN NDC
+ // quad so DrawLandscapeThroughOutsideView draws the landscape as the node's shell. Without this
+ // the outdoor-root frame would have OutsideViewSlices.Length==0 -> TerrainMode.Skip -> no terrain.
+ [Fact]
+ public void Build_RootedAtOutdoorNode_SeedsFullScreenOutsideView()
+ {
+ var building = MakeBuildingCell(0xA9B40170);
+ var node = OutdoorCellNode.Build(0xA9B40031, new[] { building });
+ LoadedCell? Lookup(uint id) => (id & 0xFFFFu) == 0x0170 ? building : null;
+
+ 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.False(frame.OutsideView.IsEmpty);
+ // The union bounds of OutsideView must cover the whole NDC viewport (a full-screen quad),
+ // so ClipFrameAssembler yields TerrainMode != Skip and the terrain draws everywhere.
+ Assert.Equal(-1f, frame.OutsideView.MinX, 3);
+ Assert.Equal(-1f, frame.OutsideView.MinY, 3);
+ Assert.Equal(1f, frame.OutsideView.MaxX, 3);
+ Assert.Equal(1f, frame.OutsideView.MaxY, 3);
+ }
+
+ // Pure-outdoor regression guard (spec section 10): an outdoor node with NO nearby buildings must
+ // resolve to exactly {node} with a full-screen OutsideView -> full-screen terrain, no interior
+ // cells, byte-for-byte today's open-world draw. Visual-gate the open field, not just the cottage.
+ [Fact]
+ public void Build_EmptyOutdoorNode_FullScreenOutsideView_OnlyNodeVisible()
+ {
+ var node = OutdoorCellNode.Build(0xA9B40031, Array.Empty());
+ Assert.Empty(node.Portals); // no doorways
+
+ 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, _ => null, view * proj);
+
+ Assert.Equal(new[] { 0xA9B40031u }, frame.OrderedVisibleCells); // only the node
+ Assert.False(frame.OutsideView.IsEmpty);
+ Assert.Equal(-1f, frame.OutsideView.MinX, 3);
+ Assert.Equal(1f, frame.OutsideView.MaxX, 3);
+ }
}