using System.Numerics;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
///
/// Unit tests for .
///
/// 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.
///
public class BSPQueryTests
{
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
///
/// Build a with four vertices forming a unit
/// square in the XY-plane (Z = 0), ids 0-3.
///
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;
}
///
/// Build a referencing vertex ids 0-3 in order.
///
private static Polygon UnitSquarePolygon() => new Polygon
{
SidesType = DatReaderWriter.Enums.CullMode.None,
VertexIds = new List { 0, 1, 2, 3 },
};
///
/// Build a leaf containing one polygon (id 0)
/// with a bounding sphere that covers the unit square.
///
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();
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 { [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 { [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 { [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 { [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)
// =========================================================================
///
/// 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.
///
private static (PhysicsBSPNode root, Dictionary 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
{
[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);
}
///
/// Build a Transition with WalkableAllowance set to FloorZ — what the
/// indoor walkable-plane synthesis uses.
///
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
{
[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)
// =========================================================================
///
/// 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).
///
private static (PhysicsBSPNode root, Dictionary 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
{
[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.
// =========================================================================
///
/// 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.");
}
}