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:
parent
82045805fd
commit
abbd7615ee
2 changed files with 70 additions and 35 deletions
|
|
@ -1855,21 +1855,38 @@ public static class BSPQuery
|
||||||
return TransitionState.Slid;
|
return TransitionState.Slid;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sphere 0 didn't fully hit. Test sphere 1 (head sphere).
|
// Sphere 0 didn't fully hit. Per retail, the head-sphere test AND
|
||||||
ResolvedPolygon? hitPoly1 = null;
|
// both near-miss dispatches are gated behind num_sphere > 1 (a head
|
||||||
bool hit1 = false;
|
// 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)
|
if (sphere1 is not null)
|
||||||
{
|
{
|
||||||
Vector3 contact1 = Vector3.Zero;
|
ResolvedPolygon? hitPoly1 = null;
|
||||||
hit1 = SphereIntersectsPolyInternal(root, resolved, sphere1, movement,
|
Vector3 contact1 = Vector3.Zero;
|
||||||
ref hitPoly1, ref contact1);
|
bool hit1 = SphereIntersectsPolyInternal(root, resolved, sphere1, movement,
|
||||||
|
ref hitPoly1, ref contact1);
|
||||||
|
|
||||||
if (hit1)
|
if (hit1)
|
||||||
{
|
{
|
||||||
// Sphere 1 full hit while sphere 0 had only near-miss
|
// Sphere 1 (head) full hit → slide_sphere.
|
||||||
// (hitPoly0) — retail calls slide_sphere here. Record
|
|
||||||
// collision + slide.
|
|
||||||
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
||||||
PhysicsDiagnostics.LastBspHitPoly = hitPoly1;
|
PhysicsDiagnostics.LastBspHitPoly = hitPoly1;
|
||||||
|
|
||||||
|
|
@ -1878,26 +1895,24 @@ public static class BSPQuery
|
||||||
collisions.SetSlidingNormal(worldNormal);
|
collisions.SetSlidingNormal(worldNormal);
|
||||||
return TransitionState.Slid;
|
return TransitionState.Slid;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Neither sphere fully hit. Record neg-poly hit if either had a
|
// Sphere 1 (head) near-miss → neg_poly_hit, neg_step_up = false → outer slide.
|
||||||
// near-miss polygon. Retail's set_neg_poly_hit with stepUp=false
|
if (hitPoly1 is not null)
|
||||||
// for sphere 0's near-miss, stepUp=true for sphere 1's near-miss.
|
{
|
||||||
// Outer transitional_insert loop then dispatches via slide_sphere
|
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
||||||
// (stepUp=false) or step_up + step_up_slide (stepUp=true).
|
PhysicsDiagnostics.LastBspHitPoly = hitPoly1;
|
||||||
if (hitPoly0 is not null)
|
NegPolyHitDispatch(path, hitPoly1, stepUp: false, localToWorld);
|
||||||
{
|
return TransitionState.OK;
|
||||||
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
}
|
||||||
PhysicsDiagnostics.LastBspHitPoly = hitPoly0;
|
|
||||||
NegPolyHitDispatch(path, hitPoly0, stepUp: false, localToWorld);
|
// Sphere 0 (foot) near-miss → neg_poly_hit, neg_step_up = true → outer step_up.
|
||||||
return TransitionState.OK;
|
if (hitPoly0 is not null)
|
||||||
}
|
{
|
||||||
if (hitPoly1 is not null)
|
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
||||||
{
|
PhysicsDiagnostics.LastBspHitPoly = hitPoly0;
|
||||||
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
NegPolyHitDispatch(path, hitPoly0, stepUp: true, localToWorld);
|
||||||
PhysicsDiagnostics.LastBspHitPoly = hitPoly1;
|
return TransitionState.OK;
|
||||||
NegPolyHitDispatch(path, hitPoly1, stepUp: true, localToWorld);
|
}
|
||||||
return TransitionState.OK;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return TransitionState.OK;
|
return TransitionState.OK;
|
||||||
|
|
|
||||||
|
|
@ -592,9 +592,15 @@ public class BSPQueryTests
|
||||||
// returns 0 because dpMove >= 0. But the polygon pointer IS recorded
|
// returns 0 because dpMove >= 0. But the polygon pointer IS recorded
|
||||||
// (line 00539509 `*arg5 = this` fires when static-overlap result != 0).
|
// (line 00539509 `*arg5 = this` fires when static-overlap result != 0).
|
||||||
//
|
//
|
||||||
// Expected: Path 5 (Contact branch) sees hitPoly0 != null but hit0 ==
|
// Retail records the near-miss ONLY when num_sphere > 1 (a head sphere
|
||||||
// false → NegPolyHitDispatch fires → path.NegPolyHit = true → state
|
// exists) — acclient_2013_pseudo_c.txt:323852-323879. So this is a
|
||||||
// returns OK.
|
// 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 (root, resolved) = BuildSingleWallBsp();
|
||||||
|
|
||||||
var transition = new Transition();
|
var transition = new Transition();
|
||||||
|
|
@ -607,6 +613,12 @@ public class BSPQueryTests
|
||||||
Origin = new Vector3(0f, 0.3f, 0f),
|
Origin = new Vector3(0f, 0.3f, 0f),
|
||||||
Radius = 0.48f,
|
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.
|
// localCurrCenter: previous sphere center. movement = current - curr.
|
||||||
// Move +X by 0.05 m (small tick step parallel to the wall).
|
// Move +X by 0.05 m (small tick step parallel to the wall).
|
||||||
|
|
@ -617,7 +629,7 @@ public class BSPQueryTests
|
||||||
resolved,
|
resolved,
|
||||||
transition,
|
transition,
|
||||||
localSphere,
|
localSphere,
|
||||||
localSphere1: null,
|
localSphere1: localSphere1,
|
||||||
localCurrCenter: localCurrCenter,
|
localCurrCenter: localCurrCenter,
|
||||||
localSpaceZ: Vector3.UnitZ,
|
localSpaceZ: Vector3.UnitZ,
|
||||||
scale: 1.0f,
|
scale: 1.0f,
|
||||||
|
|
@ -639,7 +651,10 @@ public class BSPQueryTests
|
||||||
{
|
{
|
||||||
// Same overlap geometry, but motion is AWAY from the wall (+Y).
|
// Same overlap geometry, but motion is AWAY from the wall (+Y).
|
||||||
// moveDot = dot((0,+1,0), (0,+1,0)) = +1 > 0 → cull rejects.
|
// 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 (root, resolved) = BuildSingleWallBsp();
|
||||||
|
|
||||||
var transition = new Transition();
|
var transition = new Transition();
|
||||||
|
|
@ -651,6 +666,11 @@ public class BSPQueryTests
|
||||||
Origin = new Vector3(0f, 0.3f, 0f),
|
Origin = new Vector3(0f, 0.3f, 0f),
|
||||||
Radius = 0.48f,
|
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 localCurrCenter = localSphere.Origin - new Vector3(0f, 0.05f, 0f);
|
||||||
|
|
||||||
var state = BSPQuery.FindCollisions(
|
var state = BSPQuery.FindCollisions(
|
||||||
|
|
@ -658,7 +678,7 @@ public class BSPQueryTests
|
||||||
resolved,
|
resolved,
|
||||||
transition,
|
transition,
|
||||||
localSphere,
|
localSphere,
|
||||||
localSphere1: null,
|
localSphere1: localSphere1,
|
||||||
localCurrCenter: localCurrCenter,
|
localCurrCenter: localCurrCenter,
|
||||||
localSpaceZ: Vector3.UnitZ,
|
localSpaceZ: Vector3.UnitZ,
|
||||||
scale: 1.0f,
|
scale: 1.0f,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue