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