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:
parent
e2b2d04cb5
commit
e6457cc849
21 changed files with 1454 additions and 298 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue