acdream/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs
Erik 3253d841ac fix(phys): A6.P4 door — pos_hits_sphere records near-miss polygon
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>
2026-05-25 11:05:04 +02:00

719 lines
27 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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