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.");
+ }
}