fix(p2): Path 5 near-miss = retail num_sphere>1 gate (fixes B1 step-up wedge)

The 2026-06-03 handoff localized the failing Core tests to the BSP Path 5
step-up CLIMB (find_walkable/step_sphere_down). An ITestOutputHelper capture
of B1 disproved that: the climb code is correct (matches ACE
Polygon.adjust_sphere_to_plane / BSPTree.step_sphere_down exactly). The real
bug is the A6.P4 near-miss dispatch in FindCollisions' Path 5 (Contact
branch), which diverged from retail three ways:

  1. Recorded a near-miss NegPolyHit UNCONDITIONALLY. Retail gates both
     set_neg_poly_hit calls behind `if (num_sphere > 1)`
     (acclient_2013_pseudo_c.txt:323852).
  2. Checked the foot sphere's near-miss before the head's. Retail checks
     the head (sphere1) first.
  3. Mapped foot->neg_step_up=false / head->true. Retail maps head(index 0)
     ->false (slide), foot(index 1)->true (step-up), per
     SPHEREPATH::set_neg_poly_hit (:323279, neg_step_up = arg2).

For B1's single foot sphere, the spurious near-miss -> outer loop
`!NegStepUp -> SetCollisionNormal + Collided` -> revert: the grounded mover
wedged at x=0.1 and never advanced to the wall to step up. With the verbatim
gate, a single-sphere near-miss records nothing, the sphere advances,
full-hits the wall, and step_sphere_up climbs the 0.25 m step (verified via
probe capture: foot ends at (0.6, 0, 0.25)).

The Holtburg cottage door still blocks faithfully (door slab (0,-1,0) normal,
stops in front of the door) when the scenario has a real floor — confirmed
this change does not regress the door.

The two BSPQueryTests Path5 near-miss tests used a single sphere (the very
non-retail assumption that caused this wedge); converted to the production
2-sphere shape where the head sphere records the near-miss, matching retail.

Core 1312 pass / 4 fail (the 4 pre-existing: 3 door documents-the-bug + D4
airborne, none regressed here); App 177 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-04 09:32:55 +02:00
parent 82045805fd
commit abbd7615ee
2 changed files with 70 additions and 35 deletions

View file

@ -1855,21 +1855,38 @@ public static class BSPQuery
return TransitionState.Slid;
}
// Sphere 0 didn't fully hit. Test sphere 1 (head sphere).
ResolvedPolygon? hitPoly1 = null;
bool hit1 = false;
// Sphere 0 didn't fully hit. Per retail, the head-sphere test AND
// both near-miss dispatches are gated behind num_sphere > 1 (a head
// sphere exists). A single-sphere mover that only near-misses — e.g.
// a grounded foot sphere brushing a walkable step's top face on a
// parallel (movement ⟂ normal, front-face-culled) move — records NO
// neg-poly hit and is allowed to advance. It full-hits the
// obstacle's vertical face on a later sub-step and step_sphere_up's
// there. Recording a spurious foot near-miss here is what wedged a
// grounded mover against a walkable low step (it never advanced far
// enough to full-hit the wall and step up). See B1.
//
// Retail BSPTREE::find_collisions Contact branch
// (acclient_2013_pseudo_c.txt:323838-323881, @0x53a630-0x53a6fb):
// if (num_sphere > 1) {
// sphere1 full hit -> slide_sphere (Slid)
// else sphere1 near-miss -> set_neg_poly_hit(0, n1) (neg_step_up=0 -> outer slide)
// else sphere0 near-miss -> set_neg_poly_hit(1, n0) (neg_step_up=1 -> outer step_up)
// }
// SPHEREPATH::set_neg_poly_hit (acclient_2013_pseudo_c.txt:323279)
// assigns neg_step_up = arg2, so the HEAD near-miss (index 0) slides
// and the FOOT near-miss (index 1) steps up. The head test wins when
// both spheres near-miss (sphere1 is checked first).
if (sphere1 is not null)
{
Vector3 contact1 = Vector3.Zero;
hit1 = SphereIntersectsPolyInternal(root, resolved, sphere1, movement,
ref hitPoly1, ref contact1);
ResolvedPolygon? hitPoly1 = null;
Vector3 contact1 = Vector3.Zero;
bool hit1 = SphereIntersectsPolyInternal(root, resolved, sphere1, movement,
ref hitPoly1, ref contact1);
if (hit1)
{
// Sphere 1 full hit while sphere 0 had only near-miss
// (hitPoly0) — retail calls slide_sphere here. Record
// collision + slide.
// Sphere 1 (head) full hit → slide_sphere.
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
PhysicsDiagnostics.LastBspHitPoly = hitPoly1;
@ -1878,26 +1895,24 @@ public static class BSPQuery
collisions.SetSlidingNormal(worldNormal);
return TransitionState.Slid;
}
}
// Neither sphere fully hit. Record neg-poly hit if either had a
// near-miss polygon. Retail's set_neg_poly_hit with stepUp=false
// for sphere 0's near-miss, stepUp=true for sphere 1's near-miss.
// Outer transitional_insert loop then dispatches via slide_sphere
// (stepUp=false) or step_up + step_up_slide (stepUp=true).
if (hitPoly0 is not null)
{
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
PhysicsDiagnostics.LastBspHitPoly = hitPoly0;
NegPolyHitDispatch(path, hitPoly0, stepUp: false, localToWorld);
return TransitionState.OK;
}
if (hitPoly1 is not null)
{
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
PhysicsDiagnostics.LastBspHitPoly = hitPoly1;
NegPolyHitDispatch(path, hitPoly1, stepUp: true, localToWorld);
return TransitionState.OK;
// Sphere 1 (head) near-miss → neg_poly_hit, neg_step_up = false → outer slide.
if (hitPoly1 is not null)
{
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
PhysicsDiagnostics.LastBspHitPoly = hitPoly1;
NegPolyHitDispatch(path, hitPoly1, stepUp: false, localToWorld);
return TransitionState.OK;
}
// Sphere 0 (foot) near-miss → neg_poly_hit, neg_step_up = true → outer step_up.
if (hitPoly0 is not null)
{
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
PhysicsDiagnostics.LastBspHitPoly = hitPoly0;
NegPolyHitDispatch(path, hitPoly0, stepUp: true, localToWorld);
return TransitionState.OK;
}
}
return TransitionState.OK;