acdream had never implemented retail's SECOND cell-membership algorithm.
CPhysicsObj::calc_cross_cells @0x00515230 tests HAS_PHYSICS_BSP_PS at
0x00515285 and jumps (0x0051528f jne 0x515305) to find_bbox_cell_list
@0x00510fc0 for a BSP-bearing object; everything below that jump is the
OTHER algorithm, CObjCell::find_cell_list, and that is all we had. Every
object, BSP-bearing or not, was routed through it.
That path's outdoor expansion is a HARD CAP of one cell in each direction.
CellTransit.AddAllOutsideCells computes minRad = radius, maxRad = 24 - radius
and adds at most the eight neighbours of the sphere's own cell, so for any
radius >= 12 m both boundary tests are unconditionally true and the result is
exactly 3x3. Widening the radius or adding a second sphere is mechanically
incapable of adding a tenth cell. The user's live probe measured the
consequence directly: standing inside a Neftet formation, inCell=2 exempt=2
reached=0 -- the geometry was not a candidate at all.
The port. AddAllOutsideCellsFromParts is CLandCell::add_all_outside_cells
@0x00533360 plus add_cell_block @0x005331d0: base landcell from the FIRST
part's own adjust_to_outside, baseX/baseY within-block, each part's authored
CGfxObj::gfx_bound_box re-fit through all eight corners
(BBox::LocalToGlobal @0x005b2120), floor(v / square_length) where
square_length = 0x7c920c = 24.0f, four accumulators seeded to zero, ONE
rectangle unioned across all parts, FILLED, in GLOBAL lcoords so it crosses
landblocks freely, clamped only to [0, 0x7f8).
BuildShadowCellSetFromParts is find_bbox_cell_list's worklist.
RegisterMultiPart dispatches on the same flag retail does, and
BuildFloodSpheres' BSP arm is deleted rather than left unreachable.
Disassembled from the PDB-paired 2013-09-06 binary, not read from Binary
Ninja: BN mis-renders four separate constructs inside add_all_outside_cells
alone -- a dropped `and eax,0xffff` on baseX, a neg/sbb/and select shown as
identically zero, a wrong get_landcell argument, and both x87 flag tests as
`unimplemented {test ah}`.
ShadowPartGeometry pairs the BSP root sphere with the authored box so no
resolver can answer one and leave the other call site to synthesize a
substitute -- the AP-156 invariant applied a second time, since that split is
what produced AP-156 and then this. The box comes from
FlatGfxObjVisualBounds, already computed by exactly CGfxObj::init_end's
algorithm and already in the prepared package: no bake change, no DAT re-read.
Cost, measured over the installed DATs before any code was written: 1,258
physics-BSP GfxObjs, cells/object p50 4, p90 4, p99 12, max 49. The port is
CHEAPER than the old 3x3 = 9 for 98.97% of them. Row totals (shapes x cells)
over all 1,031 landblocks with BSP owners fall 97,173 -> 15,607 (0.161x);
dense Arwic 0xC6A9 falls 342 -> 43. One landblock more than doubles.
Precondition confirmed before pinning any expected cell set: 0x010046D8's box
is 96 m x 96 m about cell (2,2) = 0x87640013, which independently corroborates
the 3x3-centred-there diagnosis, and its rectangle does contain 0x87640011 and
0x87640019 -- the two cells the probe measured empty.
Register: AP-156's outdoor half CLOSED and its risk column CORRECTED (it read
"extra broadphase candidates, never a missed one", which generalised the indoor
direction to the whole row and is why #334 sat inside it unnoticed). AP-159 +
issue #335 file the unported indoor arm; AD-49 records the seed-time rectangle.
Issue #336 files a fourth load-sensitive test flake seen once during the gate.
Ten tests, every one sabotage-verified in both directions across eight
mutations (dispatch, 8-corner refit, floor-vs-truncation, union-vs-per-part,
map clamp, adjust guard, landblock clamp, box-path-for-everything). The
strongest is an installed-DAT replay of the user's own probe evidence.
Suite 11,208 -> 11,218 passed / 4 skipped / 0 failed; the +10 is exactly the
new tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
419 lines
19 KiB
C#
419 lines
19 KiB
C#
using System.Globalization;
|
|
using System.Numerics;
|
|
using AcDream.Core.Physics;
|
|
using DatReaderWriter;
|
|
using DatReaderWriter.DBObjs;
|
|
using DatReaderWriter.Enums;
|
|
using DatReaderWriter.Options;
|
|
using DatReaderWriter.Types;
|
|
|
|
namespace AcDream.Content.Tests;
|
|
|
|
/// <summary>
|
|
/// AP-152 population + behaviour proof over the installed client_portal.dat.
|
|
///
|
|
/// <para>
|
|
/// Retail dispatches a Setup's collision geometry EXCLUSIVELY, BSP first, at
|
|
/// both consumers: <c>CPhysicsObj::FindObjCollisions</c> @0x0050f050 tests
|
|
/// <c>HAS_PHYSICS_BSP_PS</c> at <c>0x0050f165</c> and leaves the BSP branch
|
|
/// through the unconditional <c>0x0050f19d jmp 0x50f2b0</c>, past both the
|
|
/// CylSphere loop (0x50f1a2) and the Sphere loop (0x50f21d); and
|
|
/// <c>CPhysicsObj::calc_cross_cells</c> @0x00515230 tests the same flag at
|
|
/// <c>0x00515285</c> and routes to <c>CPhysicsObj::find_bbox_cell_list</c>
|
|
/// @0x00510fc0 at <c>0x0051528f jne 0x515305</c>, never reaching its
|
|
/// cylsphere (0x005152d1) or sorting-sphere (0x005152fb) branches.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// This sweep pins the affected population and asserts that
|
|
/// <see cref="ShadowShapeBuilder.FromSetup"/> emits NO primitive for any of
|
|
/// it. Retail derives the dispatch flag from the parts themselves
|
|
/// (<c>CPartArray::CacheHasPhysicsBSP</c> @0x00518110 ORs 0x10000 on the first
|
|
/// part whose <c>gfxobj->physics_bsp</c> is non-null), which is exactly the
|
|
/// predicate used here.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class InstalledSetupBspPrimitiveDispatchTests
|
|
{
|
|
// EXTERNAL constants. The four bucket controls are the ones already
|
|
// committed by the AP-22 reachability sweep (measured by an independent
|
|
// raw client_portal.dat B-tree parse that validated itself by byte
|
|
// accounting); the affected counts were measured on 2026-08-06 by a
|
|
// separate DatReaderWriter sweep that reproduced FromSetup's steps rather
|
|
// than calling it.
|
|
//
|
|
// They are deliberately NOT derived from the predicates below. A broken
|
|
// enumeration, a wrong dat path, or a silently-empty decode all satisfy
|
|
// the affected-count claim vacuously and are caught only by the controls.
|
|
private const int ExpectedSetups = 5935;
|
|
private const int ExpectedWithCylinder = 678;
|
|
private const int ExpectedSphereOnlyNoCylinder = 3605;
|
|
private const int ExpectedWithoutAnyPrimitive = 1652;
|
|
|
|
private const int ExpectedAffected = 172;
|
|
private const int ExpectedAffectedCylinderBearing = 73;
|
|
private const int ExpectedAffectedSphereBearing = 99;
|
|
private const int ExpectedWithPhysicsBspPart = 530;
|
|
|
|
[Fact]
|
|
public void InstalledSetups_WithBothAPrimitiveAndAPhysicsBspPart_EmitOnlyBspShapes()
|
|
{
|
|
string? datDir = ContentConformanceDats.ResolveDatDir();
|
|
if (datDir is null)
|
|
return;
|
|
|
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
|
|
|
// Production physics-BSP predicate, FlatCollisionAssetBuilder.cs:377-380.
|
|
var physicsBspCache = new Dictionary<uint, bool>();
|
|
bool HasPhysicsBsp(uint gfxObjId)
|
|
{
|
|
if (physicsBspCache.TryGetValue(gfxObjId, out bool cached))
|
|
return cached;
|
|
bool result =
|
|
dats.Portal.TryGet<GfxObj>(gfxObjId, out GfxObj? gfx)
|
|
&& gfx is not null
|
|
&& gfx.Flags.HasFlag(GfxObjFlags.HasPhysics)
|
|
&& gfx.PhysicsBSP?.Root is not null
|
|
&& gfx.VertexArray is not null;
|
|
physicsBspCache[gfxObjId] = result;
|
|
return result;
|
|
}
|
|
|
|
int total = 0;
|
|
int withCylinder = 0;
|
|
int sphereOnly = 0;
|
|
int withoutPrimitive = 0;
|
|
int withPhysicsBspPart = 0;
|
|
int affected = 0;
|
|
int affectedCylinderBearing = 0;
|
|
int affectedSphereBearing = 0;
|
|
var affectedThatStillEmitAPrimitive = new List<uint>();
|
|
|
|
foreach (uint id in dats.GetAllIdsOfType<Setup>())
|
|
{
|
|
if (!dats.Portal.TryGet<Setup>(id, out Setup? setup) || setup is null)
|
|
continue;
|
|
total++;
|
|
|
|
bool hasCylinder = false;
|
|
foreach (var cyl in setup.CylSpheres)
|
|
{
|
|
if (cyl.Radius > 0f) { hasCylinder = true; break; }
|
|
}
|
|
bool hasSphere = false;
|
|
foreach (var sph in setup.Spheres)
|
|
{
|
|
if (sph.Radius > 0f) { hasSphere = true; break; }
|
|
}
|
|
// FromSetup step 2 is gated on CylSpheres.Count == 0, so a Setup
|
|
// with both only ever emitted Cylinders.
|
|
bool emitsSphere = setup.CylSpheres.Count == 0 && hasSphere;
|
|
|
|
if (hasCylinder) withCylinder++;
|
|
else if (emitsSphere) sphereOnly++;
|
|
else withoutPrimitive++;
|
|
|
|
bool hasBspPart = false;
|
|
foreach (uint partId in setup.Parts)
|
|
{
|
|
if (HasPhysicsBsp(partId)) { hasBspPart = true; break; }
|
|
}
|
|
if (hasBspPart) withPhysicsBspPart++;
|
|
|
|
if (!hasBspPart || !(hasCylinder || emitsSphere))
|
|
continue;
|
|
|
|
affected++;
|
|
if (hasCylinder) affectedCylinderBearing++;
|
|
else affectedSphereBearing++;
|
|
|
|
// The behaviour: for every affected Setup the production builder
|
|
// must emit BSP shapes only.
|
|
IReadOnlyList<ShadowShape> shapes =
|
|
ShadowShapeBuilder.FromSetup(setup, 1f, HasPhysicsBsp);
|
|
bool clean = shapes.Count > 0;
|
|
foreach (ShadowShape shape in shapes)
|
|
{
|
|
if (shape.CollisionType != ShadowCollisionType.BSP)
|
|
{
|
|
clean = false;
|
|
break;
|
|
}
|
|
}
|
|
if (!clean)
|
|
affectedThatStillEmitAPrimitive.Add(id);
|
|
}
|
|
|
|
// Positive controls first — without these the claim below is
|
|
// satisfiable by an empty enumeration.
|
|
Assert.Equal(ExpectedSetups, total);
|
|
Assert.Equal(ExpectedWithCylinder, withCylinder);
|
|
Assert.Equal(ExpectedSphereOnlyNoCylinder, sphereOnly);
|
|
Assert.Equal(ExpectedWithoutAnyPrimitive, withoutPrimitive);
|
|
Assert.Equal(ExpectedWithPhysicsBspPart, withPhysicsBspPart);
|
|
|
|
Assert.Equal(ExpectedAffected, affected);
|
|
Assert.Equal(ExpectedAffectedCylinderBearing, affectedCylinderBearing);
|
|
Assert.Equal(ExpectedAffectedSphereBearing, affectedSphereBearing);
|
|
|
|
Assert.Empty(affectedThatStillEmitAPrimitive);
|
|
}
|
|
|
|
// EXTERNAL constants for the containment sweep, measured 2026-08-06 by a
|
|
// scratch DatReaderWriter console program OUTSIDE the repo that resolves
|
|
// every quantity from client_portal.dat by hand and references no acdream
|
|
// assembly. NOT derived from the code under test.
|
|
//
|
|
// BspBearingSetups / PhysicsBspParts / OffCentreParts / PhysicsVertices are
|
|
// population controls: without them a broken enumeration, a wrong dat path,
|
|
// a silently-empty polygon decode, or a build in which every BSP root
|
|
// sphere happened to sit at its part origin would all satisfy the
|
|
// containment claim vacuously.
|
|
//
|
|
// WouldFailIfOriginDiscarded is the DEFECT control: it re-runs the pre-fix
|
|
// composition (radius carried, root-sphere origin dropped) against the same
|
|
// oracle and pins how many Setups it breaks. If that number ever goes to
|
|
// zero the fixture population has stopped exercising the field and the
|
|
// containment assertion has stopped meaning anything.
|
|
//
|
|
// NOTE ON THE POPULATION (AP-156 review finding R2). The defect population
|
|
// is NOT the 172 AP-152 Setups. 172 is the DISPATCH population — Setups
|
|
// carrying both a primitive and a physics-BSP part. After AP-152 every
|
|
// BSP-bearing Setup floods from its BSP shapes alone, so a discarded root
|
|
// origin mis-places the flood for all 530 of them. 525 have at least one
|
|
// flood sphere move; 428 fail vertex-level containment at the 1 mm
|
|
// tolerance below (412 at a 1 cm tolerance — the figure the review quotes).
|
|
private const int ExpectedPhysicsBspParts = 973;
|
|
private const int ExpectedBspBearingSetups = 530;
|
|
private const int ExpectedOffCentreParts = 376; // |origin| > radius/2
|
|
private const int ExpectedPhysicsVertices = 91689;
|
|
private const int ExpectedWouldFailIfOriginDiscarded = 428; // of 530
|
|
private const int ExpectedDeepestBspPartArray = 49; // Setup 0x02001A91
|
|
|
|
/// <summary>
|
|
/// AP-156. Every flood sphere acdream emits for a physics-BSP part must
|
|
/// CONTAIN that part's real collision geometry — and the oracle for "real
|
|
/// collision geometry" is the part's PHYSICS-POLYGON VERTICES, not its
|
|
/// bounding sphere.
|
|
///
|
|
/// <para>
|
|
/// That distinction is the point. The first version of this test compared
|
|
/// the emitted flood sphere against a hand-rebuilt copy of the same
|
|
/// bounding sphere from the same resolver, which made the shortfall
|
|
/// algebraically identically zero for any DAT input — a green test that
|
|
/// could not fail (review finding R1). Vertices come from a DIFFERENT DAT
|
|
/// field (<c>GfxObj.PhysicsPolygons</c> -> <c>GfxObj.VertexArray</c>)
|
|
/// than the bounding sphere the builder emits, so the assertion now has
|
|
/// something real to disagree with: any error in which sphere is read,
|
|
/// where it is placed, or how it is scaled shows up as an uncovered vertex.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// A GfxObj's physics BSP is authored in the GfxObj's own coordinates and
|
|
/// its root bounding sphere is usually not centred on that origin — 376
|
|
/// of the 973 installed physics-BSP parts sit further from it than half
|
|
/// their own radius, worst 20.762 m on a 27.708 m sphere (gfx 0x010036DD,
|
|
/// Setup 0x0200129A). acdream used to take the sphere's radius and drop
|
|
/// its origin, flooding from the part origin instead. Indoor floods are
|
|
/// 3-D (<c>CellTransit.BuildShadowCellSet</c> routes every candidate with
|
|
/// <c>id & 0xFFFF >= 0x0100</c> through
|
|
/// <c>FindTransitCellsSphere</c>), so a tall prop or door slab simply was
|
|
/// not registered in the EnvCells it occupies — never a broadphase
|
|
/// candidate there, the #98 / #168 class.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Retail: <c>CGfxObj::physics_sphere</c> (<c>[gfxobj+0x74]</c>) is
|
|
/// assigned <c>BSPTREE::GetSphere(physics_bsp)</c> @0x005397e0 — the root
|
|
/// <c>BSPNODE</c>'s <c>CSphere</c>, past its 4-byte vftable — and
|
|
/// <c>CEnvCell::find_transit_cells</c> @0x0052cae0, the part-array
|
|
/// overload reached from <c>CPhysicsObj::find_bbox_cell_list</c>
|
|
/// @0x00510fc0 via <c>CPartArray::calc_cross_cells_static</c> @0x00518160,
|
|
/// transforms that sphere's CENTRE through the part's own Position at
|
|
/// <c>[part+0x30]</c> before reading its radius at <c>[esi+0xc]</c>.
|
|
/// </para>
|
|
/// </summary>
|
|
[Fact]
|
|
public void InstalledSetups_BspFloodSpheres_ContainTheirOwnPhysicsPolygons()
|
|
{
|
|
string? datDir = ContentConformanceDats.ResolveDatDir();
|
|
if (datDir is null)
|
|
return;
|
|
|
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
|
|
|
// Two INDEPENDENT reads of the same GfxObj: the bounding sphere the
|
|
// builder is handed, and the physics-polygon vertices that are the
|
|
// truth it must cover. Only the sphere is fed to ShadowShapeBuilder.
|
|
var boundsCache = new Dictionary<uint, FlatCollisionSphere?>();
|
|
var vertexCache = new Dictionary<uint, Vector3[]>();
|
|
FlatCollisionSphere? Bounds(uint gfxObjId)
|
|
{
|
|
if (boundsCache.TryGetValue(gfxObjId, out FlatCollisionSphere? cached))
|
|
return cached;
|
|
FlatCollisionSphere? result = null;
|
|
Vector3[] vertices = [];
|
|
if (dats.Portal.TryGet<GfxObj>(gfxObjId, out GfxObj? gfx)
|
|
&& gfx is not null
|
|
&& gfx.Flags.HasFlag(GfxObjFlags.HasPhysics)
|
|
&& gfx.PhysicsBSP?.Root is not null
|
|
&& gfx.VertexArray is not null
|
|
&& gfx.PhysicsBSP.Root.BoundingSphere is { } bs)
|
|
{
|
|
result = new FlatCollisionSphere(bs.Origin, bs.Radius);
|
|
var collected = new List<Vector3>();
|
|
foreach (var polygon in gfx.PhysicsPolygons.Values)
|
|
{
|
|
foreach (var vertexId in polygon.VertexIds)
|
|
{
|
|
if (gfx.VertexArray.Vertices.TryGetValue(
|
|
(ushort)vertexId, out var vertex))
|
|
{
|
|
collected.Add(vertex.Origin);
|
|
}
|
|
}
|
|
}
|
|
vertices = collected.ToArray();
|
|
}
|
|
boundsCache[gfxObjId] = result;
|
|
vertexCache[gfxObjId] = vertices;
|
|
return result;
|
|
}
|
|
|
|
const float EntScale = 1.75f; // not 1: a dropped scale must show up
|
|
const float Tolerance = 1e-3f;
|
|
int bspParts = 0;
|
|
int bspBearingSetups = 0;
|
|
int offCentreParts = 0;
|
|
int physicsVertices = 0;
|
|
int wouldFailIfOriginDiscarded = 0;
|
|
float worstShortfall = 0f;
|
|
uint worstShortfallSetup = 0u;
|
|
int mostBspShapesOnOneSetup = 0;
|
|
var uncontained = new List<uint>();
|
|
|
|
foreach (uint id in dats.GetAllIdsOfType<Setup>())
|
|
{
|
|
if (!dats.Portal.TryGet<Setup>(id, out Setup? setup) || setup is null)
|
|
continue;
|
|
|
|
// Independent oracle: resolve the placement frame from the raw
|
|
// Setup and place each part's TRUE physics polygons by hand.
|
|
AnimationFrame? placement = null;
|
|
if (setup.PlacementFrames.TryGetValue(Placement.Resting, out var resting))
|
|
placement = resting;
|
|
else if (setup.PlacementFrames.TryGetValue(Placement.Default, out var def))
|
|
placement = def;
|
|
else foreach (var kvp in setup.PlacementFrames) { placement = kvp.Value; break; }
|
|
|
|
var truth = new List<Vector3>();
|
|
for (int i = 0; i < setup.Parts.Count; i++)
|
|
{
|
|
uint partGfxObjId = (uint)setup.Parts[i];
|
|
FlatCollisionSphere? b = Bounds(partGfxObjId);
|
|
if (b is null) continue;
|
|
bspParts++;
|
|
if (b.Value.Origin.Length() > b.Value.Radius / 2f)
|
|
offCentreParts++;
|
|
|
|
Vector3 partOrigin = Vector3.Zero;
|
|
Quaternion partRot = Quaternion.Identity;
|
|
if (placement is not null && i < placement.Frames.Count)
|
|
{
|
|
partOrigin = placement.Frames[i].Origin;
|
|
partRot = placement.Frames[i].Orientation;
|
|
}
|
|
foreach (Vector3 vertex in vertexCache[partGfxObjId])
|
|
{
|
|
truth.Add(
|
|
(partOrigin + Vector3.Transform(vertex, partRot)) * EntScale);
|
|
}
|
|
}
|
|
if (truth.Count == 0) continue;
|
|
bspBearingSetups++;
|
|
physicsVertices += truth.Count;
|
|
|
|
// Production emission, through the production bounds seam.
|
|
IReadOnlyList<ShadowShape> shapes = ShadowShapeBuilder.FromSetup(
|
|
setup,
|
|
EntScale,
|
|
id => Bounds(id) is not null,
|
|
physicsBspBounds: id => Bounds(id) is { } sphere
|
|
? ShadowPartGeometry.Create(sphere, null)
|
|
: (ShadowPartGeometry?)null);
|
|
|
|
// ShadowObjectRegistry.BuildFloodSpheres' composition, at an
|
|
// entity placed at the world origin with identity rotation.
|
|
// Deliberately UNCAPPED, matching production: retail's BSP branch
|
|
// has no sphere cap (the 10-clamp at 0x0052ba21 is inside the
|
|
// cylsphere overload only). This loop is the test's own
|
|
// re-implementation, so it cannot observe a cap regression in
|
|
// BuildFloodSpheres — that is covered by
|
|
// ShadowObjectRegistryMultiPartTests
|
|
// .BuildFloodSpheres_CapsCylSpheresAtTenButNeverTheBspParts, which
|
|
// reddens under both cap sabotages. What the mostBspShapesOnOneSetup
|
|
// assertion below DOES prove is that the containment claim reaches
|
|
// Setups past the retired 10-sphere clamp rather than stopping short
|
|
// of them.
|
|
var flood = new List<(Vector3 Centre, float Radius)>();
|
|
var floodIfOriginDiscarded = new List<(Vector3 Centre, float Radius)>();
|
|
foreach (ShadowShape shape in shapes)
|
|
{
|
|
if (shape.CollisionType != ShadowCollisionType.BSP) continue;
|
|
flood.Add((
|
|
shape.LocalPosition
|
|
+ Vector3.Transform(shape.BoundsCenter, shape.LocalRotation),
|
|
shape.Radius));
|
|
floodIfOriginDiscarded.Add((shape.LocalPosition, shape.Radius));
|
|
}
|
|
if (flood.Count > mostBspShapesOnOneSetup)
|
|
mostBspShapesOnOneSetup = flood.Count;
|
|
|
|
float Shortfall(List<(Vector3 Centre, float Radius)> spheres)
|
|
{
|
|
float worst = 0f;
|
|
foreach (Vector3 point in truth)
|
|
{
|
|
float best = float.MaxValue;
|
|
foreach ((Vector3 fc, float fr) in spheres)
|
|
{
|
|
float need = (point - fc).Length() - fr;
|
|
if (need < best) best = need;
|
|
}
|
|
if (best > worst) worst = best;
|
|
}
|
|
return worst;
|
|
}
|
|
|
|
float shortfall = Shortfall(flood);
|
|
if (shortfall > Tolerance)
|
|
{
|
|
uncontained.Add(id);
|
|
if (shortfall > worstShortfall)
|
|
{
|
|
worstShortfall = shortfall;
|
|
worstShortfallSetup = id;
|
|
}
|
|
}
|
|
if (Shortfall(floodIfOriginDiscarded) > Tolerance)
|
|
wouldFailIfOriginDiscarded++;
|
|
}
|
|
|
|
// Population + defect controls first.
|
|
Assert.Equal(ExpectedPhysicsBspParts, bspParts);
|
|
Assert.Equal(ExpectedBspBearingSetups, bspBearingSetups);
|
|
Assert.Equal(ExpectedOffCentreParts, offCentreParts);
|
|
Assert.Equal(ExpectedPhysicsVertices, physicsVertices);
|
|
Assert.Equal(ExpectedWouldFailIfOriginDiscarded, wouldFailIfOriginDiscarded);
|
|
Assert.Equal(ExpectedDeepestBspPartArray, mostBspShapesOnOneSetup);
|
|
|
|
// The fact.
|
|
Assert.True(
|
|
uncontained.Count == 0,
|
|
$"{uncontained.Count} Setups flood from spheres that do not contain "
|
|
+ $"their own physics-polygon geometry; worst shortfall "
|
|
+ $"{worstShortfall.ToString("F3", CultureInfo.InvariantCulture)} m on "
|
|
+ $"Setup 0x{worstShortfallSetup:X8}.");
|
|
}
|
|
}
|