acdream/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs
Erik a11df5b8d3 fix #137 (mechanism 2): BSP full-hit stubs leaked sliding normals — the corridor absorbing wedge
The Facility Hub corridor dead-stop's second half: after one seam hit,
every forward resolve returned ok=False hit=no with zero advance. The
body-persisted SlidingNormal (-1,0,0) projected the exactly-anti-parallel
corridor push to zero in AdjustOffset and the step loop aborted at step 0
before any collision test could refresh the state.

Audit (docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md):
retail's only in-transition sliding-normal writer is validate_transition
(0x0050ac21); the whole sphere/BSP layer writes NONE (grep-verified), and
the body persistence (SetPositionInternal 0x005154c2, SLIDING_TS bit sync
0x005154e1) runs only on transition success. Our BSPQuery Contact-branch
full-hit responses were stubs (SetCollisionNormal + SetSlidingNormal +
return Slid) where retail dispatches the real slide_sphere — so the seam
hit (a SUCCESSFUL full-advance resolve per the live log) persisted the
phantom wall's normal, which retail's lifecycle structurally cannot do.

- BSPQuery Contact foot full-hit fallback + head full-hit now route
  through Transition.SlideSphereInternal (CSphere::slide_sphere
  0x00537440 — in-frame slide, no sliding-normal write; ACE
  BSPTree.cs:202,310-316). The dead stub is rewritten as the faithful
  BSPTREE::slide_sphere wrapper.
- PhysicsEngine sliding writeback gated on ok (retail success-only
  placement; behaviorally latent, removes the failed-frame leak class).
- Register: TS-4 amended (Path-6 steep-tangent sites still write the
  normal — now documented), TS-45 added (SphereCollision's write — same
  leak class, left for a follow-up out of #171's blast radius).
- Pins: Issue137SlidingNormalLifecycleTests — both site pins RED->GREEN,
  plus the retail persist/absorb/clear wall lifecycle (validate-write
  persistence, faithful absorbed anti-parallel frame, oblique escape
  clears the bit). BSPQueryTests full-hit pin updated to the real slide.

Mechanism 1 (PortalSide portal polys solid in the physics set) stays
OPEN - #137 not closed; the corridor re-test rides that session.

Suites: Core 2545 / App 713 / UI 425 / Net 385, 0 failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 18:08:29 +02:00

747 lines
29 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).
//
// Retail records the near-miss ONLY when num_sphere > 1 (a head sphere
// exists) — acclient_2013_pseudo_c.txt:323852-323879. So this is a
// TWO-sphere mover (foot + head). Both spheres near-miss; retail checks
// the HEAD (sphere1) first → set_neg_poly_hit(0,..) → neg_step_up=FALSE
// (slide). Expected: hit1 == false but hitPoly1 != null →
// NegPolyHitDispatch fires → path.NegPolyHit = true, NegStepUp = false,
// state returns OK. (A single-sphere mover records NOTHING here — that
// retail gate is what un-wedged the B1 grounded step-up; see
// BSPStepUpTests.B1.)
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,
};
// Head sphere — also statically overlaps the wall (Z within poly range).
var localSphere1 = new Sphere
{
Origin = new Vector3(0f, 0.3f, 1.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: localSphere1,
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 — but
// only because this is a TWO-sphere mover (num_sphere > 1 gate,
// acclient_2013_pseudo_c.txt:323852). The head sphere's near-miss
// records the NegPolyHit (neg_step_up=FALSE).
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 localSphere1 = new Sphere
{
Origin = new Vector3(0f, 0.3f, 1.0f),
Radius = 0.48f,
};
var localCurrCenter = localSphere.Origin - new Vector3(0f, 0.05f, 0f);
var state = BSPQuery.FindCollisions(
root,
resolved,
transition,
localSphere,
localSphere1: localSphere1,
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 real slide
// (CSphere::slide_sphere via Transition.SlideSphereInternal). No
// contact plane is seeded on this bare Transition, so the slide takes
// the wall-only branch (project out the into-wall displacement,
// return Slid) — and per retail it must NOT write the sliding normal
// (#137 mechanism 2; validate_transition 0x0050ac21 is the only
// in-transition writer).
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.CollisionInfo.SlidingNormalValid,
"find_collisions must not write the sliding normal — retail's " +
"only in-transition writer is validate_transition (#137).");
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.");
}
}