diff --git a/src/AcDream.App/Rendering/OutdoorCellNode.cs b/src/AcDream.App/Rendering/OutdoorCellNode.cs
new file mode 100644
index 00000000..0830f096
--- /dev/null
+++ b/src/AcDream.App/Rendering/OutdoorCellNode.cs
@@ -0,0 +1,63 @@
+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 docs/superpowers/specs/2026-06-07-render-unification-outdoor-as-cell-design.md).
+/// 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 (SmartBox::RenderNormalMode, decomp pc:92635).
+///
+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.Normalize(Vector3.TransformNormal(src.Normal, bcell.WorldTransform));
+ 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;
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs b/tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs
new file mode 100644
index 00000000..1129b295
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs
@@ -0,0 +1,52 @@
+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);
+ }
+}