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
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:
parent
1b2580be4c
commit
b3e43d22c9
14 changed files with 1600 additions and 4914 deletions
|
|
@ -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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue