using System.Collections.Generic; using System.Linq; using System.Numerics; using DatReaderWriter.Enums; using DatReaderWriter.Types; using AcDream.Core.Physics; using Xunit; namespace AcDream.Core.Tests.Physics; /// /// BR-7 / A6.P4 C2 (2026-06-11): /// — the registration-side sphere-overlap portal flood, ported from /// CObjCell::find_cell_list (Ghidra 0x0052b4e0) as called by /// calc_cross_cells(_static) (0x00515230/0x00515160). Synthetic /// fixtures mirror CellTransitFindCellSetTests. /// public class BuildShadowCellSetTests { private const uint IndoorSeed = 0xA9B40100u; private const uint NeighborCell = 0xA9B40101u; private static Sphere[] One(Vector3 center, float radius) => new[] { new Sphere { Origin = center, Radius = radius } }; private static CellPhysics MakeCellWithPortalAtRightWall( Matrix4x4 worldTransform, ushort otherCellId, IReadOnlySet? visible = null) { var portalPoly = new ResolvedPolygon { Vertices = new[] { new Vector3(2.5f, -2.5f, 0f), new Vector3(2.5f, 2.5f, 0f), new Vector3(2.5f, 2.5f, 5f), new Vector3(2.5f, -2.5f, 5f), }, Plane = new Plane(new Vector3(1, 0, 0), -2.5f), // x = 2.5 NumPoints = 4, SidesType = CullMode.None, }; Matrix4x4.Invert(worldTransform, out var inv); return new CellPhysics { WorldTransform = worldTransform, InverseWorldTransform = inv, Resolved = new Dictionary(), PortalPolygons = new Dictionary { [10] = portalPoly }, Portals = new[] { new PortalInfo(otherCellId: otherCellId, polygonId: 10, flags: 0), }, CellBSP = new CellBSPTree { Root = new CellBSPNode { Type = BSPNodeType.Leaf }, }, VisibleCellIds = visible ?? new HashSet(), }; } private static CellPhysics MakeLeafCell(Matrix4x4 worldTransform) { Matrix4x4.Invert(worldTransform, out var inv); return new CellPhysics { WorldTransform = worldTransform, InverseWorldTransform = inv, Resolved = new Dictionary(), CellBSP = new CellBSPTree { Root = new CellBSPNode { Type = BSPNodeType.Leaf }, }, }; } // ── Seeds ────────────────────────────────────────────────────────── [Fact] public void IndoorSeed_SphereAwayFromPortals_FloodsSeedOnly() { // Neighbor NOT cached: a leaf-root CellBSP would admit any sphere // (no geometry), so the seed-only case is pinned with the neighbor // unloaded — the unloaded-neighbor portal-side hint (PortalSide=true // for flags=0 → dist > -rad) does not fire at local x=-1 // (dist = -3.5 vs -rad = -0.52). var cache = new PhysicsDataCache(); cache.RegisterCellStructForTest(IndoorSeed, MakeCellWithPortalAtRightWall( Matrix4x4.Identity, otherCellId: 0x0101)); var set = CellTransit.BuildShadowCellSet( cache, IndoorSeed, One(new Vector3(-1f, 0f, 2.5f), 0.5f), 1, isStatic: false); Assert.Equal(new[] { IndoorSeed }, set); } [Fact] public void IndoorSeed_SphereOverlapsNeighborBsp_FloodsNeighbor() { var cache = new PhysicsDataCache(); cache.RegisterCellStructForTest(IndoorSeed, MakeCellWithPortalAtRightWall( Matrix4x4.Identity, otherCellId: 0x0101)); cache.RegisterCellStructForTest(NeighborCell, MakeLeafCell( Matrix4x4.CreateTranslation(5f, 0f, 0f))); var set = CellTransit.BuildShadowCellSet( cache, IndoorSeed, One(new Vector3(2.0f, 0f, 2.5f), 0.5f), 1, isStatic: false); Assert.Contains(IndoorSeed, set); Assert.Contains(NeighborCell, set); // Seed first — retail CELLARRAY order (seed added at index 0). Assert.Equal(IndoorSeed, set[0]); } [Fact] public void UnloadedIndoorSeed_ReturnsSeedByIdOnly_NoWalk() { // Retail: the unloaded seed is add_cell()'d by id with a null cell // pointer and the walk is skipped (gate on the seed pointer, // 0052b576). The object stays registered under its claimed cell // until re-flood on hydration. var cache = new PhysicsDataCache(); var set = CellTransit.BuildShadowCellSet( cache, IndoorSeed, One(Vector3.Zero, 0.5f), 1, isStatic: false); Assert.Equal(new[] { IndoorSeed }, set); } [Fact] public void OutdoorSeed_FloodsOverlappedLandcells_BlockCrossingMath() { var cache = new PhysicsDataCache(); // Near the east boundary of landcell grid(0,0): AddAllOutsideCells // adds the east neighbor grid(1,0) (block-local frame, anchor block). var set = CellTransit.BuildShadowCellSet( cache, 0xA9B40001u, One(new Vector3(23.8f, 12f, 0f), 0.5f), 1, isStatic: false); Assert.Contains(0xA9B40001u, set); Assert.True(set.Count >= 2, $"Expected >= 2 outdoor cells (seed + east neighbor), got {set.Count}"); Assert.All(set, id => Assert.True((id & 0xFFFFu) < 0x0100u)); } // ── The building bridge (outdoor → indoor at registration) ──────── [Fact] public void OutdoorSeed_BuildingOnLandcell_SphereInsideInterior_AddsInteriorCell() { // The door shape: an outdoor-positioned object at a doorway whose // flood sphere pokes into the vestibule. Retail writes it into the // vestibule's shadow_object_list at registration via // CLandCell::find_transit_cells → ... → check_building_transit. var cache = new PhysicsDataCache(); cache.RegisterCellStructForTest(NeighborCell, MakeLeafCell(Matrix4x4.Identity)); var sphere = One(new Vector3(12f, 12f, 0f), 0.5f); // Compute the landcell the sphere floods (seed cell of the walk) and // hang the building off it. var probe = CellTransit.BuildShadowCellSet( cache, 0xA9B40001u, sphere, 1, isStatic: false); uint floodedLandcell = probe[0]; cache.RegisterBuildingForTest(floodedLandcell, new BuildingPhysics { WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity, Portals = new[] { new BldPortalInfo(NeighborCell, otherPortalId: 0, flags: 0), }, }); var set = CellTransit.BuildShadowCellSet( cache, 0xA9B40001u, sphere, 1, isStatic: false); Assert.Contains(floodedLandcell, set); Assert.Contains(NeighborCell, set); } [Fact] public void OutdoorSeed_BuildingPortalNegativeId_InteriorNotAdded() { // C1's gate observed through the flood: a portal with // other_portal_id = -1 (wire 0xFFFF) never admits its interior cell // (CEnvCell::check_building_transit, 0x0052c5dc). var cache = new PhysicsDataCache(); cache.RegisterCellStructForTest(NeighborCell, MakeLeafCell(Matrix4x4.Identity)); var sphere = One(new Vector3(12f, 12f, 0f), 0.5f); var probe = CellTransit.BuildShadowCellSet( cache, 0xA9B40001u, sphere, 1, isStatic: false); uint floodedLandcell = probe[0]; cache.RegisterBuildingForTest(floodedLandcell, new BuildingPhysics { WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity, Portals = new[] { new BldPortalInfo(NeighborCell, otherPortalId: unchecked((short)0xFFFF), flags: 0), }, }); var set = CellTransit.BuildShadowCellSet( cache, 0xA9B40001u, sphere, 1, isStatic: false); Assert.DoesNotContain(NeighborCell, set); } // ── Exterior straddle from an indoor seed ────────────────────────── [Fact] public void IndoorSeed_ExteriorPortalStraddle_AddsOutsideCells() { // Cell with an EXTERIOR portal (0xFFFF) at x=2.5; sphere straddling // the plane → AddAllOutsideCells fires (retail straddle gate, // 0052c8e5-0052c92d + gate 0052c9d6). var cache = new PhysicsDataCache(); cache.RegisterCellStructForTest(IndoorSeed, MakeCellWithPortalAtRightWall( Matrix4x4.Identity, otherCellId: 0xFFFF)); var set = CellTransit.BuildShadowCellSet( cache, IndoorSeed, One(new Vector3(2.4f, 0f, 2.5f), 0.5f), 1, isStatic: false); Assert.Contains(IndoorSeed, set); Assert.Contains(set, id => (id & 0xFFFFu) < 0x0100u); } [Fact] public void IndoorSeed_SphereAwayFromExteriorPortal_NoOutsideCells() { var cache = new PhysicsDataCache(); cache.RegisterCellStructForTest(IndoorSeed, MakeCellWithPortalAtRightWall( Matrix4x4.Identity, otherCellId: 0xFFFF)); var set = CellTransit.BuildShadowCellSet( cache, IndoorSeed, One(new Vector3(-1f, 0f, 2.5f), 0.5f), 1, isStatic: false); Assert.Equal(new[] { IndoorSeed }, set); } // ── Static prune (do_not_load_cells) ─────────────────────────────── [Fact] public void StaticIndoorSeed_PrunesCellsOutsideStabList() { // The neighbor is flooded by sphere overlap but is NOT in the seed's // stab list → calc_cross_cells_static's do_not_load_cells prune // (0052b66e) drops it. {seed} survives unconditionally. var cache = new PhysicsDataCache(); cache.RegisterCellStructForTest(IndoorSeed, MakeCellWithPortalAtRightWall( Matrix4x4.Identity, otherCellId: 0x0101, visible: new HashSet())); cache.RegisterCellStructForTest(NeighborCell, MakeLeafCell( Matrix4x4.CreateTranslation(5f, 0f, 0f))); var sphere = One(new Vector3(2.0f, 0f, 2.5f), 0.5f); var dynamicSet = CellTransit.BuildShadowCellSet( cache, IndoorSeed, sphere, 1, isStatic: false); Assert.Contains(NeighborCell, dynamicSet); var staticSet = CellTransit.BuildShadowCellSet( cache, IndoorSeed, sphere, 1, isStatic: true); Assert.Equal(new[] { IndoorSeed }, staticSet); } [Fact] public void StaticIndoorSeed_KeepsCellsInStabList() { var cache = new PhysicsDataCache(); cache.RegisterCellStructForTest(IndoorSeed, MakeCellWithPortalAtRightWall( Matrix4x4.Identity, otherCellId: 0x0101, visible: new HashSet { NeighborCell })); cache.RegisterCellStructForTest(NeighborCell, MakeLeafCell( Matrix4x4.CreateTranslation(5f, 0f, 0f))); var staticSet = CellTransit.BuildShadowCellSet( cache, IndoorSeed, One(new Vector3(2.0f, 0f, 2.5f), 0.5f), 1, isStatic: true); Assert.Contains(IndoorSeed, staticSet); Assert.Contains(NeighborCell, staticSet); } [Fact] public void ZeroSpheres_ReturnsEmpty() { var cache = new PhysicsDataCache(); var set = CellTransit.BuildShadowCellSet( cache, IndoorSeed, System.Array.Empty(), 0, isStatic: false); Assert.Empty(set); } }