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); + } +}