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

@ -48,6 +48,12 @@ internal sealed record LiveEntityCollisionRegistration(
internal sealed class LiveEntityCollisionBuilder
{
private readonly Func<uint, FlatCollisionSphere?> _physicsBspBounds;
/// <summary>
/// The dispatch gate, derived from <see cref="_physicsBspBounds"/> so the
/// two can never disagree (AP-156), and cached once so <see cref="Build"/>
/// allocates no closure per call — Slice I1's 0 B/resolve budget.
/// </summary>
private readonly Func<uint, bool> _hasPhysicsBsp;
private readonly LiveEntityDefaultPoseResolver _defaultPose;
public LiveEntityCollisionBuilder(
@ -80,6 +86,7 @@ internal sealed class LiveEntityCollisionBuilder
{
_physicsBspBounds = physicsBspBounds
?? throw new ArgumentNullException(nameof(physicsBspBounds));
_hasPhysicsBsp = id => _physicsBspBounds(id) is not null;
_defaultPose = defaultPose
?? throw new ArgumentNullException(nameof(defaultPose));
}
@ -134,7 +141,7 @@ internal sealed class LiveEntityCollisionBuilder
IReadOnlyList<ShadowShape> shapes = ShadowShapeBuilder.FromSetup(
setup,
scale,
id => _physicsBspBounds(id) is not null,
_hasPhysicsBsp,
partPoseOverride: defaultPose,
effectivePartGfxObjIds: effectivePartGfxObjIds,
physicsBspBounds: _physicsBspBounds);

View file

@ -1000,14 +1000,13 @@ public sealed class LandblockPhysicsPublisher
continue;
Vector3 localOffset = cylinder.Origin * scale;
setupShapes.Add(new ShadowShape(
GfxObjId: entity.SourceGfxObjOrSetupId,
LocalPosition: localOffset,
LocalRotation: Quaternion.Identity,
Scale: scale,
CollisionType: ShadowCollisionType.Cylinder,
Radius: radius,
CylHeight: height));
setupShapes.Add(ShadowShape.Cylinder(
gfxObjId: entity.SourceGfxObjOrSetupId,
localPosition: localOffset,
localRotation: Quaternion.Identity,
scale: scale,
radius: radius,
cylHeight: height));
}
if (setup.Cylinders.Length == 0)
@ -1027,14 +1026,13 @@ public sealed class LandblockPhysicsPublisher
+ Vector3.Transform(
-Vector3.UnitZ * radius,
Quaternion.Inverse(entity.Rotation));
setupShapes.Add(new ShadowShape(
GfxObjId: entity.SourceGfxObjOrSetupId,
LocalPosition: localBaseOffset,
LocalRotation: Quaternion.Identity,
Scale: scale,
CollisionType: ShadowCollisionType.Cylinder,
Radius: radius,
CylHeight: radius * 2f));
setupShapes.Add(ShadowShape.Cylinder(
gfxObjId: entity.SourceGfxObjOrSetupId,
localPosition: localBaseOffset,
localRotation: Quaternion.Identity,
scale: scale,
radius: radius,
cylHeight: radius * 2f));
}
}

View file

@ -655,12 +655,11 @@ public static class LandblockPhysicsContentBuilder
: cylinder.Radius * 4f) * scale;
if (radius <= 0f)
continue;
setupShapes.Add(new ShadowShape(
setupShapes.Add(ShadowShape.Cylinder(
entity.SourceGfxObjOrSetupId,
cylinder.Origin * scale,
Quaternion.Identity,
scale,
ShadowCollisionType.Cylinder,
radius,
height));
}
@ -680,12 +679,11 @@ public static class LandblockPhysicsContentBuilder
+ Vector3.Transform(
-Vector3.UnitZ * radius,
Quaternion.Inverse(entity.Rotation));
setupShapes.Add(new ShadowShape(
setupShapes.Add(ShadowShape.Cylinder(
entity.SourceGfxObjOrSetupId,
localBaseOffset,
Quaternion.Identity,
scale,
ShadowCollisionType.Cylinder,
radius,
radius * 2f));
}

View file

@ -663,14 +663,28 @@ public sealed class ShadowObjectRegistry
// The 10-sphere clamp belongs to the CYLSPHERE branch alone.
// CObjCell::find_cell_list @0x0052b9f0 clamps the cylsphere count at
// 0x0052ba21 cmp eax,0xa / 0x0052ba28 mov ebp,0xa. The BSP branch —
// find_bbox_cell_list @0x00510fc0 -> CPartArray::calc_cross_cells_static
// @0x00518160 -> CEnvCell::find_transit_cells @0x0052cae0 — walks EVERY
// part with no cap, and the sorting-sphere overload @0x0052b990 takes a
// single sphere. Applying the clamp to the BSP branch dropped parts
// 11..N out of the flood entirely: 7 installed Setups carry more than
// 10 physics-BSP parts (max 49, Setup 0x02001A91), and landblock-baked
// part arrays — stair runs, fences, rock clusters — routinely do.
// 0x0052ba21 cmp eax,0xa / 0x0052ba28 mov ebp,0xa, and that clamp is a
// fixed static-buffer capacity (the destination array at
// 0x844838..0x8448d8 is exactly ten 16-byte entries), not a policy.
//
// BSP branch: NO CAP, and this is a retail port. find_bbox_cell_list
// @0x00510fc0 -> CPartArray::calc_cross_cells_static @0x00518160 ->
// CEnvCell::find_transit_cells @0x0052cae0 walks every part, bounded
// only by num_parts. Clamping it dropped parts 11..N out of the flood
// entirely: 7 installed Setups carry more than 10 physics-BSP parts
// (max 49, Setup 0x02001A91), and landblock-baked part arrays — stair
// runs, fences, rock clusters — routinely do.
//
// only == null (the sorting-sphere branch): int.MaxValue is NOT a
// retail port and the addresses above do not justify it. Retail's
// overload @0x0052b990 pushes a literal 1 (0x0052b9d6 push 1) and
// floods from ONE authored CSetup::sorting_sphere. acdream floods from
// every Sphere shape instead — a different DAT field with a different
// cardinality, which is AP-157, filed and open. Capping at 1 HERE would
// not move toward retail: it would take Spheres[0], which is not the
// sorting sphere. int.MaxValue keeps the substitution in its safe
// (over-inclusive) direction until AP-157 ports the real field. Inert
// over installed data — max 5 Spheres on any Setup (0x020016F7).
int cap = only == ShadowCollisionType.Cylinder ? RetailSphereCap : int.MaxValue;
foreach (var s in shapes)

View file

@ -15,47 +15,158 @@ namespace AcDream.Core.Physics;
/// at <c>acclient_2013_pseudo_c.txt:275045-275055</c>. Each part stores its
/// own transform and dispatches per-part collision to its GfxObj.
/// </para>
///
/// <para>
/// CONSTRUCTION IS BY FACTORY ONLY (<see cref="Bsp"/>, <see cref="Cylinder"/>,
/// <see cref="Sphere"/>) and the constructor is private. That is the AP-156
/// invariant expressed at the type rather than only at the producer: a BSP
/// shape's radius and its bounding-sphere CENTRE arrive as one
/// <see cref="FlatCollisionSphere"/> value and are scaled together inside
/// <see cref="Bsp"/>, so no call site — present or future — can take the
/// radius while dropping the origin. That split is exactly what produced
/// AP-156, and with the old public 7-argument constructor a new BSP producer
/// could have reintroduced it silently and green.
/// </para>
/// </summary>
/// <param name="BoundsCenter">
/// Center of the shape's bounding sphere IN THE SHAPE'S OWN LOCAL FRAME —
/// the frame <see cref="LocalRotation"/> rotates out of and
/// <see cref="Radius"/> is measured in — already multiplied by the entity
/// scale, like <see cref="LocalPosition"/> and <see cref="Radius"/>.
/// <c>Zero</c> for Cylinder/Sphere shapes, whose
/// <see cref="LocalPosition"/> already IS their center.
///
/// <para>
/// BSP shapes need it because a GfxObj's physics BSP is authored in the
/// GfxObj's own coordinates and its root bounding sphere is frequently NOT
/// centered on that origin (376 of the 973 physics-BSP parts in the
/// installed <c>client_portal.dat</c> sit further from it than half their
/// own radius; worst 20.762 m on a 27.708 m sphere, gfx 0x010036DD).
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/>'s flood needs the
/// sphere's real position, not the part origin's.
/// </para>
///
/// <para>
/// Retail anchor: <c>CGfxObj::physics_sphere</c> (<c>[gfxobj+0x74]</c>) is
/// assigned <c>BSPTREE::GetSphere(physics_bsp)</c> @0x005397e0
/// (<c>mov eax,[ecx]; add eax,4</c> — the root <c>BSPNODE</c>'s
/// <c>CSphere sphere</c>, past its 4-byte vftable), so retail's per-part
/// flood sphere IS the BSP root bounding sphere, origin included.
/// <c>CEnvCell::find_transit_cells</c> @0x0052cae0 — the part-array overload
/// reached from <c>CPhysicsObj::find_bbox_cell_list</c> @0x00510fc0 via
/// <c>CPartArray::calc_cross_cells_static</c> @0x00518160's
/// <c>[vtbl+0x7c]</c> dispatch — loads that sphere at
/// <c>0x0052cb36 mov esi,[ecx+0x74]</c>, transforms its CENTER through the
/// part's own <c>Position</c> at <c>[part+0x30]</c>
/// (<c>0x0052cb4c add eax,0x30</c> / <c>0x0052cb5a call Position::localtolocal</c>),
/// and only then reads the radius at <c>0x0052cb65 fadd [esi+0xc]</c>.
/// </para>
/// </param>
public readonly record struct ShadowShape(
uint GfxObjId,
Vector3 LocalPosition,
Quaternion LocalRotation,
float Scale,
ShadowCollisionType CollisionType,
float Radius,
float CylHeight,
Vector3 BoundsCenter = default);
public readonly record struct ShadowShape
{
private ShadowShape(
uint gfxObjId,
Vector3 localPosition,
Quaternion localRotation,
float scale,
ShadowCollisionType collisionType,
float radius,
float cylHeight,
Vector3 boundsCenter)
{
GfxObjId = gfxObjId;
LocalPosition = localPosition;
LocalRotation = localRotation;
Scale = scale;
CollisionType = collisionType;
Radius = radius;
CylHeight = cylHeight;
BoundsCenter = boundsCenter;
}
/// <summary>Source GfxObj id, for the BSP walk and for diagnostics.</summary>
public uint GfxObjId { get; }
/// <summary>Part placement in the entity's own frame, entity-scaled.</summary>
public Vector3 LocalPosition { get; }
/// <summary>Part orientation in the entity's own frame.</summary>
public Quaternion LocalRotation { get; }
/// <summary>The entity (or part) scale already applied to the geometry.</summary>
public float Scale { get; }
public ShadowCollisionType CollisionType { get; }
/// <summary>Collision radius, entity-scaled.</summary>
public float Radius { get; }
/// <summary>Cylinder height, entity-scaled; 0 for BSP and Sphere.</summary>
public float CylHeight { get; }
/// <summary>
/// Center of the shape's bounding sphere IN THE SHAPE'S OWN LOCAL FRAME —
/// the frame <see cref="LocalRotation"/> rotates out of and
/// <see cref="Radius"/> is measured in — already multiplied by the entity
/// scale, like <see cref="LocalPosition"/> and <see cref="Radius"/>.
/// <c>Zero</c> for Cylinder/Sphere shapes, whose
/// <see cref="LocalPosition"/> already IS their center.
///
/// <para>
/// BSP shapes need it because a GfxObj's physics BSP is authored in the
/// GfxObj's own coordinates and its root bounding sphere is frequently NOT
/// centered on that origin (376 of the 973 physics-BSP parts in the
/// installed <c>client_portal.dat</c> sit further from it than half their
/// own radius; worst 20.762 m on a 27.708 m sphere, gfx 0x010036DD).
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/>'s flood needs the
/// sphere's real position, not the part origin's.
/// </para>
///
/// <para>
/// Retail anchor: <c>CGfxObj::physics_sphere</c> (<c>[gfxobj+0x74]</c>) is
/// assigned <c>BSPTREE::GetSphere(physics_bsp)</c> @0x005397e0
/// (<c>mov eax,[ecx]; add eax,4</c> — the root <c>BSPNODE</c>'s
/// <c>CSphere sphere</c>, past its 4-byte vftable), so retail's per-part
/// flood sphere IS the BSP root bounding sphere, origin included.
/// <c>CEnvCell::find_transit_cells</c> @0x0052cae0 — the part-array overload
/// reached from <c>CPhysicsObj::find_bbox_cell_list</c> @0x00510fc0 via
/// <c>CPartArray::calc_cross_cells_static</c> @0x00518160's
/// <c>[vtbl+0x7c]</c> dispatch — loads that sphere at
/// <c>0x0052cb36 mov esi,[ecx+0x74]</c>, transforms its CENTER through the
/// part's own <c>Position</c> at <c>[part+0x30]</c>
/// (<c>0x0052cb4c add eax,0x30</c> / <c>0x0052cb5a call Position::localtolocal</c>),
/// and only then reads the radius at <c>0x0052cb65 fadd [esi+0xc]</c>.
/// </para>
/// </summary>
public Vector3 BoundsCenter { get; }
/// <summary>
/// One physics-BSP part. <paramref name="localBounds"/> is the part
/// GfxObj's physics-BSP ROOT bounding sphere in the GfxObj's OWN frame,
/// unscaled — retail's <c>CGfxObj::physics_sphere</c>. Radius and centre
/// are scaled together here, which is the whole point of taking them as
/// one value.
/// </summary>
public static ShadowShape Bsp(
uint gfxObjId,
Vector3 localPosition,
Quaternion localRotation,
float scale,
FlatCollisionSphere localBounds)
=> new(
gfxObjId,
localPosition,
localRotation,
scale,
ShadowCollisionType.BSP,
localBounds.Radius * scale,
0f,
localBounds.Origin * scale);
/// <summary>
/// One Setup CylSphere. <paramref name="localPosition"/> already IS the
/// shape's centre, so it carries no separate bounds centre.
/// </summary>
public static ShadowShape Cylinder(
uint gfxObjId,
Vector3 localPosition,
Quaternion localRotation,
float scale,
float radius,
float cylHeight)
=> new(
gfxObjId,
localPosition,
localRotation,
scale,
ShadowCollisionType.Cylinder,
radius,
cylHeight,
Vector3.Zero);
/// <summary>
/// One Setup Sphere. <paramref name="localPosition"/> already IS the
/// shape's centre, so it carries no separate bounds centre.
/// </summary>
public static ShadowShape Sphere(
uint gfxObjId,
Vector3 localPosition,
Quaternion localRotation,
float scale,
float radius)
=> new(
gfxObjId,
localPosition,
localRotation,
scale,
ShadowCollisionType.Sphere,
radius,
0f,
Vector3.Zero);
}

View file

@ -145,14 +145,13 @@ public static class ShadowShapeBuilder
{
if (cyl.Radius <= 0f) continue;
float baseHeight = cyl.Height > 0f ? cyl.Height : cyl.Radius * 4f;
result.Add(new ShadowShape(
GfxObjId: 0u,
LocalPosition: new Vector3(cyl.Origin.X, cyl.Origin.Y, cyl.Origin.Z) * entScale,
LocalRotation: Quaternion.Identity,
Scale: entScale,
CollisionType: ShadowCollisionType.Cylinder,
Radius: cyl.Radius * entScale,
CylHeight: baseHeight * entScale));
result.Add(ShadowShape.Cylinder(
gfxObjId: 0u,
localPosition: new Vector3(cyl.Origin.X, cyl.Origin.Y, cyl.Origin.Z) * entScale,
localRotation: Quaternion.Identity,
scale: entScale,
radius: cyl.Radius * entScale,
cylHeight: baseHeight * entScale));
}
// 2. Spheres — only when no CylSpheres. Retail's CylSphere loop
@ -166,14 +165,12 @@ public static class ShadowShapeBuilder
foreach (var sph in setup.Spheres)
{
if (sph.Radius <= 0f) continue;
result.Add(new ShadowShape(
GfxObjId: 0u,
LocalPosition: new Vector3(sph.Origin.X, sph.Origin.Y, sph.Origin.Z) * entScale,
LocalRotation: Quaternion.Identity,
Scale: entScale,
CollisionType: ShadowCollisionType.Sphere,
Radius: sph.Radius * entScale,
CylHeight: 0f));
result.Add(ShadowShape.Sphere(
gfxObjId: 0u,
localPosition: new Vector3(sph.Origin.X, sph.Origin.Y, sph.Origin.Z) * entScale,
localRotation: Quaternion.Identity,
scale: entScale,
radius: sph.Radius * entScale));
}
}
}
@ -211,20 +208,19 @@ public static class ShadowShapeBuilder
// the part origin: 376 of the 973 installed physics-BSP parts sit
// further from it than half their own radius. A single resolver
// supplies both so one cannot be taken without the other.
// Absent bounds keep the loose-but-safe 2 m placeholder.
FlatCollisionSphere? bounds = physicsBspBounds?.Invoke(gfxId);
float bspRadius = (bounds?.Radius ?? 2f) * entScale;
Vector3 boundsCenter = (bounds?.Origin ?? Vector3.Zero) * entScale;
// Absent bounds keep the loose-but-safe 2 m placeholder, centred
// on the part origin because nothing better is known.
// ShadowShape.Bsp scales radius and centre together.
FlatCollisionSphere bounds =
physicsBspBounds?.Invoke(gfxId)
?? new FlatCollisionSphere(Vector3.Zero, 2f);
result.Add(new ShadowShape(
GfxObjId: gfxId,
LocalPosition: new Vector3(partFrame.Origin.X, partFrame.Origin.Y, partFrame.Origin.Z) * entScale,
LocalRotation: partFrame.Orientation,
Scale: entScale,
CollisionType: ShadowCollisionType.BSP,
Radius: bspRadius,
CylHeight: 0f,
BoundsCenter: boundsCenter));
result.Add(ShadowShape.Bsp(
gfxObjId: gfxId,
localPosition: new Vector3(partFrame.Origin.X, partFrame.Origin.Y, partFrame.Origin.Z) * entScale,
localRotation: partFrame.Orientation,
scale: entScale,
localBounds: bounds));
}
return result;
@ -305,29 +301,19 @@ public static class ShadowShapeBuilder
// same CPartArray walk (CPartArray::calc_cross_cells_static
// @0x00518160), so dropping the center here mis-places the flood
// exactly as it did for live Setups.
float localRadius;
Vector3 localCenter;
if (hasFlat)
{
FlatCollisionSphere root = flat!.Nodes[flat.RootIndex].BoundingSphere;
localRadius = root.Radius;
localCenter = root.Origin;
}
else
{
localRadius = phys.BoundingSphere?.Radius ?? 1f;
localCenter = phys.BoundingSphere?.Origin ?? Vector3.Zero;
}
FlatCollisionSphere localBounds =
hasFlat
? flat!.Nodes[flat.RootIndex].BoundingSphere
: new FlatCollisionSphere(
phys.BoundingSphere?.Origin ?? Vector3.Zero,
phys.BoundingSphere?.Radius ?? 1f);
shapes.Add(new ShadowShape(
GfxObjId: meshRef.GfxObjId,
LocalPosition: pPos,
LocalRotation: pRot,
Scale: partScale,
CollisionType: ShadowCollisionType.BSP,
Radius: localRadius * partScale,
CylHeight: 0f,
BoundsCenter: localCenter * partScale));
shapes.Add(ShadowShape.Bsp(
gfxObjId: meshRef.GfxObjId,
localPosition: pPos,
localRotation: pRot,
scale: partScale,
localBounds: localBounds));
}
return shapes;