fix(physics): #90 — sphere-overlap cell stickiness at doorway threshold
ResolveCellId's indoor-seed fall-through was point-only: when the indoor BSP push-back moved the foot-sphere CENTER a few cm outside the indoor CellBSP volume, the resolver flipped CellId back to outdoor. Next tick re-promoted via CheckBuildingTransit. The ping-pong caused most ticks to be classified outdoor, bypassing indoor BSP wall checks entirely and producing the user-reported "walls walk through everywhere in the inn" symptom. Fix: port retail's BSPTREE::sphere_intersects_cell_bsp (acclient_2013_pseudo_c.txt:323267 → BSPNODE variant at :325546) as BSPQuery.SphereIntersectsCellBsp(node, center, radius). Replace the point-only check at PhysicsEngine.ResolveCellId:285 with the radius- aware overlap test. Player stays classified indoor as long as ANY part of the foot-sphere still overlaps the indoor cell volume; only flips to outdoor when the sphere is FULLY outside. Retail uses a 0.01 m epsilon on the radius (acclient :325551); ported verbatim. 8 new unit tests cover null/leaf/inside/on-plane/straddling/ fully-outside/tangent-boundary cases plus a regression-anchor test that proves the old PointInsideCellBsp would have returned false for the same straddling input. 1147 + 8 baseline maintained (was 1139 + 8 before #90 fix). Closes #90. A4 multi-cell iteration (shipped earlier today) should now actually exercise in production since the player can stably remain in indoor cells. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1534990102
commit
4ca35966f8
3 changed files with 172 additions and 3 deletions
|
|
@ -962,6 +962,51 @@ public static class BSPQuery
|
|||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issue #90 fix (2026-05-20). Radius-aware sibling of
|
||||
/// <see cref="PointInsideCellBsp"/>. Returns true if a sphere centered
|
||||
/// at <paramref name="center"/> with radius <paramref name="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).
|
||||
///
|
||||
/// <para>
|
||||
/// Used by <see cref="PhysicsEngine.ResolveCellId"/> 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.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Retail oracle: <c>BSPTREE::sphere_intersects_cell_bsp</c> at
|
||||
/// <c>acclient_2013_pseudo_c.txt:323267</c> →
|
||||
/// <c>BSPNODE::sphere_intersects_cell_bsp</c> at
|
||||
/// <c>:325546</c>. Retail adds a 0.01f epsilon to the radius before
|
||||
/// the threshold compare; we match that.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
110
tests/AcDream.Core.Tests/Physics/SphereIntersectsCellBspTests.cs
Normal file
110
tests/AcDream.Core.Tests/Physics/SphereIntersectsCellBspTests.cs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
using System.Numerics;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="BSPQuery.SphereIntersectsCellBsp"/> — 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.
|
||||
/// </summary>
|
||||
public class SphereIntersectsCellBspTests
|
||||
{
|
||||
/// <summary>
|
||||
/// One splitting plane at x=0 with positive normal +X (so the
|
||||
/// "inside" half-space is x ≥ 0). Pos-leaf representing the cell
|
||||
/// interior.
|
||||
/// </summary>
|
||||
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)));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue