diff --git a/src/AcDream.Core/Physics/CollisionPrimitives.cs b/src/AcDream.Core/Physics/CollisionPrimitives.cs
new file mode 100644
index 0000000..3324461
--- /dev/null
+++ b/src/AcDream.Core/Physics/CollisionPrimitives.cs
@@ -0,0 +1,718 @@
+using System;
+using System.Numerics;
+
+namespace AcDream.Core.Physics;
+
+///
+/// Pure-static collision primitive routines ported from the retail acclient.exe.
+///
+///
+/// Each method corresponds to a specific decompiled function from
+/// chunk_00530000.c. Addresses are noted in the per-method XML doc so
+/// they can be cross-checked against the ghidra listing.
+///
+///
+///
+/// "Polygon" in this context is an AC convex face stored in BSP leaves.
+/// The polygon is described by an array of vertices
+/// (wound counter-clockwise when viewed from the normal side) together with a
+/// precomputed . The plane normal is the outward-facing
+/// surface normal; Plane.D is the signed distance from the origin
+/// (positive-D convention: dot(N,p) + D == 0 on the surface).
+///
+///
+public static class CollisionPrimitives
+{
+ // -----------------------------------------------------------------------
+ // Global-equivalent constants (pulled from retail binary DAT addresses)
+ // -----------------------------------------------------------------------
+
+ ///
+ /// Floating-point epsilon used for "near-zero" ray-direction checks
+ /// (_DAT_007ca628 in the retail binary, ≈ 1×10⁻⁴).
+ ///
+ public const float Epsilon = 1e-4f;
+
+ ///
+ /// Relaxed epsilon for the squared-distance comparisons
+ /// (_DAT_007ca5d8 in the retail binary, ≈ 1×10⁻⁸).
+ ///
+ public const float EpsilonSq = 1e-8f;
+
+ // -----------------------------------------------------------------------
+ // 1. SphereIntersectsRay — FUN_005384e0
+ // -----------------------------------------------------------------------
+
+ ///
+ /// Tests whether a ray intersects a sphere and, if so, returns the
+ /// parametric time of the nearest intersection.
+ ///
+ ///
+ /// Decompiled from FUN_005384e0 @ 0x005384E0.
+ /// The sphere is represented by its centre
+ /// and . The ray is defined by a start
+ /// point and direction vector
+ /// (need not be unit length;
+ /// is in terms of that direction's length).
+ ///
+ ///
+ ///
+ /// Returns when the origin is inside the
+ /// sphere (AC treats that as non-colliding, matching the retail binary).
+ ///
+ ///
+ /// Centre of the sphere.
+ /// Radius of the sphere.
+ /// Start point of the ray (param_1[0..2]).
+ /// Direction of the ray (param_2[3..5]).
+ ///
+ /// On success: parametric intersection time ≥ 0.
+ /// Undefined on failure.
+ ///
+ /// if the ray hits the sphere.
+ public static bool SphereIntersectsRay(
+ Vector3 sphereCenter, float sphereRadius,
+ Vector3 rayOrigin, Vector3 rayDir,
+ out double t)
+ {
+ t = 0.0;
+
+ // delta = rayOrigin − sphereCenter
+ float dx = rayOrigin.X - sphereCenter.X;
+ float dy = rayOrigin.Y - sphereCenter.Y;
+ float dz = rayOrigin.Z - sphereCenter.Z;
+
+ // c = |delta|² − r² (positive ⟹ origin is outside sphere)
+ float c = dx * dx + dy * dy + dz * dz - sphereRadius * sphereRadius;
+ if (c <= 0f)
+ return false; // origin is inside — not considered an intersection
+
+ // a = |rayDir|²
+ float a = rayDir.X * rayDir.X + rayDir.Y * rayDir.Y + rayDir.Z * rayDir.Z;
+ if (a < EpsilonSq)
+ return false; // degenerate ray
+
+ // b = −dot(delta, rayDir)
+ float b = -(dx * rayDir.X + dy * rayDir.Y + dz * rayDir.Z);
+
+ // discriminant = b² − c·a
+ float disc = b * b - c * a;
+ if (disc < 0f)
+ return false; // no real intersection
+
+ float sqrtDisc = MathF.Sqrt(disc);
+
+ // retail: if b < sqrtDisc return (b+sqrtDisc)/a else (b−sqrtDisc)/a
+ if (b < sqrtDisc)
+ t = (b + sqrtDisc) / a;
+ else
+ t = (b - sqrtDisc) / a;
+
+ return true;
+ }
+
+ // -----------------------------------------------------------------------
+ // 2. ray_plane_intersect — FUN_00539060
+ // -----------------------------------------------------------------------
+
+ ///
+ /// Finds the parametric time at which a ray intersects a plane, returning
+ /// the result in .
+ ///
+ ///
+ /// Decompiled from FUN_00539060 @ 0x00539060.
+ /// The plane is stored as four floats: normal (XYZ) and D.
+ /// The ray is stored as six floats: origin (XYZ) then direction (XYZ).
+ ///
+ ///
+ ///
+ /// Returns when the ray is parallel to the plane
+ /// (dot(dir, normal) ≈ 0) or when the intersection is behind the ray
+ /// origin (t < 0).
+ ///
+ ///
+ ///
+ /// The plane to test. plane.D is the signed offset such that
+ /// dot(N,p) + D == 0 on the plane surface.
+ ///
+ /// Start point of the ray.
+ /// Direction of the ray (need not be normalised).
+ /// Parametric intersection distance (≥ 0 on success).
+ /// if the ray hits the plane.
+ public static bool RayPlaneIntersect(
+ Plane plane,
+ Vector3 rayOrigin, Vector3 rayDir,
+ out double t)
+ {
+ t = 0.0;
+
+ // denom = dot(rayDir, plane.Normal)
+ float denom = Vector3.Dot(rayDir, plane.Normal);
+ if (MathF.Abs(denom) < Epsilon)
+ return false; // ray is (nearly) parallel to plane
+
+ // numerator = −(dot(rayOrigin, plane.Normal) + plane.D) / denom
+ // Note: retail uses a negated constant (_DAT_0079cc48 = −1.0) then
+ // multiplies, which is equivalent to flipping the sign of
+ // (dot(origin,N) + D).
+ float num = -(Vector3.Dot(rayOrigin, plane.Normal) + plane.D);
+ float tVal = num / denom;
+
+ t = tVal;
+ return tVal >= 0f;
+ }
+
+ // -----------------------------------------------------------------------
+ // 3. calc_normal — FUN_00539110
+ // -----------------------------------------------------------------------
+
+ ///
+ /// Computes the face normal and plane-distance for an N-gon described by
+ /// an ordered list of vertex positions, storing the result in
+ /// and .
+ ///
+ ///
+ /// Decompiled from FUN_00539110 @ 0x00539110.
+ /// The algorithm accumulates a Newell-style cross-product sum across all
+ /// (fan) triangle pairs, then normalises to obtain the unit normal. The
+ /// plane-distance is the average dot-product of all vertices with the
+ /// normal, negated — matching how AC stores Plane.D.
+ ///
+ ///
+ /// Polygon vertices in order (≥ 3 required).
+ /// Computed unit normal (zero-vector if degenerate).
+ ///
+ /// Plane constant D such that dot(N,p) + D == 0 on the surface.
+ ///
+ public static void CalcNormal(
+ ReadOnlySpan vertices,
+ out Vector3 normal,
+ out float planeD)
+ {
+ normal = Vector3.Zero;
+ planeD = 0f;
+
+ int n = vertices.Length;
+ if (n < 3)
+ return;
+
+ // Accumulate cross-product contributions (Newell method)
+ float accX = 0f, accY = 0f, accZ = 0f;
+ var v0 = vertices[0];
+ for (int i = 1; i < n - 1; i++)
+ {
+ var vi = vertices[i];
+ var vi1 = vertices[i + 1];
+
+ // edge a = vi − v0, edge b = vi1 − v0
+ float ax = vi.X - v0.X, ay = vi.Y - v0.Y, az = vi.Z - v0.Z;
+ float bx = vi1.X - v0.X, by = vi1.Y - v0.Y, bz = vi1.Z - v0.Z;
+
+ accX += ay * bz - az * by;
+ accY += az * bx - ax * bz;
+ accZ += ax * by - ay * bx;
+ }
+
+ float len = MathF.Sqrt(accX * accX + accY * accY + accZ * accZ);
+ if (len < EpsilonSq)
+ return;
+
+ float invLen = 1f / len;
+ normal = new Vector3(accX * invLen, accY * invLen, accZ * invLen);
+
+ // planeD = −(average dot(normal, vertex))
+ float dotSum = 0f;
+ for (int i = 0; i < n; i++)
+ dotSum += Vector3.Dot(normal, vertices[i]);
+
+ planeD = -(dotSum / n);
+ }
+
+ // -----------------------------------------------------------------------
+ // 4. sphere_intersects_poly — FUN_00539500
+ // -----------------------------------------------------------------------
+
+ ///
+ /// Returns when a sphere overlaps (or just touches)
+ /// a convex polygon on the positive-normal side.
+ ///
+ ///
+ /// Decompiled from FUN_00539500 @ 0x00539500.
+ /// On success the contact point projected onto the polygon plane is written
+ /// into .
+ ///
+ ///
+ ///
+ /// The algorithm:
+ ///
+ /// - Project sphere centre onto the polygon plane; if the signed
+ /// distance exceeds the radius the sphere cannot touch.
+ /// - Walk each directed edge; if the projected contact lies outside
+ /// an edge check whether it is within the sphere's "inflated" radius —
+ /// returning immediately when an edge vertex is inside the sphere.
+ ///
+ ///
+ ///
+ /// Plane of the polygon (normal + D).
+ /// Polygon vertices in winding order.
+ /// Centre of the sphere.
+ /// Radius of the sphere.
+ ///
+ /// Projected contact point on the polygon plane (valid when result is
+ /// ).
+ ///
+ ///
+ /// when the sphere touches the polygon face.
+ ///
+ public static bool SphereIntersectsPoly(
+ Plane polyPlane,
+ ReadOnlySpan vertices,
+ Vector3 sphereCenter, float sphereRadius,
+ out Vector3 contactPoint)
+ {
+ contactPoint = Vector3.Zero;
+
+ // Signed distance from sphere centre to plane
+ float dist = Vector3.Dot(polyPlane.Normal, sphereCenter) + polyPlane.D;
+ float rad = sphereRadius - Epsilon;
+
+ if (MathF.Abs(dist) > rad)
+ return false;
+
+ // Project sphere centre onto the plane
+ contactPoint = sphereCenter - polyPlane.Normal * dist;
+
+ float radSq = rad * rad - dist * dist; // available slack² for edge tests
+
+ int numVerts = vertices.Length;
+ if (numVerts == 0)
+ return true;
+
+ bool inside = true; // tracks whether contact point is unambiguously inside all edges
+ int prevIdx = numVerts - 1;
+
+ for (int i = 0; i < numVerts; i++)
+ {
+ var v0 = vertices[prevIdx];
+ var v1 = vertices[i];
+ prevIdx = i;
+
+ // edge vector and cross-product with plane normal (perpendicular-to-edge in the plane)
+ var edge = v1 - v0;
+ var disp = contactPoint - v0;
+
+ // edgePerp = edge × normal projected into plane ≈ cross(normal, edge) negated
+ var edgePerp = new Vector3(
+ edge.Z * polyPlane.Normal.Y - edge.Y * polyPlane.Normal.Z,
+ edge.X * polyPlane.Normal.Z - edge.Z * polyPlane.Normal.X,
+ edge.Y * polyPlane.Normal.X - edge.X * polyPlane.Normal.Y);
+
+ float dp = Vector3.Dot(disp, edgePerp);
+
+ if (dp < 0f)
+ {
+ // Contact point is outside this edge
+ float edgePerpLenSq = edgePerp.LengthSquared();
+ if (edgePerpLenSq * radSq < dp * dp)
+ return false; // too far outside to be reached by sphere radius
+
+ // Check if closest edge vertex is within sphere
+ float dispEdge = Vector3.Dot(disp, edge);
+ float edgeLenSq = edge.LengthSquared();
+ if (dispEdge >= 0f && dispEdge < edgeLenSq)
+ return true; // closest point on edge is inside sphere
+
+ inside = false;
+ }
+
+ // Vertex distance check
+ if (disp.LengthSquared() <= radSq)
+ return true;
+ }
+
+ return inside;
+ }
+
+ // -----------------------------------------------------------------------
+ // 5. find_time_of_collision — FUN_00539ba0
+ // -----------------------------------------------------------------------
+
+ ///
+ /// Finds the parametric time at which a moving sphere (radius embedded in
+ /// [3]) first intersects a convex polygon.
+ ///
+ ///
+ /// Decompiled from FUN_00539ba0 @ 0x00539BA0.
+ /// The sphere travels from [0..2] in direction
+ /// [0..2]. The radius is [3].
+ ///
+ ///
+ ///
+ /// Internally:
+ ///
+ /// - Compute the time t at which the sphere's centre reaches
+ /// the offset plane (plane pushed out by radius).
+ /// - Walk each directed edge to verify the intersection point
+ /// actually lies inside the polygon (with sphere-radius tolerance at
+ /// edges).
+ ///
+ /// Returns when the ray is parallel to the plane,
+ /// when the polygon has zero vertices, or when the intersection is behind
+ /// the ray.
+ ///
+ ///
+ /// Plane of the polygon.
+ /// Polygon vertices in winding order.
+ /// Start position of the sphere centre.
+ /// Radius of the sphere.
+ /// Normalised movement direction of the sphere.
+ ///
+ /// Parametric collision time on success. Sign convention matches the
+ /// retail binary: t = (dot(origin,N) + D) / dot(dir,N). When
+ /// the ray is approaching the surface from above (dir·N < 0) the
+ /// returned t is negative; apply as contact = origin − dir*t.
+ ///
+ ///
+ /// when a collision is found.
+ ///
+ public static bool FindTimeOfCollision(
+ Plane polyPlane,
+ ReadOnlySpan vertices,
+ Vector3 sphereOrigin, float sphereRadius,
+ Vector3 rayDir,
+ out float t)
+ {
+ t = 0f;
+
+ // dot(rayDir, planeNormal) — denominator
+ float denom = Vector3.Dot(rayDir, polyPlane.Normal);
+ if (MathF.Abs(denom) < Epsilon)
+ return false;
+
+ int numVerts = vertices.Length;
+
+ // t = (dot(origin, N) + D) / dot(dir, N)
+ // Note: sign is such that contact = origin − dir*t lands on the plane.
+ float num = Vector3.Dot(sphereOrigin, polyPlane.Normal) + polyPlane.D;
+ t = num / denom;
+
+ // Pre-compute the contact centre (constant per-call)
+ var contact = sphereOrigin - rayDir * t;
+
+ float radSq = sphereRadius * sphereRadius;
+
+ bool inside = true;
+ int prevIdx = numVerts - 1;
+
+ for (int i = 0; i < numVerts; i++)
+ {
+ var v0 = vertices[prevIdx];
+ var v1 = vertices[i];
+ prevIdx = i;
+
+ var edge = v1 - v0;
+ var dispFromV0 = contact - v0;
+
+ // edgePerp = cross(N, edge) — inward-facing perpendicular to edge in the polygon plane
+ var edgePerp = new Vector3(
+ edge.Z * polyPlane.Normal.Y - edge.Y * polyPlane.Normal.Z,
+ edge.X * polyPlane.Normal.Z - edge.Z * polyPlane.Normal.X,
+ edge.Y * polyPlane.Normal.X - edge.X * polyPlane.Normal.Y);
+
+ float dp = Vector3.Dot(dispFromV0, edgePerp);
+
+ if (dp < 0f)
+ {
+ float edgePerpLenSq = edgePerp.LengthSquared();
+ if (edgePerpLenSq * radSq < dp * dp)
+ return false;
+
+ float dispEdge = Vector3.Dot(dispFromV0, edge);
+ float edgeLenSq = edge.LengthSquared();
+ if (dispEdge >= 0f && dispEdge < edgeLenSq)
+ return true;
+
+ inside = false;
+ }
+
+ float dispLenSq = dispFromV0.LengthSquared();
+ if (dispLenSq < radSq)
+ return true;
+ }
+
+ // Retail returns the value held in the "inside" tracking variable (1 or 0 pointer)
+ return inside;
+ }
+
+ // -----------------------------------------------------------------------
+ // 6. hits_walkable — FUN_0053a230
+ // -----------------------------------------------------------------------
+
+ ///
+ /// Returns when a sphere touches a walkable polygon
+ /// and the movement direction has a positive component along the polygon
+ /// normal (i.e. the sphere is approaching from below / from the correct
+ /// side).
+ ///
+ ///
+ /// Decompiled from FUN_0053a230 @ 0x0053A230.
+ /// Delegates to ; the "walkable" check
+ /// additionally requires that dot(polyNormal, movementDir) ≥ 0.
+ ///
+ ///
+ /// Plane of the polygon.
+ /// Polygon vertices.
+ /// Centre of the sphere.
+ /// Radius of the sphere.
+ /// Normalised movement direction of the sphere.
+ ///
+ /// when the polygon is hit and is walkable from
+ /// the movement direction.
+ ///
+ public static bool HitsWalkable(
+ Plane polyPlane,
+ ReadOnlySpan vertices,
+ Vector3 sphereCenter, float sphereRadius,
+ Vector3 movementDir)
+ {
+ // The retail function checks sphere_intersects_solid (FUN_00539750)
+ // which is the two-sided version of sphere_intersects_poly. For our
+ // purposes (physics/walkable check) the single-sided version is
+ // sufficient. The walkable side-check is: dot(normal, movementDir) > 0.
+ if (Vector3.Dot(polyPlane.Normal, movementDir) < 0f)
+ return false;
+
+ return SphereIntersectsPoly(polyPlane, vertices, sphereCenter, sphereRadius, out _);
+ }
+
+ // -----------------------------------------------------------------------
+ // 7. find_walkable_collision — FUN_0053a040
+ // -----------------------------------------------------------------------
+
+ ///
+ /// Finds a walkable surface collision for a moving sphere and, on hit,
+ /// writes the outward-facing edge normal of the first crossed edge into
+ /// .
+ ///
+ ///
+ /// Decompiled from FUN_0053a040 @ 0x0053A040.
+ /// Unlike (which returns the face
+ /// contact point), this variant is interested in which edge was
+ /// crossed during movement — useful for computing the slide direction.
+ ///
+ ///
+ ///
+ /// The algorithm:
+ ///
+ /// - Verify the movement direction has a non-zero component along
+ /// the plane normal.
+ /// - Compute the ray-plane intersection time and project the contact
+ /// centre onto the plane.
+ /// - Walk each edge; if the contact lies outside an edge, compute
+ /// and normalise the edge perpendicular and return it.
+ ///
+ ///
+ ///
+ /// Plane of the polygon.
+ /// Polygon vertices.
+ /// Sphere start position.
+ /// Sphere movement direction.
+ ///
+ /// Outward-facing (normalised) edge normal on a crossed edge.
+ /// Populated only when the method returns .
+ ///
+ ///
+ /// when a crossed edge was found (sphere would
+ /// slide off the polygon).
+ ///
+ public static bool FindWalkableCollision(
+ Plane polyPlane,
+ ReadOnlySpan vertices,
+ Vector3 sphereOrigin,
+ Vector3 movementDir,
+ out Vector3 edgeNormal)
+ {
+ edgeNormal = Vector3.Zero;
+
+ float denom = Vector3.Dot(polyPlane.Normal, movementDir);
+ if (MathF.Abs(denom) < Epsilon)
+ return false;
+
+ int numVerts = vertices.Length;
+
+ // Ray-plane time
+ float t = (Vector3.Dot(polyPlane.Normal, sphereOrigin) + polyPlane.D) / denom;
+
+ // Contact centre projected onto polygon plane
+ var contact = sphereOrigin - movementDir * t;
+
+ int prevIdx = numVerts - 1;
+
+ for (int i = 0; i < numVerts; i++)
+ {
+ var v0 = vertices[prevIdx];
+ var v1 = vertices[i];
+ prevIdx = i;
+
+ var edge = v1 - v0;
+ var disp = contact - v0;
+
+ // edgePerp = cross(edge, normal) projected [= normal × edge in retail]
+ var nx = polyPlane.Normal.X;
+ var ny = polyPlane.Normal.Y;
+ var nz = polyPlane.Normal.Z;
+
+ var epX = edge.Z * ny - edge.Y * nz;
+ var epY = edge.X * nz - edge.Z * nx;
+ var epZ = edge.Y * nx - edge.X * ny;
+
+ float dp = disp.X * epX + disp.Y * epY + disp.Z * epZ;
+
+ if (dp < 0f)
+ {
+ // Contact point is outside this edge — this is the crossed edge
+ var raw = new Vector3(epX, epY, epZ);
+ float len = raw.Length();
+ if (len < EpsilonSq)
+ return false;
+
+ edgeNormal = raw / len;
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ // -----------------------------------------------------------------------
+ // 8. slide_sphere — FUN_00538eb0
+ // -----------------------------------------------------------------------
+
+ ///
+ /// Computes the parametric distance a sphere must travel to reach a plane
+ /// when sliding along it, taking into account the sphere's radius.
+ ///
+ ///
+ /// Decompiled from FUN_00538eb0 @ 0x00538EB0.
+ /// Returns a positive value when the sphere should move toward the plane,
+ /// a negative value when it is moving away, or
+ /// / 0f for degenerate cases.
+ ///
+ ///
+ ///
+ /// The retail code uses struct offsets into a "Plane" at +0x20 (normal
+ /// XYZ) and +0x2C (D). A second struct at +0x0C holds the sphere radius.
+ ///
+ ///
+ /// The plane to slide against (normal + D).
+ /// Radius of the sphere.
+ /// Current sphere centre.
+ /// Desired movement direction (unit vector).
+ ///
+ /// Parametric distance along to the plane
+ /// surface, accounting for sphere radius.
+ /// Returns when the sphere is already flush
+ /// with the plane; returns 0f when the movement is perpendicular.
+ ///
+ public static float SlideSphere(
+ Plane plane, float sphereRadius,
+ Vector3 sphereCenter, Vector3 movementDir)
+ {
+ // Signed distance from sphere centre to plane
+ float dist = Vector3.Dot(sphereCenter, plane.Normal) + plane.D;
+
+ if (MathF.Abs(dist) < sphereRadius)
+ return float.MaxValue; // already touching — no slide needed
+
+ float denom = Vector3.Dot(movementDir, plane.Normal);
+ if (MathF.Abs(denom) < Epsilon)
+ return 0f; // movement is parallel to plane
+
+ // offset = ±radius (sign chosen by which side the sphere is on)
+ float offset = dist <= 0f ? -sphereRadius : sphereRadius;
+
+ return (offset - dist) / denom;
+ }
+
+ // -----------------------------------------------------------------------
+ // 9. land_on_sphere — FUN_00538f50
+ // -----------------------------------------------------------------------
+
+ ///
+ /// Steps a sphere "down" onto a plane surface, updating its centre in
+ /// place when the landing is valid.
+ ///
+ ///
+ /// Decompiled from FUN_00538f50 @ 0x00538F50.
+ /// The sphere tries to land on the plane by moving
+ /// −radius·normal (toward the surface). The move is accepted only
+ /// when:
+ ///
+ /// - The movement direction has a non-zero component along the normal
+ /// (sphere is not travelling parallel to the surface).
+ /// - The resulting interpolation factor lies in the valid range
+ /// [−0.1, walkInterp).
+ ///
+ /// When accepted, is modified and
+ /// is updated.
+ ///
+ ///
+ /// Surface plane (normal + D).
+ /// Radius of the sphere.
+ ///
+ /// Sphere centre — updated in place when landing succeeds.
+ ///
+ ///
+ /// Current movement direction (updated in place).
+ ///
+ ///
+ /// Walk interpolation factor. Updated to the new interp value when
+ /// landing is accepted.
+ ///
+ ///
+ /// when the sphere successfully landed on the
+ /// plane.
+ ///
+ public static bool LandOnSphere(
+ Plane plane, float sphereRadius,
+ ref Vector3 sphereCenter, ref Vector3 movementDir,
+ ref float walkInterp)
+ {
+ // Signed distance from sphere centre to plane
+ float distToPlane = Vector3.Dot(sphereCenter, plane.Normal) + plane.D;
+
+ float denom = Vector3.Dot(movementDir, plane.Normal);
+
+ // Retail logic (FUN_00538f50):
+ // if denom > +Eps → moving away from surface → use (-r - dist) / denom
+ // if denom in [−Eps, +Eps] → parallel, return 0
+ // if denom < −Eps → moving toward surface → use (dist - r) / denom
+ float tLand;
+ if (denom > Epsilon)
+ {
+ // Moving away from surface (along positive normal direction)
+ tLand = (-sphereRadius - distToPlane) / denom;
+ }
+ else if (denom >= -Epsilon)
+ {
+ // Parallel to plane
+ return false;
+ }
+ else
+ {
+ // Moving toward surface (against positive normal direction)
+ tLand = (distToPlane - sphereRadius) / denom;
+ }
+
+ // Retail check: newInterp must be in [−0.5, walkInterp)
+ // (_DAT_007ca630 ≈ −0.5 from usage context in FUN_00538f50)
+ float newInterp = (1f - tLand) * walkInterp;
+ if (newInterp >= walkInterp || newInterp < -0.5f)
+ return false;
+
+ // Apply the landing
+ sphereCenter -= movementDir * tLand;
+ walkInterp = newInterp;
+ return true;
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/CollisionPrimitivesTests.cs b/tests/AcDream.Core.Tests/Physics/CollisionPrimitivesTests.cs
new file mode 100644
index 0000000..176dd47
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/CollisionPrimitivesTests.cs
@@ -0,0 +1,483 @@
+using System;
+using System.Numerics;
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// Unit tests for — one or more tests per
+/// ported function, verifying basic correctness against hand-computed ground
+/// truth.
+///
+public class CollisionPrimitivesTests
+{
+ // -----------------------------------------------------------------------
+ // Helpers
+ // -----------------------------------------------------------------------
+
+ ///
+ /// Build a unit-square polygon in the XY-plane (Z = 0), normal = +Z,
+ /// plane.D = 0. Vertices are wound counter-clockwise.
+ ///
+ private static (Plane Plane, Vector3[] Verts) UnitSquareXY()
+ {
+ var plane = new Plane(Vector3.UnitZ, 0f); // Z = 0 plane
+ var verts = new[]
+ {
+ new Vector3(0f, 0f, 0f),
+ new Vector3(1f, 0f, 0f),
+ new Vector3(1f, 1f, 0f),
+ new Vector3(0f, 1f, 0f),
+ };
+ return (plane, verts);
+ }
+
+ // -----------------------------------------------------------------------
+ // 1. SphereIntersectsRay
+ // -----------------------------------------------------------------------
+
+ [Fact]
+ public void SphereIntersectsRay_DirectHit_ReturnsTrue()
+ {
+ // Ray starts 5 units in front of a unit sphere at origin, shoots −Z.
+ var hit = CollisionPrimitives.SphereIntersectsRay(
+ sphereCenter: Vector3.Zero, sphereRadius: 1f,
+ rayOrigin: new Vector3(0f, 0f, 5f),
+ rayDir: new Vector3(0f, 0f, -1f),
+ out double t);
+
+ Assert.True(hit);
+ // t ≈ 4.0 (ray travels from z=5 to z=1, where sphere surface is)
+ Assert.True(t >= 3.9 && t <= 4.1, $"Expected t≈4, got {t}");
+ }
+
+ [Fact]
+ public void SphereIntersectsRay_Miss_ReturnsFalse()
+ {
+ // Ray travels in +X, sphere is at (0,10,0) — clearly a miss.
+ var hit = CollisionPrimitives.SphereIntersectsRay(
+ sphereCenter: new Vector3(0f, 10f, 0f), sphereRadius: 1f,
+ rayOrigin: Vector3.Zero,
+ rayDir: Vector3.UnitX,
+ out double _);
+
+ Assert.False(hit);
+ }
+
+ [Fact]
+ public void SphereIntersectsRay_OriginInsideSphere_ReturnsFalse()
+ {
+ // Origin is inside the sphere — AC does not consider this an intersection.
+ var hit = CollisionPrimitives.SphereIntersectsRay(
+ sphereCenter: Vector3.Zero, sphereRadius: 5f,
+ rayOrigin: new Vector3(1f, 0f, 0f),
+ rayDir: Vector3.UnitX,
+ out double _);
+
+ Assert.False(hit);
+ }
+
+ [Fact]
+ public void SphereIntersectsRay_GrazingHit_ReturnsTrue()
+ {
+ // Ray passes tangentially: origin at (1, 5, 0) dir −Y, sphere at origin radius 1.
+ // Minimum approach distance = 1 (exactly tangent).
+ var hit = CollisionPrimitives.SphereIntersectsRay(
+ sphereCenter: Vector3.Zero, sphereRadius: 1f,
+ rayOrigin: new Vector3(1f, 5f, 0f),
+ rayDir: new Vector3(0f, -1f, 0f),
+ out double _);
+
+ Assert.True(hit);
+ }
+
+ // -----------------------------------------------------------------------
+ // 2. RayPlaneIntersect
+ // -----------------------------------------------------------------------
+
+ [Fact]
+ public void RayPlaneIntersect_PerpendicularRay_ReturnsCorrectT()
+ {
+ // Z=0 plane, ray from (0,0,5) shooting −Z. t should be 5.
+ var plane = new Plane(Vector3.UnitZ, 0f);
+ var hit = CollisionPrimitives.RayPlaneIntersect(
+ plane,
+ rayOrigin: new Vector3(0f, 0f, 5f),
+ rayDir: new Vector3(0f, 0f, -1f),
+ out double t);
+
+ Assert.True(hit);
+ Assert.Equal(5.0, t, precision: 5);
+ }
+
+ [Fact]
+ public void RayPlaneIntersect_ParallelRay_ReturnsFalse()
+ {
+ // Ray in XY plane, plane is Z=0 — parallel, no intersection.
+ var plane = new Plane(Vector3.UnitZ, 0f);
+ var hit = CollisionPrimitives.RayPlaneIntersect(
+ plane,
+ rayOrigin: Vector3.Zero,
+ rayDir: Vector3.UnitX,
+ out double _);
+
+ Assert.False(hit);
+ }
+
+ [Fact]
+ public void RayPlaneIntersect_BehindRay_ReturnsFalse()
+ {
+ // Plane is at Z=10, ray starts at Z=0 and shoots +Z away from plane… wait:
+ // actually the plane at z=−5 (D=5) is behind a ray shooting from z=0 in +Z.
+ // Plane equation: z + 5 = 0 → z = −5.
+ var plane = new Plane(Vector3.UnitZ, 5f); // dot(N,p)+D=0 → z=−5
+ var hit = CollisionPrimitives.RayPlaneIntersect(
+ plane,
+ rayOrigin: new Vector3(0f, 0f, 0f),
+ rayDir: new Vector3(0f, 0f, 1f),
+ out double t);
+
+ // t = −5/1 = −5 < 0 → should return false
+ Assert.False(hit);
+ }
+
+ // -----------------------------------------------------------------------
+ // 3. CalcNormal
+ // -----------------------------------------------------------------------
+
+ [Fact]
+ public void CalcNormal_SquareInXYPlane_NormalIsUnitZ()
+ {
+ var verts = new[]
+ {
+ new Vector3(0f, 0f, 0f),
+ new Vector3(1f, 0f, 0f),
+ new Vector3(1f, 1f, 0f),
+ new Vector3(0f, 1f, 0f),
+ };
+ CollisionPrimitives.CalcNormal(verts, out var normal, out float d);
+
+ // Normal should be ≈ ±Z (ACE winds CCW → +Z)
+ Assert.Equal(0f, normal.X, precision: 5);
+ Assert.Equal(0f, normal.Y, precision: 5);
+ Assert.True(MathF.Abs(MathF.Abs(normal.Z) - 1f) < 0.001f,
+ $"Expected |Z|≈1, got {normal.Z}");
+
+ // The polygon lies on Z=0, so plane.D should be ≈ 0
+ Assert.Equal(0f, d, precision: 5);
+ }
+
+ [Fact]
+ public void CalcNormal_Triangle_NormalIsNormalised()
+ {
+ var verts = new[]
+ {
+ new Vector3(0f, 0f, 0f),
+ new Vector3(2f, 0f, 0f),
+ new Vector3(0f, 2f, 0f),
+ };
+ CollisionPrimitives.CalcNormal(verts, out var normal, out _);
+
+ Assert.True(MathF.Abs(normal.Length() - 1f) < 0.001f,
+ $"Normal should be unit-length, got {normal.Length()}");
+ }
+
+ [Fact]
+ public void CalcNormal_DegeneratePolygon_ReturnsZero()
+ {
+ // All three vertices collinear → degenerate → zero normal
+ var verts = new[]
+ {
+ new Vector3(0f, 0f, 0f),
+ new Vector3(1f, 0f, 0f),
+ new Vector3(2f, 0f, 0f),
+ };
+ CollisionPrimitives.CalcNormal(verts, out var normal, out float d);
+
+ Assert.Equal(Vector3.Zero, normal);
+ }
+
+ // -----------------------------------------------------------------------
+ // 4. SphereIntersectsPoly
+ // -----------------------------------------------------------------------
+
+ [Fact]
+ public void SphereIntersectsPoly_SphereAboveCentre_ReturnsTrue()
+ {
+ var (plane, verts) = UnitSquareXY();
+ // Sphere centred at (0.5, 0.5, 0.5) with radius 0.6 — overlaps the XY polygon.
+ var hit = CollisionPrimitives.SphereIntersectsPoly(
+ plane, verts,
+ new Vector3(0.5f, 0.5f, 0.5f), 0.6f,
+ out var contact);
+
+ Assert.True(hit);
+ // Contact point should be projected onto Z=0
+ Assert.Equal(0f, contact.Z, precision: 4);
+ }
+
+ [Fact]
+ public void SphereIntersectsPoly_SphereFarAbove_ReturnsFalse()
+ {
+ var (plane, verts) = UnitSquareXY();
+ // Sphere centre at Z=5, radius 0.5 — far from the plane.
+ var hit = CollisionPrimitives.SphereIntersectsPoly(
+ plane, verts,
+ new Vector3(0.5f, 0.5f, 5f), 0.5f,
+ out _);
+
+ Assert.False(hit);
+ }
+
+ [Fact]
+ public void SphereIntersectsPoly_SphereOutsideEdge_ReturnsFalse()
+ {
+ var (plane, verts) = UnitSquareXY();
+ // Sphere entirely outside the polygon (to the right in X), too far to graze an edge.
+ var hit = CollisionPrimitives.SphereIntersectsPoly(
+ plane, verts,
+ new Vector3(5f, 0.5f, 0f), 0.3f,
+ out _);
+
+ Assert.False(hit);
+ }
+
+ // -----------------------------------------------------------------------
+ // 5. FindTimeOfCollision
+ // -----------------------------------------------------------------------
+
+ [Fact]
+ public void FindTimeOfCollision_SphereApproachingPlane_ReturnsTrueAndPlaneT()
+ {
+ var (plane, verts) = UnitSquareXY();
+ // Sphere at (0.5, 0.5, 3.0) moving −Z toward the XY polygon.
+ // The retail formula computes t = (dot(origin,N) + D) / dot(dir,N)
+ // = (3 + 0) / dot((0,0,-1),(0,0,1)) = 3 / −1 = −3.
+ // A negative t here means "travel 3 units backwards along −Z" to reach z=0,
+ // which is physically forward motion. The contact at t uses:
+ // contact = origin − dir*t = (0.5,0.5,3) − (0,0,−1)*(−3) = (0.5,0.5,0) ✓
+ var hit = CollisionPrimitives.FindTimeOfCollision(
+ plane, verts,
+ sphereOrigin: new Vector3(0.5f, 0.5f, 3f),
+ sphereRadius: 1f,
+ rayDir: new Vector3(0f, 0f, -1f),
+ out float t);
+
+ Assert.True(hit);
+ // t should be −3 (the signed distance convention matches the retail binary)
+ Assert.Equal(-3f, t, precision: 4);
+ }
+
+ [Fact]
+ public void FindTimeOfCollision_ParallelRay_ReturnsFalse()
+ {
+ var (plane, verts) = UnitSquareXY();
+ var hit = CollisionPrimitives.FindTimeOfCollision(
+ plane, verts,
+ sphereOrigin: new Vector3(0.5f, 0.5f, 0.5f),
+ sphereRadius: 0.2f,
+ rayDir: Vector3.UnitX,
+ out float _);
+
+ Assert.False(hit);
+ }
+
+ // -----------------------------------------------------------------------
+ // 6. HitsWalkable
+ // -----------------------------------------------------------------------
+
+ [Fact]
+ public void HitsWalkable_SphereApproachingFromAbove_ReturnsTrue()
+ {
+ var (plane, verts) = UnitSquareXY();
+ // Moving −Z toward the polygon from above → positive dot(N, movDir) … actually −Z dot +Z < 0.
+ // The walkable surface check requires dot(N, movDir) >= 0 to be approaching correctly.
+ // AC walkable surfaces are "from below": a character standing on a floor approaches from above
+ // but the test is: dot(normal, movDir) > 0 means moving in the same direction as the normal
+ // (away from surface). The retail binary returns 0 when this < 0.
+ // For a floor with +Z normal: moving in +Z (upward off ground) → dot > 0 → walkable check passes.
+ // Moving in −Z (falling) → dot < 0 → not walkable from that direction.
+ // This matches the retail: hits_walkable checks that we're on top of the polygon.
+ var hitFromBelow = CollisionPrimitives.HitsWalkable(
+ plane, verts,
+ new Vector3(0.5f, 0.5f, -0.3f), 0.5f,
+ movementDir: new Vector3(0f, 0f, 1f)); // moving UP toward the underside
+
+ // Movement in +Z direction hits the floor polygon from below → not walkable
+ // (the walkable check gate is: dot(N, movDir) must be >= 0)
+ Assert.True(hitFromBelow); // sphere is within range and direction is positive
+ }
+
+ [Fact]
+ public void HitsWalkable_SphereMovingAwayFromNormal_ReturnsFalse()
+ {
+ var (plane, verts) = UnitSquareXY();
+ // Moving −Z (away from floor normal +Z) → HitsWalkable returns false.
+ var hit = CollisionPrimitives.HitsWalkable(
+ plane, verts,
+ new Vector3(0.5f, 0.5f, 0.3f), 0.5f,
+ movementDir: new Vector3(0f, 0f, -1f));
+
+ Assert.False(hit);
+ }
+
+ // -----------------------------------------------------------------------
+ // 7. FindWalkableCollision
+ // -----------------------------------------------------------------------
+
+ [Fact]
+ public void FindWalkableCollision_InsidePolygon_ReturnsFalse()
+ {
+ var (plane, verts) = UnitSquareXY();
+ // Sphere dead centre above polygon, moving straight down.
+ // The contact point is inside all edges → no crossed edge.
+ var hit = CollisionPrimitives.FindWalkableCollision(
+ plane, verts,
+ sphereOrigin: new Vector3(0.5f, 0.5f, 1f),
+ movementDir: new Vector3(0f, 0f, -1f),
+ out var edgeNormal);
+
+ Assert.False(hit);
+ }
+
+ [Fact]
+ public void FindWalkableCollision_ParallelMovement_ReturnsFalse()
+ {
+ var (plane, verts) = UnitSquareXY();
+ var hit = CollisionPrimitives.FindWalkableCollision(
+ plane, verts,
+ sphereOrigin: new Vector3(0.5f, 0.5f, 0f),
+ movementDir: Vector3.UnitX,
+ out _);
+
+ Assert.False(hit);
+ }
+
+ [Fact]
+ public void FindWalkableCollision_SphereCrossesEdge_ReturnsTrueWithNormal()
+ {
+ var (plane, verts) = UnitSquareXY();
+ // Sphere at (2.5, 0.5, 1), moving normalize(−1,0,−1) toward the polygon.
+ // The ray-plane t = (dot(N, pos) + D) / dot(dir, N) = 1 / (−1/√2) = −√2.
+ // Contact = (2.5,0.5,1) − (−1/√2, 0, −1/√2) * (−√2) = (2.5−1, 0.5, 1−1) = (1.5, 0.5, 0).
+ // (1.5, 0.5) is OUTSIDE the unit square (x > 1), so the right edge (x=1) is crossed.
+ var hit = CollisionPrimitives.FindWalkableCollision(
+ plane, verts,
+ sphereOrigin: new Vector3(2.5f, 0.5f, 1f),
+ movementDir: Vector3.Normalize(new Vector3(-1f, 0f, -1f)),
+ out var edgeNormal);
+
+ Assert.True(hit);
+ Assert.True(edgeNormal.LengthSquared() > 0.5f, "Edge normal should be non-zero");
+ Assert.True(MathF.Abs(edgeNormal.Length() - 1f) < 0.01f, "Edge normal should be unit length");
+ }
+
+ // -----------------------------------------------------------------------
+ // 8. SlideSphere
+ // -----------------------------------------------------------------------
+
+ [Fact]
+ public void SlideSphere_SphereAbovePlane_ReturnsPositiveT()
+ {
+ // Plane at Z=0, sphere at Z=3 (above), radius=0.5, moving −Z.
+ var plane = new Plane(Vector3.UnitZ, 0f);
+ float t = CollisionPrimitives.SlideSphere(
+ plane, sphereRadius: 0.5f,
+ sphereCenter: new Vector3(0f, 0f, 3f),
+ movementDir: new Vector3(0f, 0f, -1f));
+
+ // Should reach the plane when t ≈ 2.5 (z=3 − 0.5 radius = z=0.5 …
+ // offset = −0.5 (radius on neg side), dist=3, t=(−0.5−3)/−1=3.5)
+ Assert.True(t > 0f, $"Expected positive t, got {t}");
+ }
+
+ [Fact]
+ public void SlideSphere_AlreadyTouching_ReturnsMaxValue()
+ {
+ // Sphere centred at Z=0.3 with radius 0.5 → dist=0.3 < radius → already touching
+ var plane = new Plane(Vector3.UnitZ, 0f);
+ float t = CollisionPrimitives.SlideSphere(
+ plane, sphereRadius: 0.5f,
+ sphereCenter: new Vector3(0f, 0f, 0.3f),
+ movementDir: new Vector3(0f, 0f, -1f));
+
+ Assert.Equal(float.MaxValue, t);
+ }
+
+ [Fact]
+ public void SlideSphere_ParallelMovement_ReturnsZero()
+ {
+ var plane = new Plane(Vector3.UnitZ, 0f);
+ float t = CollisionPrimitives.SlideSphere(
+ plane, sphereRadius: 0.5f,
+ sphereCenter: new Vector3(0f, 0f, 3f),
+ movementDir: Vector3.UnitX); // parallel to plane
+
+ Assert.Equal(0f, t);
+ }
+
+ // -----------------------------------------------------------------------
+ // 9. LandOnSphere
+ // -----------------------------------------------------------------------
+
+ [Fact]
+ public void LandOnSphere_SphereJustInsideRadius_LandsSuccessfully()
+ {
+ // Plane at Z=0, sphere centre at Z=0.3 (dist < radius=0.5), moving −Z, walkInterp=1.
+ // The retail land_on_sphere succeeds when the sphere centre is within one radius
+ // of the plane (physically: the sphere is already clipping the surface and needs
+ // to settle onto it).
+ // denom = dot(−Z, +Z) = −1
+ // tLand = (dist − r) / denom = (0.3 − 0.5) / (−1) = 0.2 (positive ✓)
+ // newInterp = (1 − 0.2) * 1 = 0.8 < walkInterp → accept
+ // new center.Z = 0.3 − (−1)*0.2 = 0.5 (resting on surface ✓)
+ var plane = new Plane(Vector3.UnitZ, 0f);
+ var center = new Vector3(0f, 0f, 0.3f);
+ var dir = new Vector3(0f, 0f, -1f);
+ float wi = 1f;
+
+ bool landed = CollisionPrimitives.LandOnSphere(
+ plane, sphereRadius: 0.5f,
+ ref center, ref dir, ref wi);
+
+ Assert.True(landed, "Sphere within radius of plane should land successfully");
+ Assert.True(MathF.Abs(center.Z - 0.5f) < 0.01f,
+ $"After landing centre.Z should be at radius (0.5), got {center.Z}");
+ }
+
+ [Fact]
+ public void LandOnSphere_ParallelMovement_ReturnsFalse()
+ {
+ var plane = new Plane(Vector3.UnitZ, 0f);
+ var center = new Vector3(0f, 0f, 2f);
+ var dir = Vector3.UnitX;
+ float wi = 1f;
+
+ bool landed = CollisionPrimitives.LandOnSphere(
+ plane, sphereRadius: 0.5f,
+ ref center, ref dir, ref wi);
+
+ Assert.False(landed);
+ }
+
+ [Fact]
+ public void LandOnSphere_InvalidWalkInterp_ReturnsFalse()
+ {
+ // walkInterp is 0 → (1 − t) * 0 = 0 which is < walkInterp only if walkInterp > 0.
+ // With walkInterp = 0: newInterp = 0 which is NOT < 0 → passes range check.
+ // Let's use a case where tLand > 1 which makes newInterp < 0.
+ // Sphere at Z=100, plane at Z=0, moving −Z, walkInterp=0.0001 (very small).
+ // tLand = (−0.5 − 100) / (−1) = 100.5 → newInterp = (1−100.5)*0.0001 < 0 → false
+ var plane = new Plane(Vector3.UnitZ, 0f);
+ var center = new Vector3(0f, 0f, 100f);
+ var dir = new Vector3(0f, 0f, -1f);
+ float wi = 0.0001f;
+
+ bool landed = CollisionPrimitives.LandOnSphere(
+ plane, sphereRadius: 0.5f,
+ ref center, ref dir, ref wi);
+
+ Assert.False(landed);
+ }
+}