fix(physics): S1B — indoor cell membership admits on the part BOX, as retail does (#335, AP-159 narrowed)
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run

CellTransit.FindTransitCellsBox ports CEnvCell::find_transit_cells'
part-array overload @0x0052cae0 line-for-line: per-portal x per-part
order, the sphere cheap-reject at F_EPSILON+radius, the box admit whose
"Straddle or crossing-side" rule is exactly retail's `eax != side` under
the PDB Sidedness enum, leads-outside placed AFTER the admit, the
unconditional unloaded-neighbour hint without the sphere overload's
re-test, the destination box_intersects_cell gate with its deliberate
no-break, and add_all_outside_cells after the loop. The box-vs-cell BSP
traversal lands in BOTH representations behind the flat-authoritative
dispatcher with a graph referee whose 20,000 installed comparisons are
pinned by assertion (review F5), zero mismatch.

Dual Opus review: PASS on both lenses. The mandatory D0 pseudocode pass
caught that the contract's own supplementary note misattributed the box
block to the sphere overload — it belongs to a SECOND
check_building_transit overload @0x0052c680, whose portal-side
convention is INVERTED and whose admit differs; the pseudocode doc now
records that trap plus two byte confirmations made at review:
which_side @0x00444720 is strictly > eps for POSITIVE, and
intersect_box's in-plane early exit returns CROSSING(3)
(jp @0x005aa1bc -> mov eax,3), settling review items b1/b2 for the
future bridge porter. The bridge itself stays unported as AP-159's
explicit remainder.

The review also retired #335's severity premise honestly: "over-
inclusive only, never a missed one" is wrong at production shape ratios,
where the box (whole-vertex AABB) legitimately exceeds the sphere
(physics-polygon root sphere). Measured, both populations: rigged
(box << sphere) — 1,520 placements, 978 cells removed, 0 added;
production-ratio (box >= sphere) — 950 placements, 20 removed, 1 ADDED
through the loaded-neighbour gate, which is retail's direction, not a
defect. The no-op guard (review F4) asserts removal is nonzero so an
unwired admit cannot pass silently.

Process note: the implementer authored against this session's worktree
at bec5c69d, 25 commits stale — the recorded worktree-base class. All
six files were byte-identical between bases, the diff transplanted
losslessly, and every verdict-bearing run (referee, direction sweeps,
this clean-room) was re-executed on current main. S2's uncommitted
phase-1 edits were stashed for this landing so the suite verdicts
exactly one changeset.

Also untracks 341-slope-capture.jsonl (an accidental add) and
gitignores it.

Clean-room suite: 11,248 passed / 6 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-07 07:46:57 +02:00
parent 1b2580be4c
commit b3e43d22c9
14 changed files with 1600 additions and 4914 deletions

View file

@ -0,0 +1,511 @@
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;
/// <summary>
/// AP-159 / #335 (2026-08-07), Campaign S slice S1B, D2/D3. Conformance and
/// direction tests for <see cref="CellTransit.FindTransitCellsBox"/> — the
/// box-admitting INDOOR arm the D2 rewire installs in place of
/// <see cref="CellTransit.FindTransitCellsSphere"/> for
/// <see cref="CellTransit.BuildShadowCellSetFromParts"/>.
/// </summary>
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<ushort, ResolvedPolygon>(),
PortalPolygons = new Dictionary<ushort, ResolvedPolygon> { [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 ──
/// <summary>
/// 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 (<see cref="CellTransit.FindTransitCellsSphere"/>, still
/// directly callable and unmodified) admits cell B. Post-fix
/// (<see cref="CellTransit.FindTransitCellsBox"/>, what
/// <see cref="CellTransit.BuildShadowCellSetFromParts"/> now calls) does
/// not.
/// </summary>
[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<ushort, ResolvedPolygon>(),
};
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<uint>();
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<uint>();
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<uint> 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 ──────
/// <summary>
/// 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.
/// </summary>
[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<ushort, ResolvedPolygon>(),
};
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<uint>();
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<uint>();
CellTransit.FindTransitCellsBox(
cache, cellA, currentCellId: 0xA9B40100u,
box, new[] { sphere }, postFixCandidates, out bool postFixExitOutside);
Assert.Contains(0xA9B40101u, postFixCandidates);
Assert.False(postFixExitOutside);
}
/// <summary>
/// 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 <c>sphere_intersects_cell</c> 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
/// <see cref="CollisionTraversal.BoxIntersectsCell"/>'s loaded path.
/// </summary>
[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<ushort, ResolvedPolygon>(),
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<uint>();
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<uint>();
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 ──────────────
/// <summary>
/// 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.
/// </summary>
[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<CellPhysics>(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<uint>();
CellTransit.FindTransitCellsSphere(
cache, cell, cellId, worldPartPos, sphereRadius,
preFix, out _);
var postFix = new HashSet<uint>();
CellTransit.FindTransitCellsBox(
cache, cell, cellId, boxes, new[] { sphere },
postFix, out _);
objectsSwept++;
var removed = new List<uint>();
var added = new List<uint>();
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<uint>();
CellTransit.FindTransitCellsSphere(
cache, cell, cellId, worldPartPos, sphereRadius, preFix, out _);
var postFix = new HashSet<uint>();
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);
}