diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs index 72617fc..3da8720 100644 --- a/src/AcDream.Core/Physics/BSPQuery.cs +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -177,6 +177,21 @@ public static class BSPQuery /// hits after a sphere has already penetrated past a polygon. /// /// + /// + /// Near-miss recording (A6.P4 door bug fix, 2026-05-25): the + /// out-pointer is written whenever the sphere + /// statically overlaps the polygon — BEFORE the movement check. So when + /// the sphere is touching a wall but moving parallel/away, the function + /// returns false (cull rejected) but the polygon IS recorded for the + /// outer dispatch's NegPolyHit path. This mirrors retail's + /// CPolygon::pos_hits_sphere at + /// acclient_2013_pseudo_c.txt:322974-322993 (*arg5 = this + /// at 00539509 fires on static-overlap, BEFORE the + /// dot(N, movement) >= 0 → return 0 cull at 0053952f). + /// Without this ordering Path 5's near-miss dispatch is dead code (the + /// inside-out cottage door walkthrough bug). + /// + /// /// ACE: Polygon.cs pos_hits_sphere. /// private static bool PosHitsSphere( @@ -191,11 +206,15 @@ public static class BSPQuery sphere.Center, sphere.Radius, ref contactPoint); + // Retail: *arg5 = this is set BEFORE the movement cull when the + // static overlap succeeded. Path 5's near-miss dispatch reads this + // to fire NegPolyHit when the sphere is touching but moving away. + if (hit) hitPoly = poly; + // ACE: dist = Dot(movement, Plane.Normal); if dist >= 0 return false; float moveDot = Vector3.Dot(movement, poly.Plane.Normal); if (moveDot >= 0f) return false; - if (hit) hitPoly = poly; return hit; } diff --git a/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs b/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs index a80c743..1c2ef41 100644 --- a/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs +++ b/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs @@ -510,4 +510,210 @@ public class BSPQueryTests Assert.Equal(1.0f, transition.CollisionInfo.ContactPlane.Normal.Z, precision: 3); Assert.Equal(-94.0f, transition.CollisionInfo.ContactPlane.D, precision: 2); } + + // ========================================================================= + // A6.P4 door bug — Path 5 near-miss polygon recording (2026-05-25) + // + // Retail's CPolygon::pos_hits_sphere at acclient_2013_pseudo_c.txt:322974- + // 322993 records the polygon pointer (*arg5 = this) on STATIC overlap + // BEFORE the front-face cull. So when a sphere statically overlaps a + // polygon but its movement is parallel/away from the polygon normal, + // the polygon is still recorded — the BSPLEAF's pos_hits_sphere returns + // 0 (no full hit) but arg4 (the polygon out-pointer) IS set. + // + // Path 5 Contact branch in retail (acclient_2013_pseudo_c.txt:0053a630- + // 0053a6fb) reads that near-miss polygon (var_5c) and, when no full hit + // happens but a polygon WAS recorded, calls SPHEREPATH::set_neg_poly_hit + // (line 0053a6ea). The outer transitional_insert loop then dispatches + // via slide_sphere so the sphere slides along the wall. + // + // Pre-fix our PosHitsSphere only recorded the polygon when both the + // static-overlap AND the front-face cull passed. This dropped the + // near-miss case → Path 5 never fired NegPolyHitDispatch → outer loop + // never slid → inside-out cottage doors let spheres squeeze through + // walls they were touching. + // ========================================================================= + + /// + /// Build a vertical wall polygon in the XZ-plane at Y=0 facing +Y. + /// 4 m × 4 m, centered at cell-local origin. Vertices are CCW when viewed + /// from +Y so the implicit winding-normal matches the explicit + /// Plane(Vector3.UnitY, 0f) (which the precise overlap test uses + /// to compute edge cross products). + /// + private static (PhysicsBSPNode root, Dictionary resolved) + BuildSingleWallBsp() + { + var verts = new[] + { + new Vector3(-2f, 0f, -2f), + new Vector3(-2f, 0f, 2f), + new Vector3( 2f, 0f, 2f), + new Vector3( 2f, 0f, -2f), + }; + + var root = new PhysicsBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new Sphere + { + Origin = new Vector3(0f, 0f, 0f), + Radius = 4f, + }, + }; + root.Polygons.Add(0); + + var resolved = new Dictionary + { + [0] = new ResolvedPolygon + { + Vertices = verts, + // +Y-facing plane at Y=0 → normal=(0,+1,0), d=0 + Plane = new Plane(Vector3.UnitY, 0f), + NumPoints = 4, + SidesType = CullMode.None, + Id = 0, + }, + }; + + return (root, resolved); + } + + [Fact] + public void FindCollisions_Path5_SphereOverlapsWallButMovesParallel_SetsNegPolyHit() + { + // Sphere center at +Y 0.3 m with radius 0.48 — abs(dist)=0.3 < radius + // (0.48 - 0.0002 = 0.4798), so polygon_hits_sphere_slow_but_sure + // accepts the contact (static overlap = true). + // + // Movement is pure +X (parallel to wall): moveDot = + // dot((1,0,0), (0,+1,0)) = 0 + // Front-face cull at acclient_2013_pseudo_c.txt:00539524-00539538 + // 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. + var (root, resolved) = BuildSingleWallBsp(); + + var transition = new Transition(); + transition.SpherePath.WalkInterp = 1.0f; + // Path 5: Contact branch. + transition.ObjectInfo.State = ObjectInfoState.Contact; + + var localSphere = new Sphere + { + Origin = new Vector3(0f, 0.3f, 0f), + Radius = 0.48f, + }; + + // localCurrCenter: previous sphere center. movement = current - curr. + // Move +X by 0.05 m (small tick step parallel to the wall). + var localCurrCenter = localSphere.Origin - new Vector3(0.05f, 0f, 0f); + + var state = BSPQuery.FindCollisions( + root, + resolved, + transition, + localSphere, + localSphere1: null, + localCurrCenter: localCurrCenter, + localSpaceZ: Vector3.UnitZ, + scale: 1.0f, + localToWorld: Quaternion.Identity, + engine: null, + worldOrigin: Vector3.Zero); + + Assert.Equal(TransitionState.OK, state); + Assert.True(transition.SpherePath.NegPolyHit, + "Sphere statically overlaps wall but moves parallel → near-miss " + + "polygon should be recorded, NegPolyHit should fire. Retail " + + "oracle: CPolygon::pos_hits_sphere at " + + "acclient_2013_pseudo_c.txt:322974-322993 (*arg5 = this BEFORE " + + "the front-face cull)."); + } + + [Fact] + public void FindCollisions_Path5_SphereOverlapsWallAndMovesAway_SetsNegPolyHit() + { + // 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. + var (root, resolved) = BuildSingleWallBsp(); + + var transition = new Transition(); + transition.SpherePath.WalkInterp = 1.0f; + transition.ObjectInfo.State = ObjectInfoState.Contact; + + var localSphere = new Sphere + { + Origin = new Vector3(0f, 0.3f, 0f), + Radius = 0.48f, + }; + var localCurrCenter = localSphere.Origin - new Vector3(0f, 0.05f, 0f); + + var state = BSPQuery.FindCollisions( + root, + resolved, + transition, + localSphere, + localSphere1: null, + localCurrCenter: localCurrCenter, + localSpaceZ: Vector3.UnitZ, + scale: 1.0f, + localToWorld: Quaternion.Identity, + engine: null, + worldOrigin: Vector3.Zero); + + Assert.Equal(TransitionState.OK, state); + Assert.True(transition.SpherePath.NegPolyHit, + "Sphere overlaps wall and moves away → near-miss polygon should " + + "still be recorded (front-face cull rejects motion but retail " + + "records the polygon BEFORE that check)."); + } + + [Fact] + public void FindCollisions_Path5_SphereOverlapsWallAndMovesInto_DispatchesStepSphereUp() + { + // Regression guard for the FULL-HIT case in the same Path 5 branch. + // Sphere overlaps wall AND moves INTO it: moveDot < 0, cull does NOT + // reject, pos_hits_sphere returns 1, Path 5 takes the `if (hit0)` + // branch. With engine=null we fall through to the slide fallback + // (SetCollisionNormal + SetSlidingNormal + return Slid). + var (root, resolved) = BuildSingleWallBsp(); + + var transition = new Transition(); + transition.SpherePath.WalkInterp = 1.0f; + transition.ObjectInfo.State = ObjectInfoState.Contact; + + var localSphere = new Sphere + { + Origin = new Vector3(0f, 0.3f, 0f), + Radius = 0.48f, + }; + // Movement -Y (into the wall from the +Y side). + var localCurrCenter = localSphere.Origin - new Vector3(0f, -0.05f, 0f); + + var state = BSPQuery.FindCollisions( + root, + resolved, + transition, + localSphere, + localSphere1: null, + localCurrCenter: localCurrCenter, + localSpaceZ: Vector3.UnitZ, + scale: 1.0f, + localToWorld: Quaternion.Identity, + engine: null, + worldOrigin: Vector3.Zero); + + Assert.Equal(TransitionState.Slid, state); + Assert.True(transition.CollisionInfo.CollisionNormalValid, + "Full hit should set the collision normal (slide fallback)."); + Assert.False(transition.SpherePath.NegPolyHit, + "Full hit should NOT also fire NegPolyHit — that's the near-miss " + + "path only. Retail at acclient_2013_pseudo_c.txt:0053a647 returns " + + "early on eax_10 != 0 (full hit) before the near-miss dispatch."); + } }