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>
This commit is contained in:
Erik 2026-08-06 16:44:48 +02:00
parent e2b2d04cb5
commit e6457cc849
21 changed files with 1454 additions and 298 deletions

View file

@ -105,8 +105,10 @@ public sealed class LiveEntityCollisionBuilderTests
/// <c>CEnvCell::find_transit_cells</c> @0x0052cae0 transforms its centre
/// through the part's Position (<c>0x0052cb4c add eax,0x30</c>) before
/// reading the radius at <c>0x0052cb65</c>. Carrying only the radius is
/// AP-156: 170 of the 172 affected installed Setups then flood from a
/// sphere that does not contain their own collision geometry.
/// AP-156: 428 of the 530 installed BSP-bearing Setups then flood from
/// spheres that do not contain their own physics polygons. (The row as
/// filed said "170 of 172"; 172 is AP-152's DISPATCH population, not
/// AP-156's containment population — corrected at the fix review.)
/// </summary>
[Fact]
public void BspOnlyPart_UsesRealScaledPhysicsBoundingSphere()

View file

@ -81,14 +81,13 @@ public sealed class RemotePhysicsUpdaterTests
entity.Position,
entity.Rotation,
[
new ShadowShape(
ShadowShape.Cylinder(
0x01000001u,
new Vector3(1f, 0f, 0f),
Quaternion.Identity,
Scale: 1f,
CollisionType: ShadowCollisionType.Cylinder,
Radius: 0.25f,
CylHeight: 1f),
scale: 1f,
radius: 0.25f,
cylHeight: 1f),
],
state: (uint)PhysicsStateFlags.ReportCollisions,
flags: EntityCollisionFlags.None,

View file

@ -357,14 +357,13 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests
entity.Rotation,
new[]
{
new ShadowShape(
ShadowShape.Cylinder(
GfxId,
Vector3.UnitX,
Quaternion.Identity,
Scale: 1f,
CollisionType: ShadowCollisionType.Cylinder,
Radius: 0.5f,
CylHeight: 1f),
scale: 1f,
radius: 0.5f,
cylHeight: 1f),
},
state: 0u,
flags: EntityCollisionFlags.None,

View file

@ -364,14 +364,13 @@ public sealed class LiveEntityRuntimeTests
entity.Rotation,
new[]
{
new ShadowShape(
ShadowShape.Cylinder(
0x01000001u,
Vector3.UnitX,
Quaternion.Identity,
Scale: 1f,
CollisionType: ShadowCollisionType.Cylinder,
Radius: 0.5f,
CylHeight: 1f),
scale: 1f,
radius: 0.5f,
cylHeight: 1f),
},
state: (uint)PhysicsStateFlags.ReportCollisions,
flags: EntityCollisionFlags.None,
@ -475,14 +474,13 @@ public sealed class LiveEntityRuntimeTests
oldEntity.Rotation,
new[]
{
new ShadowShape(
ShadowShape.Cylinder(
0x01000001u,
Vector3.UnitX,
Quaternion.Identity,
Scale: 1f,
CollisionType: ShadowCollisionType.Cylinder,
Radius: 0.5f,
CylHeight: 1f),
scale: 1f,
radius: 0.5f,
cylHeight: 1f),
},
state: (uint)PhysicsStateFlags.ReportCollisions,
flags: EntityCollisionFlags.None,

View file

@ -161,27 +161,53 @@ public sealed class InstalledSetupBspPrimitiveDispatchTests
}
// 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.
// 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.
//
// 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.
// 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 ExpectedWouldFailIfOriginDiscarded = 170; // of 172
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.
/// 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
@ -189,12 +215,9 @@ public sealed class InstalledSetupBspPrimitiveDispatchTests
/// 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 (<c>CellTransit.BuildShadowCellSet</c> routes every candidate
/// with <c>id &amp; 0xFFFF &gt;= 0x0100</c> through
/// 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.
@ -212,7 +235,7 @@ public sealed class InstalledSetupBspPrimitiveDispatchTests
/// </para>
/// </summary>
[Fact]
public void InstalledSetups_BspFloodSpheres_ContainTheirOwnBoundingSpheres()
public void InstalledSetups_BspFloodSpheres_ContainTheirOwnPhysicsPolygons()
{
string? datDir = ContentConformanceDats.ResolveDatDir();
if (datDir is null)
@ -220,12 +243,17 @@ public sealed class InstalledSetupBspPrimitiveDispatchTests
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)
@ -234,15 +262,31 @@ public sealed class InstalledSetupBspPrimitiveDispatchTests
&& 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 affected = 0;
int physicsVertices = 0;
int wouldFailIfOriginDiscarded = 0;
float worstShortfall = 0f;
uint worstShortfallSetup = 0u;
@ -255,7 +299,7 @@ public sealed class InstalledSetupBspPrimitiveDispatchTests
continue;
// Independent oracle: resolve the placement frame from the raw
// Setup and place each part's TRUE root sphere by hand.
// 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;
@ -263,10 +307,11 @@ public sealed class InstalledSetupBspPrimitiveDispatchTests
placement = def;
else foreach (var kvp in setup.PlacementFrames) { placement = kvp.Value; break; }
var truth = new List<(Vector3 Centre, float Radius)>();
var truth = new List<Vector3>();
for (int i = 0; i < setup.Parts.Count; i++)
{
FlatCollisionSphere? b = Bounds((uint)setup.Parts[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)
@ -279,18 +324,15 @@ public sealed class InstalledSetupBspPrimitiveDispatchTests
partOrigin = placement.Frames[i].Origin;
partRot = placement.Frames[i].Orientation;
}
truth.Add((
(partOrigin + Vector3.Transform(b.Value.Origin, partRot)) * EntScale,
b.Value.Radius * EntScale));
foreach (Vector3 vertex in vertexCache[partGfxObjId])
{
truth.Add(
(partOrigin + Vector3.Transform(vertex, partRot)) * 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++;
bspBearingSetups++;
physicsVertices += truth.Count;
// Production emission, through the production bounds seam.
IReadOnlyList<ShadowShape> shapes = ShadowShapeBuilder.FromSetup(
@ -301,11 +343,17 @@ public sealed class InstalledSetupBspPrimitiveDispatchTests
// 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.
// 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)
@ -323,12 +371,12 @@ public sealed class InstalledSetupBspPrimitiveDispatchTests
float Shortfall(List<(Vector3 Centre, float Radius)> spheres)
{
float worst = 0f;
foreach ((Vector3 tc, float tr) in truth)
foreach (Vector3 point in truth)
{
float best = float.MaxValue;
foreach ((Vector3 fc, float fr) in spheres)
{
float need = (tc - fc).Length() + tr - fr;
float need = (point - fc).Length() - fr;
if (need < best) best = need;
}
if (best > worst) worst = best;
@ -336,7 +384,6 @@ public sealed class InstalledSetupBspPrimitiveDispatchTests
return worst;
}
const float Tolerance = 1e-3f;
float shortfall = Shortfall(flood);
if (shortfall > Tolerance)
{
@ -347,28 +394,23 @@ public sealed class InstalledSetupBspPrimitiveDispatchTests
worstShortfallSetup = id;
}
}
if ((hasCylinder || (setup.CylSpheres.Count == 0 && hasSphere))
&& Shortfall(floodIfOriginDiscarded) > Tolerance)
{
if (Shortfall(floodIfOriginDiscarded) > Tolerance)
wouldFailIfOriginDiscarded++;
}
}
// Population + defect controls first.
Assert.Equal(ExpectedPhysicsBspParts, bspParts);
Assert.Equal(ExpectedBspBearingSetups, bspBearingSetups);
Assert.Equal(ExpectedOffCentreParts, offCentreParts);
Assert.Equal(ExpectedAffected, affected);
Assert.Equal(ExpectedPhysicsVertices, physicsVertices);
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 "
$"{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}.");
}

View file

@ -921,7 +921,12 @@ public class DoorBugTrajectoryReplayTests
{
var phys = cache.GetGfxObj(s.GfxObjId);
float bspR = phys?.BoundingSphere?.Radius ?? 2f;
shapes.Add(s with { Radius = bspR });
shapes.Add(ShadowShape.Bsp(
s.GfxObjId,
s.LocalPosition,
s.LocalRotation,
s.Scale,
new FlatCollisionSphere(Vector3.Zero, bspR / s.Scale)));
}
else
{
@ -1103,23 +1108,20 @@ public class DoorBugTrajectoryReplayTests
// to the BSP slab; the cylinder is a small foot collider).
// entityWorldPos = BSP world pos so LocalPos=0 puts the BSP at
// the captured center. cellScope=0u mirrors production.
var bspShape = new ShadowShape(
GfxObjId: DoorGfxObjId,
LocalPosition: Vector3.Zero,
LocalRotation: Quaternion.Identity,
Scale: 1f,
CollisionType: ShadowCollisionType.BSP,
Radius: BspRadius,
CylHeight: 0f);
var bspShape = ShadowShape.Bsp(
gfxObjId: DoorGfxObjId,
localPosition: Vector3.Zero,
localRotation: Quaternion.Identity,
scale: 1f,
localBounds: new FlatCollisionSphere(Vector3.Zero, BspRadius));
var cylShape = new ShadowShape(
GfxObjId: 0u,
LocalPosition: CylWorldPos - BspWorldPos, // express cyl relative to entity origin
LocalRotation: Quaternion.Identity,
Scale: 1f,
CollisionType: ShadowCollisionType.Cylinder,
Radius: CylRadius,
CylHeight: CylHeight);
var cylShape = ShadowShape.Cylinder(
gfxObjId: 0u,
localPosition: CylWorldPos - BspWorldPos, // express cyl relative to entity origin
localRotation: Quaternion.Identity,
scale: 1f,
radius: CylRadius,
cylHeight: CylHeight);
engine.ShadowObjects.RegisterMultiPart(
entityId: DoorEntityId,

View file

@ -14,38 +14,31 @@ public class ShadowObjectRegistryMultiPartTests
private static IReadOnlyList<ShadowShape> DoorShapes() => new[]
{
new ShadowShape(
GfxObjId: 0u,
LocalPosition: new Vector3(0f, 0f, 0.018f),
LocalRotation: Quaternion.Identity,
Scale: 1.0f,
CollisionType: ShadowCollisionType.Cylinder,
Radius: 0.100f,
CylHeight: 0.200f),
new ShadowShape(
GfxObjId: 0x010044B5u,
LocalPosition: Vector3.Zero,
LocalRotation: Quaternion.Identity,
Scale: 1.0f,
CollisionType: ShadowCollisionType.BSP,
Radius: 2.0f,
CylHeight: 0f),
new ShadowShape(
GfxObjId: 0x010044B6u,
LocalPosition: Vector3.Zero,
LocalRotation: Quaternion.Identity,
Scale: 1.0f,
CollisionType: ShadowCollisionType.BSP,
Radius: 2.0f,
CylHeight: 0f),
new ShadowShape(
GfxObjId: 0x010044B6u,
LocalPosition: Vector3.Zero,
LocalRotation: Quaternion.Identity,
Scale: 1.0f,
CollisionType: ShadowCollisionType.BSP,
Radius: 2.0f,
CylHeight: 0f)
ShadowShape.Cylinder(
gfxObjId: 0u,
localPosition: new Vector3(0f, 0f, 0.018f),
localRotation: Quaternion.Identity,
scale: 1.0f,
radius: 0.100f,
cylHeight: 0.200f),
ShadowShape.Bsp(
gfxObjId: 0x010044B5u,
localPosition: Vector3.Zero,
localRotation: Quaternion.Identity,
scale: 1.0f,
localBounds: new FlatCollisionSphere(Vector3.Zero, 2.0f)),
ShadowShape.Bsp(
gfxObjId: 0x010044B6u,
localPosition: Vector3.Zero,
localRotation: Quaternion.Identity,
scale: 1.0f,
localBounds: new FlatCollisionSphere(Vector3.Zero, 2.0f)),
ShadowShape.Bsp(
gfxObjId: 0x010044B6u,
localPosition: Vector3.Zero,
localRotation: Quaternion.Identity,
scale: 1.0f,
localBounds: new FlatCollisionSphere(Vector3.Zero, 2.0f))
};
[Fact]
@ -134,10 +127,8 @@ public class ShadowObjectRegistryMultiPartTests
// Two shapes 30m apart in X — must span two outdoor 24m cells.
var shapes = new[]
{
new ShadowShape(0u, new Vector3( 0f, 0f, 0f), Quaternion.Identity, 1f,
ShadowCollisionType.Cylinder, 1f, 2f),
new ShadowShape(0u, new Vector3(30f, 0f, 0f), Quaternion.Identity, 1f,
ShadowCollisionType.Cylinder, 1f, 2f),
ShadowShape.Cylinder(0u, new Vector3( 0f, 0f, 0f), Quaternion.Identity, 1f, 1f, 2f),
ShadowShape.Cylinder(0u, new Vector3(30f, 0f, 0f), Quaternion.Identity, 1f, 1f, 2f),
};
reg.RegisterMultiPart(0x1u, new Vector3(12f, 12f, 50f), Quaternion.Identity,
shapes, 0u, EntityCollisionFlags.None, OffX, OffY, LbId);
@ -170,10 +161,8 @@ public class ShadowObjectRegistryMultiPartTests
var shapes = new[]
{
new ShadowShape(0u, new Vector3(0f, 0f, 0f), Quaternion.Identity, 1f,
ShadowCollisionType.Cylinder, 0.5f, 1f),
new ShadowShape(0u, new Vector3(1f, 0f, 0f), Quaternion.Identity, 1f,
ShadowCollisionType.Cylinder, 0.5f, 1f),
ShadowShape.Cylinder(0u, new Vector3(0f, 0f, 0f), Quaternion.Identity, 1f, 0.5f, 1f),
ShadowShape.Cylinder(0u, new Vector3(1f, 0f, 0f), Quaternion.Identity, 1f, 0.5f, 1f),
};
reg.RegisterMultiPart(movingEntityId, new Vector3(10f, 10f, 50f),
Quaternion.Identity, shapes, 0u,
@ -243,10 +232,14 @@ public class ShadowObjectRegistryMultiPartTests
return cells;
}
private static ShadowShape Cyl(float radius) => new(
GfxObjId: 0u, LocalPosition: Vector3.Zero, LocalRotation: Quaternion.Identity,
Scale: 1f, CollisionType: ShadowCollisionType.Cylinder,
Radius: radius, CylHeight: radius * 2f);
private static ShadowShape Cyl(float radius, Vector3 localPosition = default)
=> ShadowShape.Cylinder(
gfxObjId: 0u,
localPosition: localPosition,
localRotation: Quaternion.Identity,
scale: 1f,
radius: radius,
cylHeight: radius * 2f);
/// <summary>
/// A physics-BSP part shape. <paramref name="boundsCenter"/> defaults
@ -261,13 +254,15 @@ public class ShadowObjectRegistryMultiPartTests
float radius,
Vector3 boundsCenter = default,
Vector3 localPosition = default,
Quaternion localRotation = default) => new(
GfxObjId: 0x010044B5u,
LocalPosition: localPosition,
LocalRotation: localRotation == default ? Quaternion.Identity : localRotation,
Scale: 1f, CollisionType: ShadowCollisionType.BSP,
Radius: radius, CylHeight: 0f,
BoundsCenter: boundsCenter == default ? new Vector3(0f, 6f, 0f) : boundsCenter);
Quaternion localRotation = default)
=> ShadowShape.Bsp(
gfxObjId: 0x010044B5u,
localPosition: localPosition,
localRotation: localRotation == default ? Quaternion.Identity : localRotation,
scale: 1f,
localBounds: new FlatCollisionSphere(
boundsCenter == default ? new Vector3(0f, 6f, 0f) : boundsCenter,
radius));
private static List<uint> FloodCellsFor(params ShadowShape[] shapes)
{
@ -317,9 +312,12 @@ public class ShadowObjectRegistryMultiPartTests
///
/// <para>
/// acdream used to flood from the part ORIGIN with the sphere's radius.
/// Over the installed DAT that failed to contain the object's own BSP
/// sphere for 170 of the 172 AP-152 Setups, shortfall up to 9.911 m
/// (0x02000255) — under-inclusive membership, the #98 / #168 class.
/// Over the installed DAT that failed to contain the parts' own physics
/// polygons for 428 of the 530 BSP-bearing Setups, shortfall up to
/// 35.869 m at entity scale 1.75 (0x0200129A) — under-inclusive
/// membership, the #98 / #168 class. (172 is AP-152's DISPATCH
/// population; the AP-156 row's original "170 of 172" understated this
/// 2.5x and was corrected at the fix review.)
/// </para>
/// </summary>
[Fact]
@ -377,8 +375,8 @@ public class ShadowObjectRegistryMultiPartTests
var cyls = new ShadowShape[11];
for (int i = 0; i < 10; i++)
cyls[i] = Cyl(1f) with { LocalPosition = near };
cyls[10] = Cyl(1f) with { LocalPosition = far };
cyls[i] = Cyl(1f, near);
cyls[10] = Cyl(1f, far);
List<uint> cylCells = FloodCellsFor(cyls);
Assert.Contains(ownCell, bspCells);

View file

@ -967,14 +967,13 @@ public class ShadowObjectRegistryTests
};
}
private static ShadowShape BspShape(uint gfxObjId, float radius) => new(
gfxObjId,
Vector3.Zero,
Quaternion.Identity,
Scale: 1f,
CollisionType: ShadowCollisionType.BSP,
Radius: radius,
CylHeight: 0f);
private static ShadowShape BspShape(uint gfxObjId, float radius)
=> ShadowShape.Bsp(
gfxObjId,
Vector3.Zero,
Quaternion.Identity,
scale: 1f,
localBounds: new FlatCollisionSphere(Vector3.Zero, radius));
private static CellPhysics BuildShadowCellSetTests_MakeLeafCell(Matrix4x4 worldTransform)
{

View file

@ -48,9 +48,9 @@ public class ShadowRegistrationOverflowTests
// ── The bug: old per-part Register loses one registration ─────────────
private static ShadowShape Cyl(Vector3 local) => new(
GfxObjId: 0u, LocalPosition: local, LocalRotation: Quaternion.Identity,
Scale: 1f, CollisionType: ShadowCollisionType.Cylinder, Radius: 1f, CylHeight: 2f);
private static ShadowShape Cyl(Vector3 local) => ShadowShape.Cylinder(
gfxObjId: 0u, localPosition: local, localRotation: Quaternion.Identity,
scale: 1f, radius: 1f, cylHeight: 2f);
[Fact]
public void OldPerPartRegister_CollidingIds_SecondSilentlyOverwritesFirst()

View file

@ -461,12 +461,11 @@ public sealed class ShadowSetPositionCommitTests
return registry;
}
private static ShadowShape Shape(uint gfxObjId, Vector3 local) => new(
gfxObjId,
local,
Quaternion.Identity,
1f,
ShadowCollisionType.BSP,
0.25f,
0f);
private static ShadowShape Shape(uint gfxObjId, Vector3 local)
=> ShadowShape.Bsp(
gfxObjId,
local,
Quaternion.Identity,
scale: 1f,
localBounds: new FlatCollisionSphere(Vector3.Zero, 0.25f));
}