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 sweep that reproduced the geometry by hand
// rather than calling ShadowShapeBuilder, and independently reproduced by
// the AP-152 retail reviewer's own sweep. NOT derived from the code under
// test.
//
// OffCentreParts is the population control: without it, a build in which
// every BSP root sphere happened to sit at its part origin would satisfy
// the containment claim vacuously.
// WouldFailIfOriginDiscarded is the DEFECT control: it re-runs the
// pre-fix composition (radius carried, root-sphere origin dropped) and
// pins how many of the affected Setups it breaks. If that number ever
// goes to zero the fixture population has stopped exercising the field
// and the containment assertion below has stopped meaning anything.
private const int ExpectedPhysicsBspParts = 973;
private const int ExpectedOffCentreParts = 376; // |origin| > radius/2
private const int ExpectedWouldFailIfOriginDiscarded = 170; // of 172
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.
///
///
/// 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: over the 172
/// AP-152 Setups that failed to contain the object's own BSP sphere for
/// 170 of them, worst shortfall 9.911 m (Setup 0x02000255, whose single
/// part's root sphere sits 9.911 m above the part origin). 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_ContainTheirOwnBoundingSpheres()
{
string? datDir = ContentConformanceDats.ResolveDatDir();
if (datDir is null)
return;
using var dats = new DatCollection(datDir, DatAccessType.Read);
var boundsCache = new Dictionary();
FlatCollisionSphere? Bounds(uint gfxObjId)
{
if (boundsCache.TryGetValue(gfxObjId, out FlatCollisionSphere? cached))
return cached;
FlatCollisionSphere? result = null;
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);
}
boundsCache[gfxObjId] = result;
return result;
}
const float EntScale = 1.75f; // not 1: a dropped scale must show up
int bspParts = 0;
int offCentreParts = 0;
int affected = 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 root sphere 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 Centre, float Radius)>();
for (int i = 0; i < setup.Parts.Count; i++)
{
FlatCollisionSphere? b = Bounds((uint)setup.Parts[i]);
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;
}
truth.Add((
(partOrigin + Vector3.Transform(b.Value.Origin, partRot)) * EntScale,
b.Value.Radius * EntScale));
}
if (truth.Count == 0) continue;
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; }
if (hasCylinder || (setup.CylSpheres.Count == 0 && hasSphere))
affected++;
// Production emission, through the production bounds seam.
IReadOnlyList 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.
// EVERY BSP shape contributes: retail's BSP branch has no
// sphere cap (the 10-clamp at 0x0052ba21 is inside the cylsphere
// overload only), and 7 installed Setups carry more than 10
// physics-BSP parts — capping here would silently exclude their
// tail from the containment claim below.
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 tc, float tr) in truth)
{
float best = float.MaxValue;
foreach ((Vector3 fc, float fr) in spheres)
{
float need = (tc - fc).Length() + tr - fr;
if (need < best) best = need;
}
if (best > worst) worst = best;
}
return worst;
}
const float Tolerance = 1e-3f;
float shortfall = Shortfall(flood);
if (shortfall > Tolerance)
{
uncontained.Add(id);
if (shortfall > worstShortfall)
{
worstShortfall = shortfall;
worstShortfallSetup = id;
}
}
if ((hasCylinder || (setup.CylSpheres.Count == 0 && hasSphere))
&& Shortfall(floodIfOriginDiscarded) > Tolerance)
{
wouldFailIfOriginDiscarded++;
}
}
// Population + defect controls first.
Assert.Equal(ExpectedPhysicsBspParts, bspParts);
Assert.Equal(ExpectedOffCentreParts, offCentreParts);
Assert.Equal(ExpectedAffected, affected);
Assert.Equal(ExpectedWouldFailIfOriginDiscarded, wouldFailIfOriginDiscarded);
// Cap control: the deepest installed part array is 49 BSP shapes, so
// the containment claim genuinely covers Setups past the retired
// 10-sphere clamp rather than stopping short of them.
Assert.Equal(ExpectedDeepestBspPartArray, mostBspShapesOnOneSetup);
// The fact.
Assert.True(
uncontained.Count == 0,
$"{uncontained.Count} Setups flood from a sphere that does not contain "
+ $"their own physics-BSP bounding sphere; worst shortfall "
+ $"{worstShortfall.ToString("F3", CultureInfo.InvariantCulture)} m on "
+ $"Setup 0x{worstShortfallSetup:X8}.");
}
}