fix #137 (mechanism 2): BSP full-hit stubs leaked sliding normals — the corridor absorbing wedge

The Facility Hub corridor dead-stop's second half: after one seam hit,
every forward resolve returned ok=False hit=no with zero advance. The
body-persisted SlidingNormal (-1,0,0) projected the exactly-anti-parallel
corridor push to zero in AdjustOffset and the step loop aborted at step 0
before any collision test could refresh the state.

Audit (docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md):
retail's only in-transition sliding-normal writer is validate_transition
(0x0050ac21); the whole sphere/BSP layer writes NONE (grep-verified), and
the body persistence (SetPositionInternal 0x005154c2, SLIDING_TS bit sync
0x005154e1) runs only on transition success. Our BSPQuery Contact-branch
full-hit responses were stubs (SetCollisionNormal + SetSlidingNormal +
return Slid) where retail dispatches the real slide_sphere — so the seam
hit (a SUCCESSFUL full-advance resolve per the live log) persisted the
phantom wall's normal, which retail's lifecycle structurally cannot do.

- BSPQuery Contact foot full-hit fallback + head full-hit now route
  through Transition.SlideSphereInternal (CSphere::slide_sphere
  0x00537440 — in-frame slide, no sliding-normal write; ACE
  BSPTree.cs:202,310-316). The dead stub is rewritten as the faithful
  BSPTREE::slide_sphere wrapper.
- PhysicsEngine sliding writeback gated on ok (retail success-only
  placement; behaviorally latent, removes the failed-frame leak class).
- Register: TS-4 amended (Path-6 steep-tangent sites still write the
  normal — now documented), TS-45 added (SphereCollision's write — same
  leak class, left for a follow-up out of #171's blast radius).
- Pins: Issue137SlidingNormalLifecycleTests — both site pins RED->GREEN,
  plus the retail persist/absorb/clear wall lifecycle (validate-write
  persistence, faithful absorbed anti-parallel frame, oblique escape
  clears the bit). BSPQueryTests full-hit pin updated to the real slide.

Mechanism 1 (PortalSide portal polys solid in the physics set) stays
OPEN - #137 not closed; the corridor re-test rides that session.

Suites: Core 2545 / App 713 / UI 425 / Net 385, 0 failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-05 18:08:29 +02:00
parent e73e45da54
commit a11df5b8d3
7 changed files with 474 additions and 31 deletions

View file

@ -1399,26 +1399,35 @@ public static class BSPQuery
// -------------------------------------------------------------------------
// slide_sphere — BSPTree level
// ACE: BSPTree.cs slide_sphere
// Retail: BSPTREE::slide_sphere (find_collisions Contact head-hit dispatch
// at 0x0053a697). ACE: BSPTree.cs:310-316.
// -------------------------------------------------------------------------
/// <summary>
/// BSPTree.slide_sphere — apply sliding collision response.
/// BSPTree.slide_sphere — dispatch the real sphere-level slide
/// (<c>CSphere::slide_sphere</c> 0x00537440, ported as
/// <see cref="Transition.SlideSphereInternal"/>): slide IN-FRAME along the
/// crease between the collision normal and the contact plane, applied to
/// sphere 0's check position (ACE BSPTree.cs:315 —
/// <c>GlobalSphere[0].SlideSphere(..., GlobalCurrCenter[0].Center)</c>).
///
/// <para>
/// Sets the sliding normal on CollisionInfo so the outer transition loop
/// applies a wall-slide projection.
/// #137 mechanism 2 (2026-07-06): this was a stub that set
/// <c>CollisionInfo.SlidingNormal</c> and returned Slid. Retail's BSP layer
/// never writes the sliding normal — its only in-transition writer is
/// <c>CTransition::validate_transition</c> (0x0050ac21) — so the stub's
/// leaked normal survived to the body writeback and absorbed the next
/// frame's exactly-anti-parallel offset: the Facility Hub corridor
/// phantom's dead-stop half (ISSUES #137).
/// </para>
///
/// <para>ACE: BSPTree.cs slide_sphere — calls GlobalSphere[0].SlideSphere.</para>
/// </summary>
/// <param name="worldNormal">Collision normal already in world space (the
/// call sites apply L2W; ACE globalizes inside slide_sphere instead).</param>
private static TransitionState SlideSphere(
Transition transition,
Vector3 collisionNormal)
{
transition.CollisionInfo.SetSlidingNormal(collisionNormal);
return TransitionState.Slid;
}
Vector3 worldNormal)
=> transition.SlideSphereInternal(
worldNormal, transition.SpherePath.GlobalCurrCenter[0].Origin);
// -------------------------------------------------------------------------
// collide_with_pt — BSPTree level
@ -1894,11 +1903,14 @@ public static class BSPQuery
if (engine is not null && !path.StepUp && !path.StepDown)
return StepSphereUp(transition, worldNormal, engine);
// No engine OR step-up/step-down already in progress — fall
// back to wall-slide.
collisions.SetCollisionNormal(worldNormal);
collisions.SetSlidingNormal(worldNormal);
return TransitionState.Slid;
// No engine OR step-up/step-down already in progress — the
// real slide response. Retail: a blocked step-up funnels to
// SPHEREPATH::step_up_slide → CSphere::slide_sphere (ACE
// SpherePath.cs:316); the slide records the collision normal
// itself and never writes the sliding normal (#137 mechanism
// 2 — the old SetSlidingNormal stub here leaked a normal that
// wedged the next frame's forward offset).
return SlideSphere(transition, worldNormal);
}
// Sphere 0 didn't fully hit. Per retail, the head-sphere test AND
@ -1937,9 +1949,10 @@ public static class BSPQuery
PhysicsDiagnostics.LastBspHitPoly = hitPoly1;
var worldNormal = L2W(hitPoly1!.Plane.Normal);
collisions.SetCollisionNormal(worldNormal);
collisions.SetSlidingNormal(worldNormal);
return TransitionState.Slid;
// Retail head-sphere full hit → BSPTREE::slide_sphere
// (0x0053a697; ACE BSPTree.cs:202) — the real in-frame
// slide, no sliding-normal write (#137 mechanism 2).
return SlideSphere(transition, worldNormal);
}
// Sphere 1 (head) near-miss → neg_poly_hit, neg_step_up = false → outer slide.