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}."); } }