acdream/tests/AcDream.Content.Tests/InstalledSetupBspPrimitiveDispatchTests.cs
Erik e6457cc849 fix(physics): close the AP-156 fix review — real containment oracle, type-level invariant, AP-158
Both review lenses PASSED; this is the cleanup, not a rescue. Evidence:
docs/research/2026-08-06-ap156-review-closure.md (the review itself is
committed alongside it as the received artifact).

R1 — the load-bearing containment test could not fail. Its truth and flood
values were two hand-copies of the same expression over the same part set,
so the shortfall was algebraically identically zero for any DAT input. The
oracle is now PHYSICS-POLYGON VERTICES — a different DAT field from the
bounding sphere the builder emits, so the two sides can genuinely disagree.
Sabotage-verified three ways after full cleans: dropping the bounds centre
in production reddens it (428 Setups, worst 35.869 m on 0x0200129A, matching
an independent out-of-repo sweep exactly); dropping only the scale on the
centre reddens it (326); and corrupting the TEST's own bounds oracle reddens
it (467) where under the shipped oracle that same corruption was invisible
by algebra. Renamed accordingly. A6's stale "cap control" comment corrected:
that loop is the test's own uncapped re-implementation and cannot observe a
cap regression — the cap is covered in Core.

R2 — the population was understated. 172 is AP-152's DISPATCH population;
AP-156's is 530 BSP-bearing Setups, of which 525 have a flood sphere move
and 428 fail vertex containment before the fix (412 at a 1 cm tolerance —
the review's figure; the gap is 16 Setups between 1.4 mm and 10 mm, real
geometry). 0 fail after, at any tolerance down to zero. Corrected in the
AP-156 row, the section-3 header, the C5c handoff and two test docstrings.
Dated review artifacts are left as written — "170 of 172" was correct for
what they measured, and rewriting evidence to match a later measurement
loses provenance.

A1 — BoundsCenter = default reopened at the type what the commit closed at
the seam. Dropping the default alone would NOT have closed the review's own
scenario (a copied Cylinder call site would write Vector3.Zero explicitly
and stay green), so ShadowShape's constructor is now private and BSP shapes
are built only through ShadowShape.Bsp(..., FlatCollisionSphere localBounds),
which takes radius and centre as ONE value and scales them together. There
is no expression a caller can write that carries one and drops the other.
22 construction sites converted; the same sabotage now reddens 5 Core tests
where the review's sabotage A reached 4, because both BSP producers share
one scaling path.

A2 — #333 is real and bigger than filed, and its retail question is
answered. I disassembled CObjCell::find_obj_collisions @0x0052b750 from the
PDB-paired binary myself (check_exe_pdb.py MATCH) rather than inheriting the
claim: its only early-out is sphere_path.insert_type == INITIAL_PLACEMENT_
INSERT, then it calls FindObjCollisions on every unparented non-self shadow
object UNCONDITIONALLY. Retail has NO distance pre-filter, so acdream's
"+ movement + 2f" reach filter is an invention with no register row — filed
as AP-158, carrying the disassembly, the F_EPSILON = 0.0002 m contrast, and
the measured blast radius (118 of 477 unique installed physics-BSP GfxObjs
exceed its ~2.5 m budget, 46 exceed 5 m). Active AP rows 109 -> 110.

Recorded prominently in three places a reader will hit: TALL PROPS MAY SHOW
NO VISIBLE CHANGE UNTIL #333 LANDS, and a null result at the connected gate
is EXPECTED, not evidence against AP-156.

LOW items. R3: the comment claiming the cited evidence justified the whole
cap line is corrected, but int.MaxValue on the sorting-sphere branch stays —
capping at 1 would take Spheres[0], and retail's one sphere is
CSetup::sorting_sphere, a different DAT field; capping keeps the wrong field
AND flips the substitution under-inclusive (#98/#168 direction). AP-157
already owns it. R4: acdream scales the flood sphere where retail's
find_transit_cells never reads gfxobj_scale — added as a second residual on
AP-156. R5: retail's slack constant carried into AP-158 and #333. A3: the
per-call delegate allocation is back to a cached field, still derived from
the single bounds resolver. A5: noted; b52967de's message cannot be amended.

Gates: all 44 bin/obj deleted before every verdict-deciding build, each test
run gated on a verified "Build succeeded" in the same invocation. Release
build 0 errors / 21 pre-existing warnings. Complete suite 11,208 passed /
4 skipped / 0 failed — reconciles exactly with the e2b2d04c baseline; one
test renamed, none added, removed or skipped. Nothing conflated with the
known load-sensitive flakes #302 / #308 / #321.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 16:44:48 +02:00

417 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-&gt;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> -&gt; <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 &amp; 0xFFFF &gt;= 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: Bounds);
// 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}.");
}
}