diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs index 2895b20..eff0222 100644 --- a/src/AcDream.Core/Physics/BSPQuery.cs +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -1469,11 +1469,18 @@ public static class BSPQuery if (hit0 || hitPoly0 is not null) { var worldNormal = L2W(hitPoly0!.Plane.Normal); - if (engine is not null) + // L.2.3b (2026-04-29): recursion guard. Retail + // (acclient_2013_pseudo_c.txt:272954) gates step_sphere_up on + // `if (sp.step_up == 0 && sp.step_down == 0)`. Without this, + // the inner TransitionalInsert spawned by DoStepDown re-enters + // FindObjCollisions, hits the same wall, and recursively + // re-invokes step-up — churning the contact plane until + // numAttempts decays. Mid-recursion we fall back to wall-slide. + if (engine is not null && !path.StepUp && !path.StepDown) return StepSphereUp(transition, worldNormal, engine); - // No engine available (env-cell path without engine param) — - // fall back to wall-slide so existing indoor geometry still blocks. + // No engine OR step-up/step-down already in progress — fall + // back to wall-slide so the inner sphere doesn't recurse. collisions.SetCollisionNormal(worldNormal); collisions.SetSlidingNormal(worldNormal); return TransitionState.Slid; @@ -1490,7 +1497,8 @@ public static class BSPQuery if (hit1 || hitPoly1 is not null) { var worldNormal = L2W(hitPoly1!.Plane.Normal); - if (engine is not null) + // L.2.3b: same recursion guard as the foot-sphere branch. + if (engine is not null && !path.StepUp && !path.StepDown) return StepSphereUp(transition, worldNormal, engine); collisions.SetCollisionNormal(worldNormal);