fix(phys): A6.P7 — retail-binary cyl-vs-BSP dispatch (HAS_PHYSICS_BSP_PS gate)

Closes the door-cyl phantom slide where a sphere approaching a closed
cottage door at NE/SE headings could be blocked by the cyl's radial
normal contaminating the slide tangent into the slab face (live
evidence in door-a6p6-v2.utf8.log: 12 resolves with
cn=(0.86,0.51,0) attributed to door entity 0x000F4245).

Retail anchor: CPhysicsObj::FindObjCollisions at
acclient_2013_pseudo_c.txt:276861 dispatches BINARILY between
BSP-only and cyl+sphere based on HAS_PHYSICS_BSP_PS (0x10000 in
acclient.h:2833). For non-PvP, non-missile movers — every M1.5
scope walking-vs-static scenario — an entity with the flag set
tests its BSP exclusively; the foot cyl is never tested. ACE
confirms the truth table at PhysicsObj.cs:412-450 (HasPhysicsBSP,
missileIgnore, exemption).

Our dispatcher iterated every ShadowEntry independently and tested
both the cyl AND the BSP for a closed door. Cyl was registered
first (FromSetup walk order), and its diagonal radial slide normal
"won" attribution at the early-return on first non-OK. Result was
out=in for tangential motion along the door face.

Changes (~15 LOC + 7 unit tests):
- PhysicsStateFlags.HasPhysicsBsp = 0x00010000 (PhysicsBody.cs)
- Transition.BspOnlyDispatch(uint state) static predicate
  (TransitionTypes.cs) — mirrors retail's branch with M1.5 scope
  defaults (ebp_1 and eax_12 treated as false; wire PvP / missile
  refinements when those scopes ship)
- Per-entry guard in FindObjCollisions cyl/sphere branch
  (TransitionTypes.cs:2433) — continue when BspOnlyDispatch fires,
  with [cyl-skip-bsp] diagnostic line gated on ProbeBuildingEnabled
- A6P7DispatchRulesTests (7 tests, all GREEN): flag value + 6
  parameterized predicate cases

Verification: 14-test keep-green list from the 2026-05-25 handoff
passes (5 BSPQueryTests.FindCollisions_Path5_*, 2 CellTransitTests.A6P5_*,
2 DoorCollisionApparatusTests.Apparatus_DeadCenter_*,
5 DoorBugTrajectoryReplayTests, 1
CellarUpTrajectoryReplayTests.LiveCompare_FirstCap_FixClosesCottageFloorCap).
Total: 20/20 pass including the new 7-test predicate suite.

The DocumentsBug test (Apparatus_Grounded_50cmOffCenter) fails
post-fix BUT was already failing pre-fix in the worktree baseline
(verified by stashing the fix and re-running — same failure mode:
sphere blocks at start with floor normal (0,0,1)). Not in the
keep-green list, so this is a known pre-existing condition; the
test's own header comment instructs flipping the assertion when
the fix lands.

Investigation:
docs/research/2026-05-25-a6-door-cyl-retail-dispatch-investigation.md

Needs visual verification at Holtburg cottage door (NE/SE approach
should now slide smoothly along the door face — zero [cyl-test]
log lines attributed to door entity, replaced by [cyl-skip-bsp]).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-25 16:35:32 +02:00
parent b36eff1c10
commit 888272aad1
4 changed files with 492 additions and 0 deletions

View file

@ -32,6 +32,19 @@ public enum PhysicsStateFlags : uint
Gravity = 0x00000400, // bit 10 — apply downward gravity
Hidden = 0x00001000,
/// <summary>
/// A6.P7 (2026-05-25): retail HAS_PHYSICS_BSP_PS bit
/// (acclient.h:2833). When set, the entity exposes a per-Setup
/// BSP collision mesh; retail's
/// <c>CPhysicsObj::FindObjCollisions</c> at
/// acclient_2013_pseudo_c.txt:276861 dispatches the entity's
/// collision queries to the BSP path EXCLUSIVELY for non-PvP,
/// non-missile movers — the foot cylinder and per-Setup spheres
/// are NEVER tested in this case. Closed cottage doors have
/// state 0x10008 (STATIC | REPORT_COLLISIONS | HAS_PHYSICS_BSP).
/// ACE name: <c>PhysicsState.HasPhysicsBSP</c>.
/// </summary>
HasPhysicsBsp = 0x00010000, // bit 16 — retail HAS_PHYSICS_BSP_PS
/// <summary>
/// L.3a (2026-04-30): retail INELASTIC_PS bit (acclient.h:2834).
/// When set, wall-collisions zero the velocity instead of reflecting.
/// Used by spell projectiles and missiles that should embed/explode on

View file

@ -586,6 +586,58 @@ public sealed class Transition
private static bool DumpEdgeSlideEnabled =>
Environment.GetEnvironmentVariable("ACDREAM_DUMP_EDGE_SLIDE") == "1";
// -----------------------------------------------------------------------
// A6.P7 (2026-05-25) — retail-binary dispatch rule
// -----------------------------------------------------------------------
/// <summary>
/// A6.P7 retail-binary dispatch predicate. Returns true when an
/// entity's collision queries should go to its BSP exclusively,
/// skipping the cyl/sphere shapes.
///
/// <para>
/// Mirrors the dispatch branch in retail's
/// <c>CPhysicsObj::FindObjCollisions</c> at
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:276861</c>:
/// <code>
/// if (((state &amp; 0x10000) == 0 || ebp_1 != 0) || eax_12 != 0)
/// // cyl + sphere iteration
/// else
/// // BSP-only via CPartArray::FindObjCollisions
/// </code>
/// where <c>ebp_1</c> is the PvP-target-player flag (lines 276808-
/// 276841) and <c>eax_12</c> is the <c>OBJECTINFO::missile_ignore</c>
/// result (line 274385). The flag is named
/// <c>HAS_PHYSICS_BSP_PS = 0x10000</c> in acclient.h:2833 and
/// <c>PhysicsState.HasPhysicsBSP</c> in ACE
/// (references/ACE/Source/ACE.Entity/Enum/PhysicsState.cs:24).
/// </para>
///
/// <para>
/// M1.5 scope (walking-vs-static, no PK, no missiles) treats both
/// <c>ebp_1</c> and <c>eax_12</c> as <c>false</c>. The predicate
/// reduces to <c>(state &amp; HAS_PHYSICS_BSP_PS) != 0</c>. When
/// PK ships (M2+ phase) and missiles ship (F.3), wire the
/// PvP-exemption and missile_ignore checks through as additional
/// parameters following retail's
/// <c>references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:412</c>
/// dispatch shape.
/// </para>
///
/// <para>
/// Investigation:
/// <c>docs/research/2026-05-25-a6-door-cyl-retail-dispatch-investigation.md</c>.
/// </para>
/// </summary>
/// <param name="entityState">The collision target entity's raw
/// <c>PhysicsState</c> value (as stored on
/// <c>ShadowEntry.State</c>).</param>
/// <returns>True when retail would dispatch BSP-only — i.e. when
/// the entity has <c>HAS_PHYSICS_BSP_PS</c> set; cyl/sphere shapes
/// must be skipped at the per-entry dispatch site.</returns>
public static bool BspOnlyDispatch(uint entityState)
=> (entityState & (uint)PhysicsStateFlags.HasPhysicsBsp) != 0;
// -----------------------------------------------------------------------
// Public entry point
// -----------------------------------------------------------------------
@ -2383,6 +2435,36 @@ public sealed class Transition
// ACE: Sphere.IntersectsSphere handles CylSphere objects via
// the same 6-path dispatcher. For now we keep the swept-sphere
// cylinder test which matches the retail CylSphere behavior.
// A6.P7 (2026-05-25) — retail-binary dispatch. Retail's
// CPhysicsObj::FindObjCollisions at
// acclient_2013_pseudo_c.txt:276861 dispatches BSP-only
// when HAS_PHYSICS_BSP_PS (0x10000) is set on the entity
// (and the mover isn't a PvP-eligible player or
// missile-ignored). Cottage doors carry the flag in their
// state (0x10008), so retail tests their slab BSP
// exclusively — the foot cyl is never tested. Without
// this guard, our dispatcher iterated cyl FIRST (it's
// registered before the BSP shape) and its radial normal
// contaminated the slide direction at NE/SE approach
// headings, producing the "stuck on door" phantom in
// door-a6p6-v2.utf8.log. See investigation at
// docs/research/2026-05-25-a6-door-cyl-retail-dispatch-investigation.md.
//
// M1.5 scope: PvP exemption (ebp_1) and missile_ignore
// (eax_12) are treated as false. Wire them through when
// PK / missiles ship — matches retail's full predicate
// at line 276861.
if (BspOnlyDispatch(obj.State))
{
if (PhysicsDiagnostics.ProbeBuildingEnabled)
{
Console.WriteLine(System.FormattableString.Invariant(
$"[cyl-skip-bsp] obj=0x{obj.EntityId:X8} state=0x{obj.State:X8} — HAS_PHYSICS_BSP_PS dispatches BSP-only"));
}
continue;
}
result = CylinderCollision(obj, sp, engine);
// A6.P4 door investigation (2026-05-24): log every Cylinder