fix(physics): delete the invented Setup-radius collision cylinder (AP-22)

Retail synthesizes NO shape for a shapeless object, so the fix is deletion,
not a corrected height formula.

CPhysicsObj::FindObjCollisions @0x0050f050 dispatches exclusively -- BSP xor
CylSphere xor Sphere xor nothing. The BSP branch leaves via an unconditional
`jmp 0x50f2b0` at 0x0050f19d and cannot reach the primitive branches; a
CylSphere-bearing object that survives its loop returns rather than falling
through to the Sphere loop; and with zero cylspheres, zero spheres and no
physics BSP, `0x0050f22f je 0x50f31b` branches straight to the epilogue,
returning the OK_TS seeded at `0x0050f13b mov edi,1`. CPartArray::GetRadius
(0x005180a0) and GetHeight (0x005180b0) are absent from the function's entire
call set -- Setup.Radius/Height serve attack cones, cylinder_distance and
MoveTo, never collision geometry. Disassembled directly from the PDB-paired
binary (GUID 9e847e2f-777c-4bd9-886c-22256bb87f32) rather than read from the
Binary Ninja text, whose ebp_1 aliasing in this function is visibly corrupt.

THREE copies were deleted, not one. The AP-22 register row cited
LiveEntityCollisionBuilder.cs and ShadowShapeBuilder.cs; the latter never
reads Setup.Radius at all, and the row omitted both
LandblockPhysicsPublisher.PublishStaticEntity and
LandblockPhysicsContentBuilder.PublishStaticCollision -- the second being the
only copy the headless host executes. Fixing just the cited site would have
left headless statics on the invented footprint.

The branch was unreachable dead code, not a live approximation. A sweep of all
5,935 Setups in the installed client_portal.dat -- validated by byte
accounting (5,935/5,935 records consumed with an exact 20 + 48*numLights
residual tail, zero unexplained bytes) and independently reproduced by the
production FlatCollisionAssetBuilder.FlattenSetup path -- finds 0 Setups
satisfying the guard: every Setup with Radius > 0.0001 carries at least one
CylSphere or Sphere, and all 1,294 genuinely shapeless Setups have Radius
exactly 0. Buckets: 678 cylsphere, 3,605 sphere-only, 358 BSP-only, 1,294
shapeless, 4,282 with Radius > 0.0001. Nothing loses collision because nothing
gained it, so no visual gate is required.

Tests, all sabotage-verified in both directions:
- InstalledSetupCollisionReachabilityTests (new, Content) -- the negative
  claim plus five EXTERNAL positive controls, so a broken enumeration cannot
  satisfy it vacuously. Inverting the claim reddens it; emptying the
  enumeration fails on the controls at 0 != 5935 rather than passing.
- ShapelessSetupWithRadius_ProducesNoRegistration (new, App) -- restoring the
  deleted block reddens exactly this fact and nothing else.
- Build_PropagatesExactStateFlagsScaleAndFullSeedCell -- re-hosts the state /
  PWD-flag / seed-cell coverage that rode on the deleted fallback test, whose
  fixture (a Setup with a radius and no primitives) cannot exist in the DAT.
  Flipping a FromPwdBitfield bit reddens it; so does swapping SeedCellId for
  the landblock id.

Also corrects ShadowShapeBuilder's retail-anchor comment, which claimed each
part's find_obj_collisions tests "CylSpheres + GfxObj BSP".
CPhysicsPart::find_obj_collisions @0x0050d8d0 tests ONLY the GfxObj physics
BSP; CylSpheres are a Setup-level array reached via CPartArray::GetCylsphere.
That comment was the written justification for the additive emission now filed
as AP-152, so it is corrected here even though AP-152 is not fixed here.

AP-22 retired with evidence; AP-152 filed (live path emits primitives AND BSP
parts additively where retail is exclusive -- 172 of 5,935 Setups including
BSP doors; deliberately not folded in, it needs its own visual gate). Issue
#330 filed: the headless host registers no live-entity collision at all, a
pre-existing gap this survey established and nothing tracked.

Gates: Release build 0 errors / 0 warnings. Complete solution suite
11,195 passed / 4 skipped / 0 failed (baseline 11,193/4/0 at bcb66ccd; +1 App
for the added fact, +1 Content for the reachability test; the replaced test is
net zero). No new skips. Headless.Tests 89/89 exercises the site-3 copy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-06 08:27:26 +02:00
parent bcb66ccdf3
commit bc4679cda5
9 changed files with 947 additions and 62 deletions

View file

@ -29,10 +29,20 @@ internal sealed record LiveEntityCollisionRegistration(
/// <summary>
/// Ports the live-object collision-shape policy used by
/// <c>CPartArray::FindObjCollisions</c>: CylSpheres before Spheres, every
/// physics-BSP part, and the established Setup-radius fallback for the
/// remaining ACE prop data.
/// <c>CPartArray::FindObjCollisions</c>: CylSpheres before Spheres, and every
/// physics-BSP part.
/// </summary>
/// <remarks>
/// A Setup that yields no shape produces no registration. Retail synthesizes
/// nothing for a shapeless object: <c>CPhysicsObj::FindObjCollisions</c>
/// (0x0050f050) walks CylSpheres or Spheres or the physics BSP, and when
/// <c>CPartArray::GetNumSphere</c> returns zero it branches straight to the
/// epilogue (<c>0x0050f22f je 0x50f31b</c>) returning the seeded
/// <c>OK_TS</c>. <c>CPartArray::GetRadius</c> (0x005180a0) and
/// <c>GetHeight</c> (0x005180b0) are absent from that function's entire call
/// set — <c>Setup.Radius</c>/<c>Height</c> serve attack cones,
/// <c>cylinder_distance</c>, and MoveTo, never collision geometry.
/// </remarks>
internal sealed class LiveEntityCollisionBuilder
{
private readonly Func<uint, bool> _hasPhysicsBsp;
@ -133,18 +143,6 @@ internal sealed class LiveEntityCollisionBuilder
}
}
if (shapes.Count == 0 && setup.Radius > 0.0001f)
{
shapes.Add(new ShadowShape(
GfxObjId: 0u,
LocalPosition: Vector3.Zero,
LocalRotation: Quaternion.Identity,
Scale: scale,
CollisionType: ShadowCollisionType.Cylinder,
Radius: setup.Radius * scale,
CylHeight: (setup.Height > 0f ? setup.Height : setup.Radius * 2f) * scale));
}
if (shapes.Count == 0 && !retainEmptyPayload)
return null;

View file

@ -1038,24 +1038,9 @@ public sealed class LandblockPhysicsPublisher
}
}
if (setup.Cylinders.Length == 0
&& setup.Spheres.Length == 0
&& setup.Radius > 0f)
{
float radius = setup.Radius * scale;
float height = (setup.Height > 0f
? setup.Height
: setup.Radius * 2f) * scale;
setupShapes.Add(new ShadowShape(
GfxObjId: entity.SourceGfxObjOrSetupId,
LocalPosition: Vector3.Zero,
LocalRotation: Quaternion.Identity,
Scale: scale,
CollisionType: ShadowCollisionType.Cylinder,
Radius: radius,
CylHeight: height));
}
// No Setup-radius fallback: retail synthesizes no shape for a
// Setup with neither CylSpheres nor Spheres. See
// LiveEntityCollisionBuilder's remarks for the retail anchor.
if (setupShapes.Count > 0)
{
publication.StagingEngine.ShadowObjects.RegisterMultiPart(

View file

@ -691,24 +691,9 @@ public static class LandblockPhysicsContentBuilder
}
}
if (setup.Cylinders.Length == 0
&& setup.Spheres.Length == 0
&& setup.Radius > 0f)
{
float radius = setup.Radius * scale;
float height = (setup.Height > 0f
? setup.Height
: setup.Radius * 2f) * scale;
setupShapes.Add(new ShadowShape(
entity.SourceGfxObjOrSetupId,
Vector3.Zero,
Quaternion.Identity,
scale,
ShadowCollisionType.Cylinder,
radius,
height));
}
// No Setup-radius fallback: retail synthesizes no shape for a
// Setup with neither CylSpheres nor Spheres. See
// LiveEntityCollisionBuilder's remarks for the retail anchor.
if (setupShapes.Count == 0)
{
noCollision++;

View file

@ -22,11 +22,26 @@ namespace AcDream.Core.Physics;
/// </para>
///
/// <para>
/// Retail anchor: <c>CPhysicsObj::FindObjCollisions</c> calls
/// <c>CPartArray::FindObjCollisions</c> which iterates parts; each part's
/// <c>find_obj_collisions</c> tests CylSpheres + GfxObj BSP. We emit one
/// ShadowShape per part contribution so the existing FindObjCollisions
/// iteration loop in <see cref="Transition"/> tests each part independently.
/// Retail anchor: <c>CPhysicsObj::FindObjCollisions</c> (0x0050f050)
/// dispatches EXCLUSIVELY on <c>HAS_PHYSICS_BSP_PS</c> (0x10000): it calls
/// <c>CPartArray::FindObjCollisions</c> (the per-part BSP walk) and returns
/// (<c>0x0050f19d jmp</c> past the primitive branches), OR walks the Setup's
/// CylSpheres, OR walks the Setup's Spheres, OR — with none of the three —
/// returns the seeded <c>OK_TS</c> without synthesizing any shape
/// (<c>0x0050f22f je 0x50f31b</c>). It is never a union.
/// <c>CPhysicsPart::find_obj_collisions</c> (0x0050d8d0) tests ONLY the
/// GfxObj physics BSP; CylSpheres and Spheres are <c>Setup</c>-level arrays
/// reached through <c>CPartArray::GetCylsphere</c> (0x00518090) and
/// <c>GetSphere</c> (0x00518070), so a part has no primitive of its own.
/// </para>
///
/// <para>
/// KNOWN DIVERGENCE (AP-152): steps 1/2 and step 3 below are emitted
/// ADDITIVELY here, where retail is exclusive — 172 of 5,935 installed
/// Setups carry a primitive and a physics-BSP part. Do not cite the anchor
/// above as justification for the additive design; it is the evidence
/// against it. acdream's two static publication paths already implement the
/// exclusive rule.
/// </para>
/// </summary>
public static class ShadowShapeBuilder