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