diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs
index 9f2be66..cb32399 100644
--- a/src/AcDream.Core/Physics/BSPQuery.cs
+++ b/src/AcDream.Core/Physics/BSPQuery.cs
@@ -962,6 +962,51 @@ public static class BSPQuery
return false;
}
+ ///
+ /// Issue #90 fix (2026-05-20). Radius-aware sibling of
+ /// . Returns true if a sphere centered
+ /// at with radius
+ /// overlaps the cell volume — i.e., the sphere is fully inside OR
+ /// straddles any splitting plane. Returns false only when the sphere
+ /// is FULLY outside (behind some splitting plane by more than its
+ /// radius).
+ ///
+ ///
+ /// Used by to avoid
+ /// ping-ponging the CellId back to outdoor on each indoor BSP
+ /// push-back: when the indoor BSP pushes the foot-sphere back by
+ /// a few cm, the sphere CENTER may briefly leave the indoor
+ /// CellBSP volume, but the sphere itself still OVERLAPS the cell.
+ /// The point-only test would flip CellId to outdoor; this overlap
+ /// test keeps the player classified as indoor as long as any part
+ /// of the sphere is still inside the cell.
+ ///
+ ///
+ ///
+ /// Retail oracle: BSPTREE::sphere_intersects_cell_bsp at
+ /// acclient_2013_pseudo_c.txt:323267 →
+ /// BSPNODE::sphere_intersects_cell_bsp at
+ /// :325546. Retail adds a 0.01f epsilon to the radius before
+ /// the threshold compare; we match that.
+ ///
+ ///
+ public static bool SphereIntersectsCellBsp(CellBSPNode? node, Vector3 center, float radius)
+ {
+ if (node is null) return true;
+ if (node.Type == BSPNodeType.Leaf) return true;
+
+ float dist = Vector3.Dot(node.SplittingPlane.Normal, center) + node.SplittingPlane.D;
+ float rad = radius + 0.01f; // retail's epsilon at :325551
+
+ // Behind splitting plane by more than radius → sphere fully outside.
+ if (dist < -rad) return false;
+
+ // Otherwise (front, on-plane, or straddling within radius) → follow positive child.
+ return node.PosNode is not null
+ ? SphereIntersectsCellBsp(node.PosNode, center, radius)
+ : true;
+ }
+
// =========================================================================
// BSP TREE-LEVEL HELPERS
//
diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs
index eab87de..d23a000 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -281,12 +281,26 @@ public sealed class PhysicsEngine
if (indoorCell?.CellBSP?.Root is null)
return indoorResult; // Can't verify (no CellBSP); trust FindCellList.
+ // Issue #90 fix (2026-05-20): use SPHERE-overlap instead of POINT-in
+ // for the indoor verification. The previous point-only check caused
+ // a per-frame ping-pong at the inn doorway: indoor BSP push-back
+ // moved the sphere CENTER a few cm outside the indoor CellBSP
+ // volume → point-only check returned false → fell through to outdoor
+ // → next tick re-promoted to indoor → wall hit → push-back →
+ // outdoor → repeat. Net visual behavior: "walls walk through"
+ // because outdoor ticks bypass indoor BSP entirely. With sphere-
+ // overlap, the player stays classified indoor as long as ANY part
+ // of the foot sphere still overlaps the indoor cell volume.
+ //
+ // Retail oracle: CCellStruct::sphere_intersects_cell at
+ // acclient_2013_pseudo_c.txt:317666 →
+ // BSPTREE::sphere_intersects_cell_bsp at :323267.
var localCenter = Vector3.Transform(worldPos, indoorCell.InverseWorldTransform);
- if (BSPQuery.PointInsideCellBsp(indoorCell.CellBSP.Root, localCenter))
+ if (BSPQuery.SphereIntersectsCellBsp(indoorCell.CellBSP.Root, localCenter, sphereRadius))
return indoorResult;
- // Fall through to outdoor resolution: player has left the indoor
- // portal-connected graph entirely.
+ // Fall through to outdoor resolution: player has FULLY left the
+ // indoor portal-connected graph (sphere no longer overlaps).
}
// Outdoor seed: use terrain grid to compute the prefixed cell id.
diff --git a/tests/AcDream.Core.Tests/Physics/SphereIntersectsCellBspTests.cs b/tests/AcDream.Core.Tests/Physics/SphereIntersectsCellBspTests.cs
new file mode 100644
index 0000000..c72fc5b
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/SphereIntersectsCellBspTests.cs
@@ -0,0 +1,110 @@
+using System.Numerics;
+using DatReaderWriter.Enums;
+using DatReaderWriter.Types;
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// Tests for — the radius-
+/// aware sibling of PointInsideCellBsp. Pins issue #90's fix semantics:
+/// a sphere whose center is JUST outside the cell volume but whose
+/// radius extends back into the cell should still register as
+/// overlapping.
+///
+/// Retail oracle: BSPTREE::sphere_intersects_cell_bsp at
+/// acclient_2013_pseudo_c.txt:323267 / BSPNODE::sphere_intersects_cell_bsp
+/// at :325546.
+///
+public class SphereIntersectsCellBspTests
+{
+ ///
+ /// One splitting plane at x=0 with positive normal +X (so the
+ /// "inside" half-space is x ≥ 0). Pos-leaf representing the cell
+ /// interior.
+ ///
+ private static CellBSPNode SinglePlaneTree()
+ {
+ var leaf = new CellBSPNode { Type = BSPNodeType.Leaf };
+ // Internal nodes don't set Type (default is non-Leaf). The production
+ // PointInsideCellBsp / SphereIntersectsCellBsp both only branch on
+ // `Type == Leaf` and otherwise treat the node as internal.
+ return new CellBSPNode
+ {
+ SplittingPlane = new Plane(new Vector3(1f, 0f, 0f), 0f), // x ≥ 0 is inside
+ PosNode = leaf,
+ };
+ }
+
+ [Fact]
+ public void NullRoot_ReturnsTrue()
+ {
+ Assert.True(BSPQuery.SphereIntersectsCellBsp(null, Vector3.Zero, 0.5f));
+ }
+
+ [Fact]
+ public void Leaf_ReturnsTrue()
+ {
+ var leaf = new CellBSPNode { Type = BSPNodeType.Leaf };
+ Assert.True(BSPQuery.SphereIntersectsCellBsp(leaf, Vector3.Zero, 0.5f));
+ }
+
+ [Fact]
+ public void SphereCenterInsideHalfSpace_ReturnsTrue()
+ {
+ var root = SinglePlaneTree();
+ // Sphere center at x=0.5 (inside).
+ Assert.True(BSPQuery.SphereIntersectsCellBsp(root, new Vector3(0.5f, 0f, 0f), 0.5f));
+ }
+
+ [Fact]
+ public void SphereCenterOnPlane_ReturnsTrue()
+ {
+ var root = SinglePlaneTree();
+ // Sphere center exactly at x=0 (on the plane).
+ Assert.True(BSPQuery.SphereIntersectsCellBsp(root, new Vector3(0f, 0f, 0f), 0.5f));
+ }
+
+ [Fact]
+ public void SphereCenterOutside_ButRadiusReachesIn_ReturnsTrue()
+ {
+ // Issue #90's core case. Sphere center at x=-0.3 (outside the
+ // x≥0 cell), but radius 0.5 → reach to x=+0.2 (inside). The
+ // sphere STRADDLES the splitting plane — must return true.
+ // Pre-#90, PointInsideCellBsp would have returned false here,
+ // causing CellId to flip out → wall ping-pong.
+ var root = SinglePlaneTree();
+ Assert.True(BSPQuery.SphereIntersectsCellBsp(root, new Vector3(-0.3f, 0f, 0f), 0.5f));
+ }
+
+ [Fact]
+ public void SphereFullyOutside_ReturnsFalse()
+ {
+ var root = SinglePlaneTree();
+ // Sphere center at x=-1.0 with radius 0.5 → reach to x=-0.5.
+ // Fully behind the splitting plane (with the 0.01 retail epsilon
+ // accounted for, still fully outside).
+ Assert.False(BSPQuery.SphereIntersectsCellBsp(root, new Vector3(-1.0f, 0f, 0f), 0.5f));
+ }
+
+ [Fact]
+ public void SphereTangentToPlane_ReturnsTrue()
+ {
+ // Tangent boundary: sphere center at x=-0.5, radius=0.5 →
+ // reaches exactly x=0 (touches the plane). Retail's +0.01 epsilon
+ // applied to the radius means -0.5 > -(0.5+0.01) → returns true.
+ var root = SinglePlaneTree();
+ Assert.True(BSPQuery.SphereIntersectsCellBsp(root, new Vector3(-0.5f, 0f, 0f), 0.5f));
+ }
+
+ [Fact]
+ public void PointInsideCellBsp_PointJustOutside_ReturnsFalse_ProvesRegression()
+ {
+ // Confirms the OLD point-only behavior would have returned false
+ // for the same input that SphereIntersectsCellBsp returns true
+ // for above. This is the "ping-pong cause" pin.
+ var root = SinglePlaneTree();
+ Assert.False(BSPQuery.PointInsideCellBsp(root, new Vector3(-0.3f, 0f, 0f)));
+ }
+}