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

@ -592,9 +592,15 @@ public class BSPQueryTests
// returns 0 because dpMove >= 0. But the polygon pointer IS recorded
// (line 00539509 `*arg5 = this` fires when static-overlap result != 0).
//
// Expected: Path 5 (Contact branch) sees hitPoly0 != null but hit0 ==
// false → NegPolyHitDispatch fires → path.NegPolyHit = true → state
// returns OK.
// Retail records the near-miss ONLY when num_sphere > 1 (a head sphere
// exists) — acclient_2013_pseudo_c.txt:323852-323879. So this is a
// TWO-sphere mover (foot + head). Both spheres near-miss; retail checks
// the HEAD (sphere1) first → set_neg_poly_hit(0,..) → neg_step_up=FALSE
// (slide). Expected: hit1 == false but hitPoly1 != null →
// NegPolyHitDispatch fires → path.NegPolyHit = true, NegStepUp = false,
// state returns OK. (A single-sphere mover records NOTHING here — that
// retail gate is what un-wedged the B1 grounded step-up; see
// BSPStepUpTests.B1.)
var (root, resolved) = BuildSingleWallBsp();
var transition = new Transition();
@ -607,6 +613,12 @@ public class BSPQueryTests
Origin = new Vector3(0f, 0.3f, 0f),
Radius = 0.48f,
};
// Head sphere — also statically overlaps the wall (Z within poly range).
var localSphere1 = new Sphere
{
Origin = new Vector3(0f, 0.3f, 1.0f),
Radius = 0.48f,
};
// localCurrCenter: previous sphere center. movement = current - curr.
// Move +X by 0.05 m (small tick step parallel to the wall).
@ -617,7 +629,7 @@ public class BSPQueryTests
resolved,
transition,
localSphere,
localSphere1: null,
localSphere1: localSphere1,
localCurrCenter: localCurrCenter,
localSpaceZ: Vector3.UnitZ,
scale: 1.0f,
@ -639,7 +651,10 @@ public class BSPQueryTests
{
// Same overlap geometry, but motion is AWAY from the wall (+Y).
// moveDot = dot((0,+1,0), (0,+1,0)) = +1 > 0 → cull rejects.
// Static overlap is still true, so retail records the polygon.
// Static overlap is still true, so retail records the polygon — but
// only because this is a TWO-sphere mover (num_sphere > 1 gate,
// acclient_2013_pseudo_c.txt:323852). The head sphere's near-miss
// records the NegPolyHit (neg_step_up=FALSE).
var (root, resolved) = BuildSingleWallBsp();
var transition = new Transition();
@ -651,6 +666,11 @@ public class BSPQueryTests
Origin = new Vector3(0f, 0.3f, 0f),
Radius = 0.48f,
};
var localSphere1 = new Sphere
{
Origin = new Vector3(0f, 0.3f, 1.0f),
Radius = 0.48f,
};
var localCurrCenter = localSphere.Origin - new Vector3(0f, 0.05f, 0f);
var state = BSPQuery.FindCollisions(
@ -658,7 +678,7 @@ public class BSPQueryTests
resolved,
transition,
localSphere,
localSphere1: null,
localSphere1: localSphere1,
localCurrCenter: localCurrCenter,
localSpaceZ: Vector3.UnitZ,
scale: 1.0f,