diff --git a/tests/AcDream.Core.Tests/Rendering/CellGraphRootTests.cs b/tests/AcDream.Core.Tests/Rendering/CellGraphRootTests.cs
new file mode 100644
index 0000000..2b75717
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Rendering/CellGraphRootTests.cs
@@ -0,0 +1,208 @@
+// CellGraphRootTests.cs — Stage 3 (2026-06-02): unit tests for render-root
+// selection logic and CellGraph.FindVisibleChildCell.
+//
+// Tests 1–3: render-branch predicates. These exercise the formulas in
+// GameWindow.OnRender directly (Stage 3):
+// rootSeenOutside = physicsRoot?.SeenOutside ?? true
+// playerInsideCell = cameraInsideCell && !rootSeenOutside
+// renderSky = !cameraInsideCell || rootSeenOutside
+//
+// Retail anchors:
+// CellManager::ChangePosition @ 0x004559B0 (pseudo_c:94649) — landscape kept
+// live iff seen_outside; sun zeroed when inside a sealed interior.
+// SmartBox::RenderNormalMode @ 0x00453aa0 (pseudo_c:92635) — sky gate.
+//
+// Tests 4–6: CellGraph.FindVisibleChildCell (retail find_visible_child_cell
+// @ 0x0052dc50, pseudo_c:311397).
+
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.Core.World.Cells;
+using Xunit;
+
+namespace AcDream.Core.Tests.Rendering;
+
+public class CellGraphRootTests
+{
+ // ------------------------------------------------------------------
+ // Helpers
+ // ------------------------------------------------------------------
+
+ ///
+ /// Synthetic EnvCell with an identity transform and axis-aligned bounds so
+ /// PointInCell returns true for points inside [min, max].
+ /// seenOutside = false → sealed dungeon; true → building interior/exterior.
+ ///
+ private static EnvCell MakeEnvCell(uint id, Vector3 min, Vector3 max, bool seenOutside = false)
+ => new EnvCell(
+ id,
+ Matrix4x4.Identity,
+ Matrix4x4.Identity,
+ min, max,
+ portals: new List(),
+ stabList: new List(),
+ seenOutside: seenOutside,
+ containmentBsp: null);
+
+ ///
+ /// EnvCell with an explicit stab list (used by FindVisibleChildCell tests).
+ ///
+ private static EnvCell MakeEnvCellWithStab(uint id, Vector3 min, Vector3 max,
+ IReadOnlyList stabList, bool seenOutside = false)
+ => new EnvCell(
+ id,
+ Matrix4x4.Identity,
+ Matrix4x4.Identity,
+ min, max,
+ portals: new List(),
+ stabList: stabList,
+ seenOutside: seenOutside,
+ containmentBsp: null);
+
+ // ------------------------------------------------------------------
+ // Predicate helpers — mirror the formulas in GameWindow.OnRender (Stage 3)
+ // ------------------------------------------------------------------
+
+ private static bool RootSeenOutside(EnvCell? physicsRoot) => physicsRoot?.SeenOutside ?? true;
+
+ // Retail CellManager::ChangePosition (0x004559B0): sun/sun-ambient zeroed
+ // when inside a sealed interior (cameraInsideCell=true, seen_outside=false).
+ // Building interiors with seen_outside keep the sun; outdoor root always keeps it.
+ private static bool PlayerInsideCell(bool cameraInsideCell, bool rootSeenOutside)
+ => cameraInsideCell && !rootSeenOutside;
+
+ // Stage 3 sky gate: visible unless inside a sealed dungeon.
+ private static bool RenderSky(bool cameraInsideCell, bool rootSeenOutside)
+ => !cameraInsideCell || rootSeenOutside;
+
+ // ------------------------------------------------------------------
+ // Test 1: outdoor root (null CurrCell) — seen_outside=true, sky rendered,
+ // player NOT considered inside a cell.
+ // ------------------------------------------------------------------
+
+ [Fact]
+ public void RootSelection_OutdoorRoot_NullCurrCell_SeenOutsideDefaultsToTrue()
+ {
+ // When physicsRoot==null (pre-spawn, or outdoor landcell) the ?? true
+ // gives rootSeenOutside=true; cameraInsideCell=false (no cell).
+ bool rootSeenOutside = RootSeenOutside(null);
+ bool cameraInsideCell = false; // no indoor root → ComputeVisibilityFromRoot returns null
+
+ bool playerInsideCell = PlayerInsideCell(cameraInsideCell, rootSeenOutside);
+ bool renderSky = RenderSky(cameraInsideCell, rootSeenOutside);
+
+ Assert.True(rootSeenOutside, "outdoor null root → rootSeenOutside=true");
+ Assert.False(playerInsideCell, "outdoor → playerInsideCell=false (sun kept live)");
+ Assert.True(renderSky, "outdoor → sky rendered");
+ }
+
+ // ------------------------------------------------------------------
+ // Test 2: building interior (seen_outside=true) — sky rendered (interim
+ // full-screen until Stage 4 clips it), sun kept live.
+ // ------------------------------------------------------------------
+
+ [Fact]
+ public void RootSelection_BuildingInterior_SeenOutside_SkyRenderedAndSunKept()
+ {
+ // A cottage cell: seen_outside=true (exit portal exists).
+ var physicsRoot = MakeEnvCell(0xA9B40170u, Vector3.Zero, new Vector3(10, 10, 10),
+ seenOutside: true);
+
+ bool rootSeenOutside = RootSeenOutside(physicsRoot);
+ bool cameraInsideCell = true; // inside a cell → ComputeVisibilityFromRoot non-null
+
+ bool playerInsideCell = PlayerInsideCell(cameraInsideCell, rootSeenOutside);
+ bool renderSky = RenderSky(cameraInsideCell, rootSeenOutside);
+
+ Assert.True(rootSeenOutside, "building interior seen_outside=true");
+ Assert.False(playerInsideCell, "seen_outside=true → sun kept live");
+ Assert.True(renderSky, "seen_outside=true → sky rendered (Stage 4 will clip to doorway)");
+ }
+
+ // ------------------------------------------------------------------
+ // Test 3: sealed dungeon (seen_outside=false) — sky suppressed, sun zeroed.
+ // ------------------------------------------------------------------
+
+ [Fact]
+ public void RootSelection_Dungeon_NoSeenOutside_SkyNotRenderedAndSunZeroed()
+ {
+ // A dungeon cell: seen_outside=false (no exit portal reachable).
+ var physicsRoot = MakeEnvCell(0x01D90100u, Vector3.Zero, new Vector3(10, 10, 10),
+ seenOutside: false);
+
+ bool rootSeenOutside = RootSeenOutside(physicsRoot);
+ bool cameraInsideCell = true; // inside a cell → non-null visibility result
+
+ bool playerInsideCell = PlayerInsideCell(cameraInsideCell, rootSeenOutside);
+ bool renderSky = RenderSky(cameraInsideCell, rootSeenOutside);
+
+ Assert.False(rootSeenOutside, "dungeon seen_outside=false");
+ Assert.True(playerInsideCell, "sealed dungeon → playerInsideCell=true (sun zeroed)");
+ Assert.False(renderSky, "sealed dungeon → sky suppressed");
+ }
+
+ // ------------------------------------------------------------------
+ // Test 4: FindVisibleChildCell — query inside the root cell → returns root.
+ // ------------------------------------------------------------------
+
+ [Fact]
+ public void FindVisibleChildCell_PlayerCellContains_ReturnsPlayerCell()
+ {
+ var graph = new CellGraph();
+ var root = MakeEnvCell(0xA9B40170u, Vector3.Zero, new Vector3(10, 10, 10));
+ graph.Add(root);
+
+ var point = new Vector3(5, 5, 5); // inside root's bounds
+ var result = graph.FindVisibleChildCell(root.Id, point);
+
+ Assert.NotNull(result);
+ Assert.Equal(root.Id, result!.Id);
+ }
+
+ // ------------------------------------------------------------------
+ // Test 5: FindVisibleChildCell — query inside a stab-list neighbour → returns that cell.
+ // ------------------------------------------------------------------
+
+ [Fact]
+ public void FindVisibleChildCell_StabListContains_ReturnsNeighbour()
+ {
+ var graph = new CellGraph();
+
+ // Root at [0,10]. Neighbour B at [20,30]. Root's stab list includes B.
+ uint rootId = 0xA9B40170u;
+ uint neighId = 0xA9B40171u;
+
+ var neigh = MakeEnvCell(neighId, new Vector3(20, 0, 0), new Vector3(30, 10, 10));
+ var root = MakeEnvCellWithStab(rootId, Vector3.Zero, new Vector3(10, 10, 10),
+ stabList: new List { neighId });
+
+ graph.Add(root);
+ graph.Add(neigh);
+
+ var point = new Vector3(25, 5, 5); // inside neighbour, outside root
+ var result = graph.FindVisibleChildCell(rootId, point);
+
+ Assert.NotNull(result);
+ Assert.Equal(neighId, result!.Id);
+ }
+
+ // ------------------------------------------------------------------
+ // Test 6: FindVisibleChildCell — query outside all cells → null.
+ // ------------------------------------------------------------------
+
+ [Fact]
+ public void FindVisibleChildCell_NeitherContains_ReturnsNull()
+ {
+ var graph = new CellGraph();
+ uint rootId = 0xA9B40170u;
+
+ var root = MakeEnvCellWithStab(rootId, Vector3.Zero, new Vector3(10, 10, 10),
+ stabList: new List());
+ graph.Add(root);
+
+ var pointFarAway = new Vector3(999, 999, 999); // outside all cells
+ var result = graph.FindVisibleChildCell(rootId, pointFarAway);
+
+ Assert.Null(result);
+ }
+}