Retail's CPolygon::pos_hits_sphere at
acclient_2013_pseudo_c.txt:322974-322993 records the polygon pointer
(*arg5 = this at line 00539509) on STATIC overlap BEFORE the front-
face cull (dot(N, movement) >= 0 return 0 at line 0053952f). So when
a sphere statically overlaps a wall but is moving parallel/away from
the wall normal, retail returns 0 (no full hit) but the polygon
pointer IS set so Path 5's set_neg_poly_hit dispatch at
acclient_2013_pseudo_c.txt:0053a6ea fires and the outer
transitional_insert loop slides the sphere along the wall.
Pre-fix our PosHitsSphere set hitPoly only when both the static-
overlap AND the front-face cull passed. Near-miss polygons were
dropped → Path 5's `if (hitPoly0 is not null)` branch never fired →
NegPolyHit stayed false → outer loop never slid → inside-out cottage
doors let spheres squeeze through walls they were touching.
The handoff (docs/research/2026-05-25-door-bug-cdb-retail-trace-findings.md)
hypothesized swept-sphere intersection + closest-considered-polygon
tracking. Reading the actual retail decomp of pos_hits_sphere AND
polygon_hits_sphere_slow_but_sure (acclient_2013_pseudo_c.txt:322504-
322635) showed both functions are STATIC tests; the motion vector is
used only for the front-face cull. The fix is a one-line reordering.
Adds 3 unit tests in BSPQueryTests covering:
- Sphere overlaps wall + moves parallel → NegPolyHit fires (RED→GREEN)
- Sphere overlaps wall + moves away → NegPolyHit fires (RED→GREEN)
- Sphere overlaps wall + moves into → Slid (regression guard, already
passed)
Verification:
* 3 new Path 5 tests pass.
* Full Core suite: 14 failures with-fix vs 17 failures baseline-no-fix.
The with-fix failure set is a STRICT SUBSET of baseline — zero
regressions. The 14 remaining failures are pre-existing static-leak
flakiness between test classes (documented in CLAUDE.md) and 2 stale-
capture LiveCompare_* document-the-bug tests.
* All handoff "must-stay-green" tests pass:
- Directional_OutsideIn_SouthApproach_BlocksAtSlabSouthFace
- Directional_InsideOut_NorthApproach_BlocksAtSlabNorthFace
- CornerSlide_AlcoveEastToCottageNorth_ShouldBlock
- Geometric_DoorSlabAtSphereHeight_OverlapsInZ
- CellarUpTrajectoryReplayTests.LiveCompare_FirstCap_FixClosesCottageFloorCap
(issue #98 CRITICAL — no regression).
Per CLAUDE.md: needs visual verification at Holtburg cottage door
inside-out off-center (~50 cm) scenario before the A6.P4 phase is
marked complete.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
719 lines
27 KiB
C#
719 lines
27 KiB
C#
using System.Numerics;
|
||
using DatReaderWriter.Enums;
|
||
using DatReaderWriter.Types;
|
||
using AcDream.Core.Physics;
|
||
using Xunit;
|
||
|
||
namespace AcDream.Core.Tests.Physics;
|
||
|
||
/// <summary>
|
||
/// Unit tests for <see cref="BSPQuery.SphereIntersectsPoly"/>.
|
||
///
|
||
/// Real BSP data requires dat files (integration-test territory), so these
|
||
/// tests use manually constructed BSP nodes and polygon/vertex data that
|
||
/// match the structure the dat reader would produce.
|
||
/// </summary>
|
||
public class BSPQueryTests
|
||
{
|
||
// -----------------------------------------------------------------------
|
||
// Helpers
|
||
// -----------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Build a <see cref="VertexArray"/> with four vertices forming a unit
|
||
/// square in the XY-plane (Z = 0), ids 0-3.
|
||
/// </summary>
|
||
private static VertexArray UnitSquareVertexArray()
|
||
{
|
||
var va = new VertexArray();
|
||
var positions = new[]
|
||
{
|
||
new Vector3(0f, 0f, 0f),
|
||
new Vector3(1f, 0f, 0f),
|
||
new Vector3(1f, 1f, 0f),
|
||
new Vector3(0f, 1f, 0f),
|
||
};
|
||
for (ushort i = 0; i < positions.Length; i++)
|
||
{
|
||
var sv = new SWVertex { Origin = positions[i], Normal = Vector3.UnitZ };
|
||
va.Vertices[i] = sv;
|
||
}
|
||
return va;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Build a <see cref="Polygon"/> referencing vertex ids 0-3 in order.
|
||
/// </summary>
|
||
private static Polygon UnitSquarePolygon() => new Polygon
|
||
{
|
||
SidesType = DatReaderWriter.Enums.CullMode.None,
|
||
VertexIds = new List<short> { 0, 1, 2, 3 },
|
||
};
|
||
|
||
/// <summary>
|
||
/// Build a leaf <see cref="PhysicsBSPNode"/> containing one polygon (id 0)
|
||
/// with a bounding sphere that covers the unit square.
|
||
/// </summary>
|
||
private static PhysicsBSPNode LeafNode(Sphere bounds)
|
||
{
|
||
var node = new PhysicsBSPNode
|
||
{
|
||
Type = BSPNodeType.Leaf,
|
||
BoundingSphere = bounds,
|
||
};
|
||
node.Polygons.Add(0);
|
||
return node;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Test 1: null node returns false without throwing
|
||
// -----------------------------------------------------------------------
|
||
|
||
[Fact]
|
||
public void SphereIntersectsPoly_NullNode_ReturnsFalse()
|
||
{
|
||
var polygons = new Dictionary<ushort, Polygon>();
|
||
var vertices = new VertexArray();
|
||
|
||
bool hit = BSPQuery.SphereIntersectsPoly(
|
||
null, polygons, vertices,
|
||
new Vector3(0.5f, 0.5f, 0.1f), 0.2f,
|
||
out _, out _);
|
||
|
||
Assert.False(hit);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Test 2: sphere far outside the bounding sphere is fast-rejected
|
||
// -----------------------------------------------------------------------
|
||
|
||
[Fact]
|
||
public void SphereIntersectsPoly_MissesBoundingSphere_ReturnsFalse()
|
||
{
|
||
// Leaf node centred at origin with radius 1.
|
||
var bounds = new Sphere { Origin = Vector3.Zero, Radius = 1f };
|
||
var node = LeafNode(bounds);
|
||
var polygons = new Dictionary<ushort, Polygon> { [0] = UnitSquarePolygon() };
|
||
var vertices = UnitSquareVertexArray();
|
||
|
||
// Sphere is 100 units away — broad phase must reject.
|
||
bool hit = BSPQuery.SphereIntersectsPoly(
|
||
node, polygons, vertices,
|
||
new Vector3(100f, 100f, 100f), 0.5f,
|
||
out _, out _);
|
||
|
||
Assert.False(hit);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Test 3: sphere resting just above the unit-square floor polygon hits
|
||
// -----------------------------------------------------------------------
|
||
|
||
[Fact]
|
||
public void SphereIntersectsPoly_HitsLeafPolygon()
|
||
{
|
||
// Bounding sphere covers the 1×1 unit-square leaf.
|
||
var bounds = new Sphere { Origin = new Vector3(0.5f, 0.5f, 0f), Radius = 2f };
|
||
var node = LeafNode(bounds);
|
||
var polygons = new Dictionary<ushort, Polygon> { [0] = UnitSquarePolygon() };
|
||
var vertices = UnitSquareVertexArray();
|
||
|
||
// Sphere centred at (0.5, 0.5, 0.3) with radius 0.5 should touch Z=0 plane.
|
||
bool hit = BSPQuery.SphereIntersectsPoly(
|
||
node, polygons, vertices,
|
||
new Vector3(0.5f, 0.5f, 0.3f), 0.5f,
|
||
out ushort polyId, out Vector3 normal);
|
||
|
||
Assert.True(hit);
|
||
Assert.Equal(0, polyId);
|
||
// Normal should point roughly upward (+Z).
|
||
Assert.True(normal.Z > 0.9f, $"Expected Z-up normal, got {normal}");
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Test 4: sphere entirely above the polygon (no contact) returns false
|
||
// -----------------------------------------------------------------------
|
||
|
||
[Fact]
|
||
public void SphereIntersectsPoly_SphereTooHigh_ReturnsFalse()
|
||
{
|
||
var bounds = new Sphere { Origin = new Vector3(0.5f, 0.5f, 0f), Radius = 2f };
|
||
var node = LeafNode(bounds);
|
||
var polygons = new Dictionary<ushort, Polygon> { [0] = UnitSquarePolygon() };
|
||
var vertices = UnitSquareVertexArray();
|
||
|
||
// Sphere centred 5 units above the floor with radius 0.3 → no contact.
|
||
bool hit = BSPQuery.SphereIntersectsPoly(
|
||
node, polygons, vertices,
|
||
new Vector3(0.5f, 0.5f, 5f), 0.3f,
|
||
out _, out _);
|
||
|
||
Assert.False(hit);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Test 5: internal node — sphere on positive side recurses pos subtree
|
||
// -----------------------------------------------------------------------
|
||
|
||
[Fact]
|
||
public void SphereIntersectsPoly_InternalNode_PosSubtreeHit()
|
||
{
|
||
// Leaf on the positive side (Z > 0 half-space) contains the floor poly.
|
||
var leafBounds = new Sphere { Origin = new Vector3(0.5f, 0.5f, 0f), Radius = 2f };
|
||
var leafNode = LeafNode(leafBounds);
|
||
|
||
var polygons = new Dictionary<ushort, Polygon> { [0] = UnitSquarePolygon() };
|
||
var vertices = UnitSquareVertexArray();
|
||
|
||
// Splitting plane: Z = 0, normal = +Z, D = 0.
|
||
// Sphere at Z = 0.3 is on the positive side.
|
||
var internalBounds = new Sphere { Origin = new Vector3(0.5f, 0.5f, 0f), Radius = 5f };
|
||
var internalNode = new PhysicsBSPNode
|
||
{
|
||
Type = BSPNodeType.BPnn, // has PosNode only (BPnn = pos + null-neg)
|
||
SplittingPlane = new Plane(Vector3.UnitZ, 0f),
|
||
BoundingSphere = internalBounds,
|
||
PosNode = leafNode,
|
||
NegNode = null,
|
||
};
|
||
|
||
bool hit = BSPQuery.SphereIntersectsPoly(
|
||
internalNode, polygons, vertices,
|
||
new Vector3(0.5f, 0.5f, 0.3f), 0.5f,
|
||
out ushort polyId, out _);
|
||
|
||
Assert.True(hit);
|
||
Assert.Equal(0, polyId);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Test 6: PhysicsDataCache — caches GfxObj with physics data
|
||
// -----------------------------------------------------------------------
|
||
|
||
[Fact]
|
||
public void PhysicsDataCache_CachesGfxObjWithPhysics()
|
||
{
|
||
// We can't easily construct a real GfxObj (field-based dat type),
|
||
// so this test verifies the SetupPhysics cache path which is more
|
||
// easily instantiated.
|
||
var cache = new PhysicsDataCache();
|
||
Assert.Equal(0, cache.GfxObjCount);
|
||
Assert.Equal(0, cache.SetupCount);
|
||
|
||
// GetGfxObj for an unknown id should return null safely.
|
||
Assert.Null(cache.GetGfxObj(0x01000001u));
|
||
Assert.Null(cache.GetSetup(0x02000001u));
|
||
}
|
||
|
||
// =========================================================================
|
||
// FindWalkableSphere — indoor walkable-plane finder (spec 2026-05-19)
|
||
// =========================================================================
|
||
|
||
/// <summary>
|
||
/// Build a single-leaf BSP rooted at one node containing two horizontal
|
||
/// walkable polygons (id 0 at Z=lowerZ, id 1 at Z=upperZ), both covering
|
||
/// the unit square X[0..1] × Y[0..1]. Bounding sphere is sized to enclose
|
||
/// both polys.
|
||
/// </summary>
|
||
private static (PhysicsBSPNode root, Dictionary<ushort, ResolvedPolygon> resolved)
|
||
BuildTwoFloorsBsp(float lowerZ, float upperZ)
|
||
{
|
||
var center = new Vector3(0.5f, 0.5f, (lowerZ + upperZ) * 0.5f);
|
||
float halfHeight = MathF.Abs(upperZ - lowerZ) * 0.5f + 1.0f;
|
||
float radius = MathF.Sqrt(0.5f * 0.5f + 0.5f * 0.5f + halfHeight * halfHeight);
|
||
|
||
var root = new PhysicsBSPNode
|
||
{
|
||
Type = BSPNodeType.Leaf,
|
||
BoundingSphere = new Sphere { Origin = center, Radius = radius },
|
||
};
|
||
root.Polygons.Add(0);
|
||
root.Polygons.Add(1);
|
||
|
||
Vector3[] lowerVerts =
|
||
{
|
||
new Vector3(0f, 0f, lowerZ),
|
||
new Vector3(1f, 0f, lowerZ),
|
||
new Vector3(1f, 1f, lowerZ),
|
||
new Vector3(0f, 1f, lowerZ),
|
||
};
|
||
Vector3[] upperVerts =
|
||
{
|
||
new Vector3(0f, 0f, upperZ),
|
||
new Vector3(1f, 0f, upperZ),
|
||
new Vector3(1f, 1f, upperZ),
|
||
new Vector3(0f, 1f, upperZ),
|
||
};
|
||
|
||
var resolved = new Dictionary<ushort, ResolvedPolygon>
|
||
{
|
||
[0] = new ResolvedPolygon
|
||
{
|
||
Vertices = lowerVerts,
|
||
Plane = new Plane(Vector3.UnitZ, -lowerZ),
|
||
NumPoints = 4,
|
||
SidesType = CullMode.None,
|
||
},
|
||
[1] = new ResolvedPolygon
|
||
{
|
||
Vertices = upperVerts,
|
||
Plane = new Plane(Vector3.UnitZ, -upperZ),
|
||
NumPoints = 4,
|
||
SidesType = CullMode.None,
|
||
},
|
||
};
|
||
|
||
return (root, resolved);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Build a Transition with WalkableAllowance set to FloorZ — what the
|
||
/// indoor walkable-plane synthesis uses.
|
||
/// </summary>
|
||
private static Transition BuildFloorZTransition()
|
||
{
|
||
var transition = new Transition();
|
||
transition.SpherePath.WalkableAllowance = PhysicsGlobals.FloorZ;
|
||
transition.SpherePath.WalkInterp = 1.0f;
|
||
return transition;
|
||
}
|
||
|
||
[Fact]
|
||
public void FindWalkableSphere_TwoFloors_FootBetween_PicksLowerFloor()
|
||
{
|
||
// Two floors at Z=0 and Z=3. Foot sphere center at Z=0.4 (radius 0.48).
|
||
// The sphere overlaps the lower floor (|center.Z - 0| = 0.4 < radius 0.48),
|
||
// so find_walkable can resolve a rest position against it.
|
||
// Upper floor at Z=3 is out of range (dist=2.6 >> radius 0.48).
|
||
// Expect: pick lower floor (id 0).
|
||
var (root, resolved) = BuildTwoFloorsBsp(lowerZ: 0f, upperZ: 3f);
|
||
var transition = BuildFloorZTransition();
|
||
|
||
var sphere = new Sphere { Origin = new Vector3(0.5f, 0.5f, 0.4f), Radius = 0.48f };
|
||
|
||
bool found = BSPQuery.FindWalkableSphere(
|
||
root, resolved, transition,
|
||
sphere,
|
||
probeDistance: 0.5f,
|
||
up: Vector3.UnitZ,
|
||
out var hitPoly,
|
||
out var hitPolyId,
|
||
out var adjustedCenter);
|
||
|
||
Assert.True(found);
|
||
Assert.Equal((ushort)0, hitPolyId);
|
||
Assert.NotNull(hitPoly);
|
||
Assert.Equal(1f, hitPoly!.Plane.Normal.Z, precision: 3); // horizontal floor: normal.Z = 1
|
||
// AdjustSphereToPlane moves the sphere onto the plane along the movement
|
||
// vector. For sphere at Z=0.4, radius 0.48, downward movement -0.5, plane
|
||
// at Z=0: iDist = (0.4-0.48)/-0.5 = 0.16; new center.Z = 0.4 - (-0.5)*0.16 = 0.48.
|
||
Assert.Equal(0.48f, adjustedCenter.Z, precision: 2);
|
||
}
|
||
|
||
[Fact]
|
||
public void FindWalkableSphere_OnlyUpperFloor_FootAbove_PicksUpperFloor()
|
||
{
|
||
// Two floors at Z=0 and Z=3. Foot sphere center at Z=3.4 (radius 0.48).
|
||
// The sphere overlaps the upper floor (|center.Z - 3| = 0.4 < radius 0.48).
|
||
// Lower floor at Z=0 is out of range (dist=3.4 >> radius 0.48).
|
||
// Expect: pick the upper floor (id 1).
|
||
var (root, resolved) = BuildTwoFloorsBsp(lowerZ: 0f, upperZ: 3f);
|
||
var transition = BuildFloorZTransition();
|
||
|
||
var sphere = new Sphere { Origin = new Vector3(0.5f, 0.5f, 3.4f), Radius = 0.48f };
|
||
|
||
bool found = BSPQuery.FindWalkableSphere(
|
||
root, resolved, transition,
|
||
sphere,
|
||
probeDistance: 0.5f,
|
||
up: Vector3.UnitZ,
|
||
out var hitPoly,
|
||
out var hitPolyId,
|
||
out var adjustedCenter);
|
||
|
||
Assert.True(found);
|
||
Assert.Equal((ushort)1, hitPolyId);
|
||
Assert.NotNull(hitPoly);
|
||
Assert.Equal(1f, hitPoly!.Plane.Normal.Z, precision: 3); // horizontal upper floor
|
||
// Same math as Test 1 but offset by 3: adjustedCenter.Z = 3.0 + 0.48 = 3.48.
|
||
Assert.Equal(3.48f, adjustedCenter.Z, precision: 2);
|
||
}
|
||
|
||
[Fact]
|
||
public void FindWalkableSphere_NoWalkableInProbeRange_ReturnsFalse()
|
||
{
|
||
// Two floors at Z=0 and Z=3. Foot at Z=10 with radius 0.48 — out of
|
||
// sphere-overlap range for both polygons (|10-0|=10 >> 0.48, |10-3|=7 >> 0.48).
|
||
// find_walkable requires the sphere to overlap the polygon plane; neither
|
||
// floor is within overlap range, so no hit is found.
|
||
var (root, resolved) = BuildTwoFloorsBsp(lowerZ: 0f, upperZ: 3f);
|
||
var transition = BuildFloorZTransition();
|
||
|
||
var sphere = new Sphere { Origin = new Vector3(0.5f, 0.5f, 10f), Radius = 0.48f };
|
||
|
||
bool found = BSPQuery.FindWalkableSphere(
|
||
root, resolved, transition,
|
||
sphere,
|
||
probeDistance: 0.5f,
|
||
up: Vector3.UnitZ,
|
||
out var hitPoly,
|
||
out var hitPolyId,
|
||
out var adjustedCenter);
|
||
|
||
Assert.False(found);
|
||
Assert.Null(hitPoly);
|
||
Assert.Equal((ushort)0, hitPolyId);
|
||
Assert.Equal(sphere.Origin, adjustedCenter);
|
||
}
|
||
|
||
[Fact]
|
||
public void FindWalkableSphere_SteepPoly_RejectedByWalkableAllowance()
|
||
{
|
||
// One polygon with a steep normal (Z component ≈ 0.5 < FloorZ ≈ 0.6642).
|
||
// WalkableHitsSphere checks dp = dot(up, normal) > WalkableAllowance;
|
||
// dp = 0.5 <= FloorZ → not walkable. Sphere overlaps the plane so
|
||
// PolygonHitsSpherePrecise would pass, but the walkability gate fires first.
|
||
var center = new Vector3(0.5f, 0.5f, 0f);
|
||
var root = new PhysicsBSPNode
|
||
{
|
||
Type = BSPNodeType.Leaf,
|
||
BoundingSphere = new Sphere { Origin = center, Radius = 2f },
|
||
};
|
||
root.Polygons.Add(0);
|
||
|
||
// Plane tilted: normal has Z = 0.5 (60° slope). Build from two orthogonal verts.
|
||
var steepNormal = Vector3.Normalize(new Vector3(0f, MathF.Sqrt(0.75f), 0.5f));
|
||
// Vertices lying on the plane through the origin.
|
||
float rise = MathF.Sqrt(0.75f) / 0.5f; // how much Y-displacement equals 1 unit Z-rise
|
||
Vector3[] verts =
|
||
{
|
||
new Vector3(0f, 0f, 0f),
|
||
new Vector3(1f, 0f, 0f),
|
||
new Vector3(1f, 1f, rise),
|
||
new Vector3(0f, 1f, rise),
|
||
};
|
||
var resolved = new Dictionary<ushort, ResolvedPolygon>
|
||
{
|
||
[0] = new ResolvedPolygon
|
||
{
|
||
Vertices = verts,
|
||
Plane = new Plane(steepNormal, -Vector3.Dot(steepNormal, verts[0])),
|
||
NumPoints = 4,
|
||
SidesType = CullMode.None,
|
||
},
|
||
};
|
||
|
||
var transition = BuildFloorZTransition();
|
||
// Sphere overlapping the tilted plane at the origin side.
|
||
var sphere = new Sphere { Origin = new Vector3(0.5f, 0.5f, 0.3f), Radius = 0.48f };
|
||
|
||
bool found = BSPQuery.FindWalkableSphere(
|
||
root, resolved, transition,
|
||
sphere,
|
||
probeDistance: 0.5f,
|
||
up: Vector3.UnitZ,
|
||
out _,
|
||
out _,
|
||
out _);
|
||
|
||
Assert.False(found);
|
||
}
|
||
|
||
// =========================================================================
|
||
// FindCollisions — world-origin / world-rotation correctness (spec 2026-05-20)
|
||
// =========================================================================
|
||
|
||
/// <summary>
|
||
/// Build a single-leaf BSP with one horizontal floor polygon at the given
|
||
/// cell-local Z. Vertex array forms a 4x4 m square centered at cell-local
|
||
/// (0, 0).
|
||
/// </summary>
|
||
private static (PhysicsBSPNode root, Dictionary<ushort, ResolvedPolygon> resolved)
|
||
BuildSingleFloorBsp(float floorZ)
|
||
{
|
||
var verts = new[]
|
||
{
|
||
new Vector3(-2f, -2f, floorZ),
|
||
new Vector3( 2f, -2f, floorZ),
|
||
new Vector3( 2f, 2f, floorZ),
|
||
new Vector3(-2f, 2f, floorZ),
|
||
};
|
||
|
||
var root = new PhysicsBSPNode
|
||
{
|
||
Type = BSPNodeType.Leaf,
|
||
BoundingSphere = new Sphere
|
||
{
|
||
Origin = new Vector3(0f, 0f, floorZ),
|
||
Radius = 3f,
|
||
},
|
||
};
|
||
root.Polygons.Add(0);
|
||
|
||
var resolved = new Dictionary<ushort, ResolvedPolygon>
|
||
{
|
||
[0] = new ResolvedPolygon
|
||
{
|
||
Vertices = verts,
|
||
Plane = new Plane(Vector3.UnitZ, -floorZ),
|
||
NumPoints = 4,
|
||
SidesType = CullMode.None,
|
||
},
|
||
};
|
||
|
||
return (root, resolved);
|
||
}
|
||
|
||
[Fact]
|
||
public void FindCollisions_StepDown_TranslatedWorldOrigin_WritesWorldSpacePlane()
|
||
{
|
||
// Cell is translated +94 m in world Z (i.e., the cell-local floor at
|
||
// Z=0 sits at world Z=94). This mirrors the Holtburg cottage scenario
|
||
// captured in launch-cp-probe.log (cell 0xA9B40150, floor world
|
||
// Z≈94.02).
|
||
var (root, resolved) = BuildSingleFloorBsp(floorZ: 0f);
|
||
|
||
var transition = new Transition();
|
||
transition.SpherePath.WalkableAllowance = PhysicsGlobals.FloorZ;
|
||
transition.SpherePath.WalkInterp = 1.0f;
|
||
transition.SpherePath.StepDown = true;
|
||
transition.SpherePath.StepDownAmt = 0.5f;
|
||
|
||
// Foot sphere in cell-local space: overlapping the floor (Z=0.4 with
|
||
// radius 0.48 → dist 0.4 < radius−epsilon = 0.4798, so the precise
|
||
// overlap test accepts the contact).
|
||
var localSphere = new Sphere
|
||
{
|
||
Origin = new Vector3(0f, 0f, 0.4f),
|
||
Radius = 0.48f,
|
||
};
|
||
|
||
var state = BSPQuery.FindCollisions(
|
||
root,
|
||
resolved,
|
||
transition,
|
||
localSphere,
|
||
localSphere1: null,
|
||
localCurrCenter: localSphere.Origin,
|
||
localSpaceZ: Vector3.UnitZ,
|
||
scale: 1.0f,
|
||
localToWorld: Quaternion.Identity,
|
||
engine: null,
|
||
worldOrigin: new Vector3(0f, 0f, 94f));
|
||
|
||
// Path 3 step-down should fire and adjust the sphere onto the floor.
|
||
Assert.Equal(TransitionState.Adjusted, state);
|
||
|
||
// ContactPlane must be in world space: normal stays (0,0,1) since the
|
||
// cell rotation is identity; D = -world_floor_Z = -94.
|
||
Assert.True(transition.CollisionInfo.ContactPlaneValid);
|
||
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.
|
||
// =========================================================================
|
||
|
||
/// <summary>
|
||
/// 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
|
||
/// <c>Plane(Vector3.UnitY, 0f)</c> (which the precise overlap test uses
|
||
/// to compute edge cross products).
|
||
/// </summary>
|
||
private static (PhysicsBSPNode root, Dictionary<ushort, ResolvedPolygon> 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<ushort, ResolvedPolygon>
|
||
{
|
||
[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.");
|
||
}
|
||
}
|