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;
///
/// AP-152 population + behaviour proof over the installed client_portal.dat.
///
///
/// Retail dispatches a Setup's collision geometry EXCLUSIVELY, BSP first, at
/// both consumers: CPhysicsObj::FindObjCollisions @0x0050f050 tests
/// HAS_PHYSICS_BSP_PS at 0x0050f165 and leaves the BSP branch
/// through the unconditional 0x0050f19d jmp 0x50f2b0, past both the
/// CylSphere loop (0x50f1a2) and the Sphere loop (0x50f21d); and
/// CPhysicsObj::calc_cross_cells @0x00515230 tests the same flag at
/// 0x00515285 and routes to CPhysicsObj::find_bbox_cell_list
/// @0x00510fc0 at 0x0051528f jne 0x515305, never reaching its
/// cylsphere (0x005152d1) or sorting-sphere (0x005152fb) branches.
///
///
///
/// This sweep pins the affected population and asserts that
/// emits NO primitive for any of
/// it. Retail derives the dispatch flag from the parts themselves
/// (CPartArray::CacheHasPhysicsBSP @0x00518110 ORs 0x10000 on the first
/// part whose gfxobj->physics_bsp is non-null), which is exactly the
/// predicate used here.
///
///
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();
bool HasPhysicsBsp(uint gfxObjId)
{
if (physicsBspCache.TryGetValue(gfxObjId, out bool cached))
return cached;
bool result =
dats.Portal.TryGet(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();
foreach (uint id in dats.GetAllIdsOfType())
{
if (!dats.Portal.TryGet(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 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
///
/// 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.
///
///
/// 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 (GfxObj.PhysicsPolygons -> GfxObj.VertexArray)
/// 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.
///
///
///
/// 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 (CellTransit.BuildShadowCellSet routes every candidate with
/// id & 0xFFFF >= 0x0100 through
/// FindTransitCellsSphere), 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.
///
///
///
/// Retail: CGfxObj::physics_sphere ([gfxobj+0x74]) is
/// assigned BSPTREE::GetSphere(physics_bsp) @0x005397e0 — the root
/// BSPNODE's CSphere, past its 4-byte vftable — and
/// CEnvCell::find_transit_cells @0x0052cae0, the part-array
/// overload reached from CPhysicsObj::find_bbox_cell_list
/// @0x00510fc0 via CPartArray::calc_cross_cells_static @0x00518160,
/// transforms that sphere's CENTRE through the part's own Position at
/// [part+0x30] before reading its radius at [esi+0xc].
///
///
[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();
var vertexCache = new Dictionary();
FlatCollisionSphere? Bounds(uint gfxObjId)
{
if (boundsCache.TryGetValue(gfxObjId, out FlatCollisionSphere? cached))
return cached;
FlatCollisionSphere? result = null;
Vector3[] vertices = [];
if (dats.Portal.TryGet(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();
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();
foreach (uint id in dats.GetAllIdsOfType())
{
if (!dats.Portal.TryGet(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();
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 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}.");
}
}