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,299 @@
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;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// AP-159 / #335 (2026-08-07), Campaign S slice S1B, D1. Exact differential
/// referee for the new <c>BoxIntersectsCellBsp</c> traversal, shipped in both
/// representations (<see cref="BSPQuery.BoxIntersectsCellBsp"/> over the
/// graph, <see cref="FlatBspQuery.BoxIntersectsCellBsp"/> over the flat
/// production shadow) per the Slice I4/I5 house rule: a new traversal in two
/// representations ships with an exact differential referee, same inputs
/// through both, asserting identical verdicts — following the pattern in
/// <see cref="FlatBspQueryDifferentialTests"/>.
/// </summary>
public sealed class BoxIntersectsCellBspDifferentialTests
{
[Fact]
public void NullRoot_LeafRoot_ReturnTrue_BothRepresentations()
{
FlatCellContainmentBsp emptyFlat =
FlatCollisionAssetBuilder.FlattenCellContainmentBsp(null);
Assert.True(BSPQuery.BoxIntersectsCellBsp(
null, new Vector3(-1f), new Vector3(1f)));
Assert.True(FlatBspQuery.BoxIntersectsCellBsp(
emptyFlat, new Vector3(-1f), new Vector3(1f)));
var leaf = new CellBSPNode
{
Type = BSPNodeType.Leaf,
LeafIndex = 5,
};
FlatCellContainmentBsp leafFlat =
FlatCollisionAssetBuilder.FlattenCellContainmentBsp(leaf);
Assert.True(BSPQuery.BoxIntersectsCellBsp(
leaf, new Vector3(-1f), new Vector3(1f)));
Assert.True(FlatBspQuery.BoxIntersectsCellBsp(
leafFlat, new Vector3(-1f), new Vector3(1f)));
}
[Fact]
public void SingleSplittingPlane_UniformPositiveNegativeAndStraddle_MatchGraphAndRetailSemantics()
{
// One internal node: splitting plane x=0 (normal +X), PosNode a leaf
// (matching the point/sphere siblings' "PosNode is where the
// interior lives" shape), no NegNode consulted.
var leaf = new CellBSPNode { Type = BSPNodeType.Leaf, LeafIndex = 1 };
var root = new CellBSPNode
{
Type = BSPNodeType.BPIn,
SplittingPlane = new Plane(Vector3.UnitX, 0f),
PosNode = leaf,
};
FlatCellContainmentBsp flat =
FlatCollisionAssetBuilder.FlattenCellContainmentBsp(root);
// Box entirely positive (min.x > 0): admitted (descends to leaf -> true).
AssertBoxEqual(root, flat, new Vector3(1f, -1f, -1f), new Vector3(2f, 1f, 1f), expectTrue: true);
// Box entirely negative (max.x < -eps, well clear): rejected (false)
// — the case retail's box_intersects_cell_bsp actually distinguishes
// from the sphere/point siblings.
AssertBoxEqual(root, flat, new Vector3(-2f, -1f, -1f), new Vector3(-1f, 1f, 1f), expectTrue: false);
// Box straddling x=0: admitted (true) — a straddling box is never
// "entirely negative."
AssertBoxEqual(root, flat, new Vector3(-0.5f, -1f, -1f), new Vector3(0.5f, 1f, 1f), expectTrue: true);
// Exact epsilon boundary: max.x just inside -eps (entirely negative
// by the tiniest margin) vs just outside (straddling by the tiniest
// margin). F_EPSILON = 0.000199999995f (BSPQuery.BoxPlaneEpsilon).
const float eps = 0.000199999995f;
AssertBoxEqual(
root, flat,
new Vector3(-1f, -1f, -1f), new Vector3(-eps - 0.0001f, 1f, 1f),
expectTrue: false);
AssertBoxEqual(
root, flat,
new Vector3(-1f, -1f, -1f), new Vector3(-eps + 0.0001f, 1f, 1f),
expectTrue: true);
}
[Fact]
public void DeepChain_MultipleNodeTypesAndChildNullTermination_MatchGraphBits()
{
// A deep PosNode chain (mirrors the point/sphere differential's
// "Depth=256" coverage) with a mix of splitting-plane orientations
// (X, Y, Z, and a non-axis-aligned normal) so every node the box
// must pass through exercises a different plane, terminating in a
// leaf.
var leaf = new CellBSPNode { Type = BSPNodeType.Leaf, LeafIndex = 42 };
CellBSPNode graph = leaf;
Vector3[] normals =
[
Vector3.UnitX,
Vector3.UnitY,
Vector3.UnitZ,
Vector3.Normalize(new Vector3(1f, 1f, 1f)),
];
const int Depth = 200;
for (int i = 0; i < Depth; i++)
{
graph = new CellBSPNode
{
Type = BSPNodeType.BPIn,
SplittingPlane = new Plane(normals[i % normals.Length], 1_000f),
PosNode = graph,
};
}
FlatCellContainmentBsp flat =
FlatCollisionAssetBuilder.FlattenCellContainmentBsp(graph);
// Boxes far on the positive side of every plane in the chain
// (dist = Dot(N,p)+D; D=+1000 puts a box near the origin at
// dist≈+1000, deeply positive) — must reach the terminal leaf
// through every node.
AssertBoxEqual(graph, flat, new Vector3(-1f), new Vector3(1f), expectTrue: true);
// A box deeply negative along every axis (and therefore deeply
// negative against whichever of the four normals sits at the root
// of the chain) must reject immediately without ever reaching the
// terminal leaf.
AssertBoxEqual(
graph, flat,
new Vector3(-2000f, -2000f, -2000f), new Vector3(-1900f, -1900f, -1900f),
expectTrue: false);
}
[Fact]
public void RandomizedSyntheticSweep_ArbitraryBoxesAgainstBranchingTree_MatchGraphBits()
{
// A branching tree (unlike the linear chains above) built from a
// handful of axis-aligned splitting planes at different offsets, so
// a box can be admitted or rejected at different depths depending on
// its extent — closer to what an installed EnvCell's containment BSP
// actually looks like than a single linear chain.
var leafA = new CellBSPNode { Type = BSPNodeType.Leaf, LeafIndex = 1 };
var leafB = new CellBSPNode { Type = BSPNodeType.Leaf, LeafIndex = 2 };
var midY = new CellBSPNode
{
Type = BSPNodeType.BPIn,
SplittingPlane = new Plane(Vector3.UnitY, -3f),
PosNode = leafB,
};
var midX = new CellBSPNode
{
Type = BSPNodeType.BPIn,
SplittingPlane = new Plane(Vector3.UnitX, -3f),
PosNode = midY,
};
var root = new CellBSPNode
{
Type = BSPNodeType.BPIn,
SplittingPlane = new Plane(Vector3.UnitZ, -3f),
PosNode = midX,
};
_ = leafA; // referenced only to document the tree shape; unreachable via PosNode-only walk
FlatCellContainmentBsp flat =
FlatCollisionAssetBuilder.FlattenCellContainmentBsp(root);
var random = new Random(0x4150_3135);
for (int i = 0; i < 20_000; i++)
{
Vector3 a = new(
NextFloat(random, -6f, 6f),
NextFloat(random, -6f, 6f),
NextFloat(random, -6f, 6f));
Vector3 extent = new(
NextFloat(random, 0f, 4f),
NextFloat(random, 0f, 4f),
NextFloat(random, 0f, 4f));
Vector3 min = a;
Vector3 max = a + extent;
bool graphResult = BSPQuery.BoxIntersectsCellBsp(root, min, max);
bool flatResult = FlatBspQuery.BoxIntersectsCellBsp(flat, min, max);
Assert.True(
graphResult == flatResult,
$"iteration {i}: min={min}, max={max}, graph={graphResult}, flat={flatResult}.");
}
}
[Fact]
public void InstalledDat_RandomizedBoxSweepOverEnvCellContainmentBsps_HasZeroMismatch()
{
string? datDirectory = ConformanceDats.ResolveDatDir();
if (datDirectory is null)
return;
using var dats = new DatCollection(datDirectory, DatAccessType.Read);
var random = new Random(0x4230_5820);
int cellsSwept = 0;
int comparisons = 0;
foreach (uint cellId in new[]
{
0x8A02_016Eu,
0x8A02_017Au,
0xA9B4_013Fu,
0xA9B4_0150u,
0xA9B4_0159u,
0xA9B4_015Au,
0xA9B4_0161u,
0xA9B4_0162u,
0xA9B4_0164u,
0xA9B4_0166u,
})
{
var cache = new PhysicsDataCache();
ConformanceDats.LoadEnvCell(dats, cache, cellId);
CellPhysics source = Assert.IsType<CellPhysics>(
cache.GetCellStruct(cellId));
FlatCellContainmentBsp flatContainment =
FlatCollisionAssetBuilder.FlattenCellContainmentBsp(
source.CellBSP?.Root);
if (source.CellBSP?.Root is null)
continue;
cellsSwept++;
// Anchor boxes at the cell's own resolved physics-polygon
// vertices (same anchor strategy as
// FlatBspQueryDifferentialTests.InstalledDat_LargeRandomizedSweep)
// — this exercises boxes actually near the containment BSP's own
// splitting planes rather than boxes chosen independently of the
// cell's geometry.
Vector3[] anchors = source.Resolved.Count > 0
? source.Resolved.Values
.SelectMany(p => p.Vertices.ToArray())
.ToArray()
: [Vector3.Zero];
for (int iteration = 0; iteration < 2_000; iteration++)
{
Vector3 anchor = anchors[random.Next(anchors.Length)];
float halfExtent = (iteration % 9) switch
{
0 => BSPQuery.BoxPlaneEpsilon,
1 => 0.01f,
2 => 0.5f,
_ => NextFloat(random, 0.05f, 2.5f),
};
Vector3 jitter = new(
NextFloat(random, -1.5f, 1.5f),
NextFloat(random, -1.5f, 1.5f),
NextFloat(random, -1.5f, 1.5f));
Vector3 center = anchor + jitter;
Vector3 min = center - new Vector3(halfExtent);
Vector3 max = center + new Vector3(halfExtent);
bool graphResult = BSPQuery.BoxIntersectsCellBsp(
source.CellBSP?.Root, min, max);
bool flatResult = FlatBspQuery.BoxIntersectsCellBsp(
flatContainment, min, max);
comparisons++;
Assert.True(
graphResult == flatResult,
$"cell 0x{cellId:X8}, iteration {iteration}: " +
$"min={min}, max={max}, graph={graphResult}, flat={flatResult}.");
}
}
// Review F5 (2026-08-07): the counts are PINNED, not merely reported.
// The original `cellsSwept == 0 || comparisons > 0` let a run where 9
// of the 10 fixture cells lacked a containment BSP pass with a tenth
// of the claimed coverage. If a future DAT change breaks a fixture
// cell, this fails loudly and the fixture list gets re-picked — that
// is the correct outcome, not an inconvenience.
Console.WriteLine(
$"box-differential installed sweep: cellsSwept={cellsSwept} comparisons={comparisons}");
Assert.Equal(10, cellsSwept);
Assert.Equal(20_000, comparisons);
}
private static void AssertBoxEqual(
CellBSPNode? graph,
FlatCellContainmentBsp flat,
Vector3 min,
Vector3 max,
bool expectTrue)
{
bool graphResult = BSPQuery.BoxIntersectsCellBsp(graph, min, max);
bool flatResult = FlatBspQuery.BoxIntersectsCellBsp(flat, min, max);
Assert.Equal(expectTrue, graphResult);
Assert.Equal(expectTrue, flatResult);
Assert.Equal(graphResult, flatResult);
}
private static float NextFloat(Random random, float minimum, float maximum)
=> minimum + (float)random.NextDouble() * (maximum - minimum);
}