From abbd7615ee335a05b80ae9e5bfd0ef633b32576c Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 4 Jun 2026 09:32:55 +0200 Subject: [PATCH] fix(p2): Path 5 near-miss = retail num_sphere>1 gate (fixes B1 step-up wedge) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/AcDream.Core/Physics/BSPQuery.cs | 73 +++++++++++-------- .../Physics/BSPQueryTests.cs | 32 ++++++-- 2 files changed, 70 insertions(+), 35 deletions(-) diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs index 3da87204..ef047675 100644 --- a/src/AcDream.Core/Physics/BSPQuery.cs +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -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; diff --git a/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs b/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs index 1c2ef410..c2474e4e 100644 --- a/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs +++ b/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs @@ -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,