using System.Collections.Generic; using System.Linq; using System.Numerics; using AcDream.Core.Physics; using AcDream.Core.Tests.Conformance; using DatReaderWriter; using DatReaderWriter.Enums; using DatReaderWriter.Options; using DatReaderWriter.Types; using Xunit; namespace AcDream.Core.Tests.Physics; /// /// AP-159 / #335 (2026-08-07), Campaign S slice S1B, D2/D3. Conformance and /// direction tests for — the /// box-admitting INDOOR arm the D2 rewire installs in place of /// for /// . /// public sealed class CellTransitFindTransitCellsBoxTests { private static CellPhysics MakeCellWithPortalAtRightWall( Matrix4x4 worldTransform, uint otherCellId, ushort flags) { // Portal poly at local x=2.5 (right wall), normal +X. Same shape as // CellTransitFindTransitCellsSphereTests' fixture, so the sphere-only // "pre-fix" comparison below is directly reading the same geometry // the existing FindTransitCellsSphere conformance suite already // trusts. var portalPolyA = new ResolvedPolygon { Id = 10, 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] = portalPolyA }, Portals = new[] { new PortalInfo(otherCellId: (ushort)otherCellId, polygonId: 10, flags: flags), }, }; } private static CellBSPTree SinglePlaneCellBsp() { var leaf = new CellBSPNode { Type = BSPNodeType.Leaf }; return new CellBSPTree { Root = new CellBSPNode { // Local x >= 0 is inside this synthetic cell. Type = BSPNodeType.BPIn, SplittingPlane = new Plane(new Vector3(1f, 0f, 0f), 0f), PosNode = leaf, }, }; } // ── D3.1: pre-fix admits, post-fix does not, with in-session sabotage ── /// /// D3.1. A part whose SPHERE reaches the portal plane (passing the /// cheap-reject, same as the pre-existing "near portal" sphere fixture) /// but whose authored BOX is small and does not reach the plane at all. /// Pre-fix (, still /// directly callable and unmodified) admits cell B. Post-fix /// (, what /// now calls) does /// not. /// [Fact] public void SphereReachesPortal_BoxDoesNot_PreFixAdmitsPostFixRejects() { var cellA = MakeCellWithPortalAtRightWall(Matrix4x4.Identity, otherCellId: 0x0101, flags: 0); var cellBT = Matrix4x4.CreateTranslation(new Vector3(5f, 0f, 0f)); Matrix4x4.Invert(cellBT, out var cellBInv); // No CellBSP on cell B, deliberately: this exercises the SAME // unloaded-neighbour hint path CellTransitFindTransitCellsSphereTests. // SphereInsideCellA_NearPortal_AddsCellB relies on. Giving cell B a // containment BSP here would switch FindTransitCellsSphere onto the // sphere_intersects_cell branch instead, which this fixture's sphere // position was never designed to satisfy. var cellB = new CellPhysics { WorldTransform = cellBT, InverseWorldTransform = cellBInv, Resolved = new Dictionary(), }; var cache = new PhysicsDataCache(); cache.RegisterCellStructForTest(0xA9B40100u, cellA); cache.RegisterCellStructForTest(0xA9B40101u, cellB); // Same part origin as CellTransitFindTransitCellsSphereTests' // "near portal" case: local x=2.0, sphere radius=0.5 -> reaches // x=2.5 (the portal plane). The authored BOX around that same part // origin is +-0.1m, so its max.x = 2.1 - nowhere near the plane. var partWorldPos = new Vector3(2.0f, 0f, 2.5f); var sphere = new Sphere { Origin = partWorldPos, Radius = 0.5f }; var box = new ShadowPartBox[] { MakeBox(new Vector3(-0.1f), new Vector3(0.1f), partWorldPos, Quaternion.Identity), }; // Pre-fix: the sphere-only traversal still exists, unmodified. var preFixCandidates = new HashSet(); CellTransit.FindTransitCellsSphere( cache, cellA, currentCellId: 0xA9B40100u, partWorldPos, sphereRadius: 0.5f, preFixCandidates, out bool preFixExitOutside); Assert.Contains(0xA9B40101u, preFixCandidates); Assert.False(preFixExitOutside); // Post-fix: the box-admitting traversal the D2 rewire installs. var postFixCandidates = new HashSet(); CellTransit.FindTransitCellsBox( cache, cellA, currentCellId: 0xA9B40100u, box, new[] { sphere }, postFixCandidates, out bool postFixExitOutside); Assert.DoesNotContain(0xA9B40101u, postFixCandidates); Assert.False(postFixExitOutside); // End-to-end at the production entry point: BuildShadowCellSetFromParts // (which now calls FindTransitCellsBox internally) must NOT include // cell B either. IReadOnlyList endToEnd = CellTransit.BuildShadowCellSetFromParts( cache, seedCellId: 0xA9B40100u, box, new[] { sphere }, isStatic: false); Assert.DoesNotContain(0xA9B40101u, endToEnd); } // ── D3.2: inverse guard — a box that DOES cross admits, unchanged ────── /// /// D3.2. The inverse guard: a part whose box genuinely crosses the /// portal plane is admitted by BOTH the pre-fix sphere test and the /// post-fix box test — over-inclusion strictly shrinks the admitted set, /// it never drops a genuine crossing. /// [Fact] public void BoxCrossesPortal_AdmittedBeforeAndAfter_UnloadedNeighbour() { var cellA = MakeCellWithPortalAtRightWall(Matrix4x4.Identity, otherCellId: 0x0101, flags: 0); var cellBT = Matrix4x4.CreateTranslation(new Vector3(5f, 0f, 0f)); Matrix4x4.Invert(cellBT, out var cellBInv); // No CellBSP -- exercises the unloaded-neighbour hint path on the // pre-fix side and the unconditional load-hint add on the post-fix // side, same as SphereReachesPortal_BoxDoesNot_PreFixAdmitsPostFixRejects. var cellB = new CellPhysics { WorldTransform = cellBT, InverseWorldTransform = cellBInv, Resolved = new Dictionary(), }; var cache = new PhysicsDataCache(); cache.RegisterCellStructForTest(0xA9B40100u, cellA); cache.RegisterCellStructForTest(0xA9B40101u, cellB); var partWorldPos = new Vector3(2.0f, 0f, 2.5f); var sphere = new Sphere { Origin = partWorldPos, Radius = 0.5f }; // A box that genuinely spans past x=2.5 in cell A's local frame: // local box +-0.7m around the part origin -> max.x = 2.7. var box = new ShadowPartBox[] { MakeBox(new Vector3(-0.7f), new Vector3(0.7f), partWorldPos, Quaternion.Identity), }; var preFixCandidates = new HashSet(); CellTransit.FindTransitCellsSphere( cache, cellA, currentCellId: 0xA9B40100u, partWorldPos, sphereRadius: 0.5f, preFixCandidates, out bool preFixExitOutside); Assert.Contains(0xA9B40101u, preFixCandidates); Assert.False(preFixExitOutside); var postFixCandidates = new HashSet(); CellTransit.FindTransitCellsBox( cache, cellA, currentCellId: 0xA9B40100u, box, new[] { sphere }, postFixCandidates, out bool postFixExitOutside); Assert.Contains(0xA9B40101u, postFixCandidates); Assert.False(postFixExitOutside); } /// /// D3.2 (loaded-neighbour variant). Retail's part-array overload applies /// the SAME cheap-reject + box-admit test whether the destination is /// loaded or not (see docs/research/2026-08-07-ap159-pseudocode.md §1's /// "Structural difference" note) -- unlike the sphere overload, which /// skips straight to sphere_intersects_cell for a loaded /// neighbour with no admit gate at all. This fixture positions the box /// so it passes cell A's admit test AND genuinely lands inside cell B's /// real containment BSP, exercising /// 's loaded path. /// [Fact] public void BoxCrossesPortal_AdmittedBeforeAndAfter_LoadedNeighbourGate() { var cellA = MakeCellWithPortalAtRightWall(Matrix4x4.Identity, otherCellId: 0x0101, flags: 0); var cellBT = Matrix4x4.CreateTranslation(new Vector3(3f, 0f, 0f)); Matrix4x4.Invert(cellBT, out var cellBInv); var cellB = new CellPhysics { WorldTransform = cellBT, InverseWorldTransform = cellBInv, Resolved = new Dictionary(), CellBSP = SinglePlaneCellBsp(), }; var cache = new PhysicsDataCache(); cache.RegisterCellStructForTest(0xA9B40100u, cellA); cache.RegisterCellStructForTest(0xA9B40101u, cellB); // Cell A local dist = x-2.5 in [0.1, 0.7] -> uniformly positive, // past the portal. Cell B local dist = x-3.0 in [-0.4, 0.2] -> // straddles cell B's own containment plane at local x=0, so the // box genuinely lands inside cell B's volume. var partWorldPos = new Vector3(2.9f, 0f, 2.5f); var sphere = new Sphere { Origin = partWorldPos, Radius = 0.5f }; var box = new ShadowPartBox[] { MakeBox(new Vector3(-0.3f), new Vector3(0.3f), partWorldPos, Quaternion.Identity), }; var preFixCandidates = new HashSet(); CellTransit.FindTransitCellsSphere( cache, cellA, currentCellId: 0xA9B40100u, partWorldPos, sphereRadius: 0.5f, preFixCandidates, out bool preFixExitOutside); Assert.Contains(0xA9B40101u, preFixCandidates); Assert.False(preFixExitOutside); var postFixCandidates = new HashSet(); CellTransit.FindTransitCellsBox( cache, cellA, currentCellId: 0xA9B40100u, box, new[] { sphere }, postFixCandidates, out bool postFixExitOutside); Assert.Contains(0xA9B40101u, postFixCandidates); Assert.False(postFixExitOutside); } // ── D3.3: direction assertion over an installed-DAT sweep ────────────── /// /// D3.3. Over real installed EnvCells (real portals, real containment /// BSPs), for a randomized population of synthetic BSP-part placements /// near each portal (deliberately using an OVERSIZED sphere against a /// TIGHT authored box, mirroring the real-world AP-156 divergence /// pattern), the box-admitting membership set is a SUBSET of the /// sphere-only set for every swept object — never a superset. Reports /// objects swept, cells removed, cells added (must be zero) via the test /// output (xunit console capture); see the AP-159 implementation report /// for the exact counts from this run. /// [Fact] public void InstalledDat_RandomizedPartSweepNearRealPortals_PostFixMembershipIsSubsetOfPreFix() { string? datDirectory = ConformanceDats.ResolveDatDir(); if (datDirectory is null) return; using var dats = new DatCollection(datDirectory, DatAccessType.Read); var cache = new PhysicsDataCache(); uint[] cellIds = [ 0x8A02_016Eu, 0x8A02_017Au, 0xA9B4_013Fu, 0xA9B4_0150u, 0xA9B4_0159u, 0xA9B4_015Au, 0xA9B4_0161u, 0xA9B4_0162u, 0xA9B4_0164u, 0xA9B4_0166u, ]; foreach (uint cellId in cellIds) ConformanceDats.LoadEnvCell(dats, cache, cellId); var random = new Random(0x3335_5330); int objectsSwept = 0; int cellsRemovedTotal = 0; int cellsAddedTotal = 0; int objectsWithChangedMembership = 0; var worstExamples = new List<(string Object, int Before, int After, int Removed)>(); foreach (uint cellId in cellIds) { CellPhysics cell = Assert.IsType(cache.GetCellStruct(cellId)); if (cell.Portals.Count == 0 || cell.PortalPolygons is null) continue; foreach (PortalInfo portal in cell.Portals) { if (!cell.PortalPolygons.TryGetValue(portal.PolygonId, out ResolvedPolygon? portalPoly) || portalPoly.Vertices.Length == 0) { continue; } Vector3 localAnchor = Vector3.Zero; foreach (Vector3 v in portalPoly.Vertices) localAnchor += v; localAnchor /= portalPoly.Vertices.Length; for (int iteration = 0; iteration < 40; iteration++) { // Jitter the anchor along the portal's own local frame so // some placements land squarely on one side, some // straddle, and some sit right at the plane -- the // population a real object population would produce. Vector3 jitter = new( NextFloat(random, -0.6f, 0.6f), NextFloat(random, -0.6f, 0.6f), NextFloat(random, -0.6f, 0.6f)); Vector3 localPartPos = localAnchor + jitter; Vector3 worldPartPos = Vector3.Transform( localPartPos, cell.WorldTransform); // Deliberately oversized sphere vs a tight authored box -- // the AP-156 divergence pattern this contract closes. float sphereRadius = NextFloat(random, 0.3f, 1.2f); float boxHalfExtent = NextFloat(random, 0.02f, 0.25f); var sphere = new Sphere { Origin = worldPartPos, Radius = sphereRadius, }; var boxes = new ShadowPartBox[] { MakeBox( new Vector3(-boxHalfExtent), new Vector3(boxHalfExtent), worldPartPos, Quaternion.Identity), }; var preFix = new HashSet(); CellTransit.FindTransitCellsSphere( cache, cell, cellId, worldPartPos, sphereRadius, preFix, out _); var postFix = new HashSet(); CellTransit.FindTransitCellsBox( cache, cell, cellId, boxes, new[] { sphere }, postFix, out _); objectsSwept++; var removed = new List(); var added = new List(); foreach (uint id in preFix) if (!postFix.Contains(id)) removed.Add(id); foreach (uint id in postFix) if (!preFix.Contains(id)) added.Add(id); cellsRemovedTotal += removed.Count; cellsAddedTotal += added.Count; // Review F3 (2026-08-07): zero-added holds for THIS // population BY CONSTRUCTION (box rigged far smaller than // sphere, the AP-156 pattern). It is NOT a structural // invariant of the port: in production the box is the // whole-vertex-array AABB while the sphere bounds only // the physics polygons, so the real box can EXCEED the // real sphere and the loaded-neighbour gate can admit // cells the sphere test would not — which is retail's // behaviour, not a defect. The production-ratio // population below measures that direction honestly. Assert.True( added.Count == 0, $"cell 0x{cellId:X8} portal->0x{portal.OtherCellId:X4} " + $"iteration {iteration}: post-fix ADDED cell(s) " + $"{string.Join(",", added.Select(id => $"0x{id:X8}"))} " + "under a box RIGGED smaller than the sphere -- for this " + "population the admit must strictly shrink."); if (removed.Count > 0) { objectsWithChangedMembership++; string label = $"cell=0x{cellId:X8},portal->0x{portal.OtherCellId:X4}," + $"iter={iteration},pos=({worldPartPos.X:F2}," + $"{worldPartPos.Y:F2},{worldPartPos.Z:F2})," + $"r={sphereRadius:F3},box=+-{boxHalfExtent:F3}"; worstExamples.Add((label, preFix.Count, postFix.Count, removed.Count)); } } } } worstExamples.Sort((a, b) => b.Removed.CompareTo(a.Removed)); Assert.Equal(0, cellsAddedTotal); Assert.True(objectsSwept > 0, "installed-DAT sweep found no portals to test."); // Review F4 (2026-08-07): without this, the sweep passes identically // if the box admit is a NO-OP returning the sphere set bit-for-bit. // The rigged population guarantees real shrinkage exists in installed // data (41% of placements in the landing run), so zero here means the // rewire came unwired. Assert.True( cellsRemovedTotal > 0, $"the box admit removed no cells across {objectsSwept} rigged " + "placements — indistinguishable from an unwired no-op."); Console.WriteLine( $"direction sweep (rigged population): objectsSwept={objectsSwept} " + $"changed={objectsWithChangedMembership} removed={cellsRemovedTotal} " + $"added={cellsAddedTotal}"); // Review F3/F6 (2026-08-07): a SECOND population at production-like // ratios (box >= sphere — the whole-vertex-array AABB vs the // physics-BSP root sphere), plus a loaded-branch counter. No // zero-added assertion here — adds are RETAIL-CORRECT in this // direction; the numbers are printed so the register row's severity // stays honest, and the loaded-neighbour gate's reach is measured // rather than presumed. int prodSwept = 0, prodAdded = 0, prodRemoved = 0, loadedBranchHits = 0; var prodRandom = new Random(0x3335_5331); foreach (uint cellId in cellIds) { CellPhysics? cell = cache.GetCellStruct(cellId) as CellPhysics; if (cell is null || cell.Portals.Count == 0 || cell.PortalPolygons is null) continue; foreach (PortalInfo portal in cell.Portals) { if (!cell.PortalPolygons.TryGetValue(portal.PolygonId, out ResolvedPolygon? prodPoly) || prodPoly.Vertices.Length == 0) { continue; } Vector3 localAnchor = Vector3.Zero; foreach (Vector3 v in prodPoly.Vertices) localAnchor += v; localAnchor /= prodPoly.Vertices.Length; for (int iteration = 0; iteration < 25; iteration++) { Vector3 jitter = new( NextFloat(prodRandom, -0.6f, 0.6f), NextFloat(prodRandom, -0.6f, 0.6f), NextFloat(prodRandom, -0.6f, 0.6f)); Vector3 worldPartPos = Vector3.Transform( localAnchor + jitter, cell.WorldTransform); float sphereRadius = NextFloat(prodRandom, 0.2f, 0.6f); float boxHalfExtent = NextFloat(prodRandom, sphereRadius, sphereRadius * 2.5f); var sphere = new Sphere { Origin = worldPartPos, Radius = sphereRadius }; var boxes = new ShadowPartBox[] { MakeBox(new Vector3(-boxHalfExtent), new Vector3(boxHalfExtent), worldPartPos, Quaternion.Identity), }; var preFix = new HashSet(); CellTransit.FindTransitCellsSphere( cache, cell, cellId, worldPartPos, sphereRadius, preFix, out _); var postFix = new HashSet(); CellTransit.FindTransitCellsBox( cache, cell, cellId, boxes, new[] { sphere }, postFix, out _); prodSwept++; foreach (uint id in postFix) if (!preFix.Contains(id)) { prodAdded++; if (cache.GetCellStruct(id) is not null) loadedBranchHits++; } foreach (uint id in preFix) if (!postFix.Contains(id)) prodRemoved++; } } } Console.WriteLine( $"direction sweep (production-ratio population): swept={prodSwept} " + $"added={prodAdded} removed={prodRemoved} loadedBranchAdds={loadedBranchHits}"); Assert.True(prodSwept > 0); } private static ShadowPartBox MakeBox( Vector3 localMin, Vector3 localMax, Vector3 worldPosition, Quaternion worldRotation) { var shape = ShadowShape.Bsp( gfxObjId: 0x010046D8u, localPosition: Vector3.Zero, localRotation: Quaternion.Identity, scale: 1f, localGeometry: ShadowPartGeometry.Create( new FlatCollisionSphere(Vector3.Zero, 0.01f), new FlatGfxObjVisualBounds( localMin, localMax, (localMin + localMax) * 0.5f, ((localMax - localMin) * 0.5f).Length(), (localMax - localMin) * 0.5f))); return ShadowPartBox.FromShape(shape, worldPosition, worldRotation); } private static float NextFloat(Random random, float minimum, float maximum) => minimum + (float)random.NextDouble() * (maximum - minimum); }