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>
299 lines
12 KiB
C#
299 lines
12 KiB
C#
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);
|
|
}
|