using System; using System.Collections.Generic; using System.Numerics; using DatReaderWriter.Enums; using DatReaderWriter.Types; using AcDream.Core.Physics; using Xunit; using Xunit.Abstractions; using Plane = System.Numerics.Plane; namespace AcDream.Core.Tests.Physics; /// /// Campaign S slice S4 (2026-08-07) conformance suite for /// Transition.AdjustOffset's two register-row substitutions: /// /// /// AD-65 — the away-from-plane arm (collisionAngle > 0) must /// SNAP to the contact plane (retail's Plane::snap_to_plane /// @0x00509c50: X/Y preserved, Z re-solved, epsilon no-op), not run the /// orthogonal-projection subtraction used by the into-plane arm. /// AD-66 — the safety push-out's trigger comparison and zDist /// numerator must use the BARE global_sphere->radius, not /// radius * ContactPlane.Normal.Z. /// /// /// See docs/research/2026-08-07-s4-adjustoffset-contract.md (the /// pinned contract) and docs/research/2026-08-07-s4-pseudocode.md /// (the full branch-tree derivation, including the resolved Binary Ninja /// flag-idiom ambiguity for the two epsilon-relative comparisons this suite /// exercises). /// public class S4AdjustOffsetConformanceTests { private readonly ITestOutputHelper _out; public S4AdjustOffsetConformanceTests(ITestOutputHelper output) => _out = output; private const float Tolerance = 1e-5f; // ========================================================================= // AD-65 — away-from-plane arm must SNAP (XY preserved, Z re-solved). // ========================================================================= [Fact] public void AdjustOffset_AwayFromPlane_SnapsPreservingXYAndResolvingZ() { // 30 degree contact plane: N = (sin30, 0, cos30) = (0.5, 0, 0.8660254). // Exact unit vector (30-60-90 triangle: 0.5^2 + 0.8660254^2 == 1). var normal = new Vector3(0.5f, 0f, 0.8660254f); var t = new Transition(); t.CollisionInfo.SetContactPlane(new Plane(normal, 0f), cellId: 0, isWater: false); // Moving +X only: dot(offset, N) = 0.5 > 0 -> AWAY from the plane -> snap. var offset = new Vector3(1f, 0f, 0f); Vector3 result = t.AdjustOffset(offset); // snap_to_plane preserves X and Y exactly and re-solves Z: // z = -(x*N.x + y*N.y) / N.z = -(1*0.5 + 0*0) / 0.8660254 = -0.5773502691896258 Assert.Equal(1f, result.X, Tolerance); Assert.Equal(0f, result.Y, Tolerance); Assert.Equal(-0.5773502691896258f, result.Z, Tolerance); _out.WriteLine($"snap result = ({result.X:F7}, {result.Y:F7}, {result.Z:F7})"); } [Fact] public void AdjustOffset_AwayFromPlane_NearVerticalPlane_IsNoOp() { // |N.z| = 0.0001 <= PhysicsGlobals.EPSILON (0.0002) -> snap_to_plane's // divide-guard trips -> the ENTIRE offset (X, Y, and Z) is left // unchanged, not just Z. var normal = new Vector3(1f, 0f, 0.0001f); var t = new Transition(); t.CollisionInfo.SetContactPlane(new Plane(normal, 0f), cellId: 0, isWater: false); // dot(offset, N) = 1*1 + 0 + 0*0.0001 = 1 > 0 -> away-from-plane arm // entered, but the epsilon guard inside must no-op. var offset = new Vector3(1f, 0f, 0f); Vector3 result = t.AdjustOffset(offset); Assert.Equal(offset, result); _out.WriteLine($"epsilon no-op result = ({result.X:F7}, {result.Y:F7}, {result.Z:F7})"); } // ========================================================================= // Into-plane arm — verify unchanged (not part of AD-65's fix, but the // contract requires this exact-value case be covered too). // ========================================================================= [Fact] public void AdjustOffset_IntoPlane_SubtractsFullNormalComponent() { var normal = new Vector3(0.5f, 0f, 0.8660254f); var t = new Transition(); t.CollisionInfo.SetContactPlane(new Plane(normal, 0f), cellId: 0, isWater: false); // Moving -X: dot(offset, N) = -0.5 <= 0 -> INTO the plane -> subtract. var offset = new Vector3(-1f, 0f, 0f); Vector3 result = t.AdjustOffset(offset); // result = offset - N * collisionAngle = (-1,0,0) - (0.5,0,0.8660254)*(-0.5) // = (-0.75, 0, 0.4330127) Assert.Equal(-0.75f, result.X, Tolerance); Assert.Equal(0f, result.Y, Tolerance); Assert.Equal(0.4330127f, result.Z, Tolerance); } // ========================================================================= // AD-66 — safety push-out must use the BARE radius (not radius*N.z) for // both the trigger comparison and the zDist numerator. // ========================================================================= /// /// Constructs a sphere position whose signed plane distance sits strictly /// BETWEEN the old (buggy) radius*N.z threshold and the new /// (retail-faithful) bare-radius threshold. This single fixture /// discriminates BOTH AD-66 sub-fixes at once: /// /// the OLD trigger (dist < radius*N.z - EPSILON) would NOT /// have fired here at all (0.47 is not less than 0.4328); /// the NEW trigger (dist < radius - EPSILON) DOES fire /// (0.47 < 0.4998), and the pushed amount must equal the bare-radius /// zDist formula, not the radius*N.z one. /// /// // AD-66 RELANDED 2026-08-08 (issue #341): the #341 boundary hunt ran 37 // measurements of the historical assert-shape flip across three JIT // tiering configurations and found it unreproducible (37/37 // bit-identical). The reland's own ten-run gate on // RuntimeRemoteUphillProgressTests.AnExactlyUpSlopeOffsetIsAbsorbedByThePersistedSlidingNormal // also came back bit-identical across ten runs. Un-skipped. [Fact] public void AdjustOffset_SafetyPush_UsesBareRadiusForTriggerAndNumerator() { const float radius = 0.5f; var normal = new Vector3(0.5f, 0f, 0.8660254f); // 30 degrees, unit. const float dist = 0.47f; // strictly between radius*N.z (0.4330127) and radius (0.5) // Sanity: confirm the fixture actually sits in the discriminating gap // before trusting the test's own claim about it. float naturalRestingDistOld = radius * normal.Z; Assert.True(dist > naturalRestingDistOld - PhysicsGlobals.EPSILON, "fixture must NOT trip the old radius*N.z trigger"); Assert.True(dist < radius - PhysicsGlobals.EPSILON, "fixture MUST trip the new bare-radius trigger"); var t = new Transition(); t.CollisionInfo.SetContactPlane(new Plane(normal, 0f), cellId: 0xA9B40001u, isWater: false); // globCenter chosen purely along N (Y=0) so dot(globCenter,N)+D == dist // exactly: globCenter.z = dist / N.z (D = 0). float centerZ = dist / normal.Z; t.SpherePath.GlobalSphere[0].Origin = new Vector3(0f, 0f, centerZ); t.SpherePath.GlobalSphere[0].Radius = radius; float checkPosZBefore = t.SpherePath.CheckPos.Z; float globSphereZBefore = t.SpherePath.GlobalSphere[0].Origin.Z; // Zero movement request: collisionAngle == 0 <= 0 takes the (no-op at // zero) into-plane arm, isolating the safety-push block under test. t.AdjustOffset(Vector3.Zero); float expectedZDist = (radius - dist) / normal.Z; // bare-radius numerator float actualPush = t.SpherePath.CheckPos.Z - checkPosZBefore; Assert.True(actualPush > 0f, "the bare-radius trigger must fire and push the sphere up; " + "the old radius*N.z trigger would NOT have fired for this fixture " + $"(dist={dist}, old threshold={naturalRestingDistOld - PhysicsGlobals.EPSILON:F7})."); Assert.Equal(expectedZDist, actualPush, Tolerance); // AddOffsetToCheckPos mirrors the same push onto every active // GlobalSphere entry (NumSphere == 1 here), from ITS OWN baseline — // not CheckPos's baseline, which started at a different Z. Assert.Equal(globSphereZBefore + expectedZDist, t.SpherePath.GlobalSphere[0].Origin.Z, Tolerance); _out.WriteLine($"push = {actualPush:F7} (expected bare-radius zDist = {expectedZDist:F7}); " + $"old naturalRestingDist formula would have given " + $"{(naturalRestingDistOld - dist) / normal.Z:F7} AND would not have fired at all."); } // AD-66 RELANDED 2026-08-08 (issue #341): see the sibling test's comment // above for the boundary-hunt evidence. Un-skipped. [Fact] public void AdjustOffset_SafetyPush_DoesNotFire_WhenAboveBareRadiusThreshold() { const float radius = 0.5f; var normal = new Vector3(0.5f, 0f, 0.8660254f); const float dist = 0.6f; // > radius (0.5) -> comfortably above threshold either way. var t = new Transition(); t.CollisionInfo.SetContactPlane(new Plane(normal, 0f), cellId: 0xA9B40001u, isWater: false); float centerZ = dist / normal.Z; t.SpherePath.GlobalSphere[0].Origin = new Vector3(0f, 0f, centerZ); t.SpherePath.GlobalSphere[0].Radius = radius; float checkPosZBefore = t.SpherePath.CheckPos.Z; t.AdjustOffset(Vector3.Zero); Assert.Equal(checkPosZBefore, t.SpherePath.CheckPos.Z, Tolerance); } // ========================================================================= // Uphill no-flap guard. Written as the S4 contract's AD-66 STOP-condition // scenario. AD-66 relanded 2026-08-08 (issue #341's boundary hunt); this // now runs against the bare-radius push and stays green under the // plant-then-lift mechanism (the lift settles to tangent equilibrium on // first contact and then goes quiet, so it does not re-fire every tick // and does not flap OnWalkable running uphill). // ========================================================================= /// /// 42 degree contact plane (N.z = cos(42deg) = 0.74314 > /// PhysicsGlobals.FloorZ = 0.6642, so it IS walkable, with a margin of /// about 0.079 — "steep but walkable", matching the register row's own /// framing). A single large sloped BSP polygon rising toward +X stands in /// for outdoor terrain (same mechanism: FindEnvCollisions -> /// AdjustOffset -> ValidateWalkable per tick); the mover requests a /// PURELY HORIZONTAL forward step every tick (a purely /// horizontal request against a seeded contact plane — the ordinary /// grounded-movement shape; note PhysicsBody.cs:350-355 documents /// horizontal PROCEEDING as the failure symptom when the contact-plane /// seed is missing, which is precisely what the per-tick assertions /// below rule out) and relies on collision detection against the /// rising polygon plus AdjustOffset's projection/safety-push to keep the /// sphere glued to the surface, exactly the retail per-tick mechanism. /// Sabotage record (SAB-S4-1, 2026-08-07, verified twice — /// implementer and reviewer independently): re-instating /// result -= N * collisionAngle in the away arm reds /// AdjustOffset_AwayFromPlane_SnapsPreservingXYAndResolvingZ with /// X = 0.75 — exactly the cos²30° shrinkage AD-65's register row recorded — /// and reds the near-vertical no-op test with (0, 0, -1e-4). /// /// What the rest of the suite does NOT discriminate: every /// pre-existing test that reaches the away arm /// (RuntimeRemoteSlopeProjectionTests, RuntimeRemoteSteepContactSlideTests) /// asserts lower bounds or XY-invariant offsets that the snap over-satisfies /// — RuntimeRemoteSlopeProjectionTests records its own AdjustOffset /// short-circuit sabotage staying GREEN. The two exact-value tests here are /// the ONLY discriminating coverage for AD-65; the felt 33–100% downhill /// speed-up is the morning gate's G1 row. /// /// [Fact] public void Uphill_NoContactFlapAcrossTicks() { const float radius = 0.5f; const float angleDegrees = 42f; const uint cellId = 0xA9B40157u; float theta = angleDegrees * MathF.PI / 180f; float sinT = MathF.Sin(theta); float cosT = MathF.Cos(theta); Assert.True(cosT > PhysicsGlobals.FloorZ, "fixture sanity: the slope must be walkable by retail's own FloorZ test"); var (engine, root) = BuildSlopeEngine(sinT, cosT, cellId); // Resting root Z at horizontal x0, derived from: sphere center = // root + (0,0,radius); dot(center, N) + D == radius (D == 0); // N = (-sinT, 0, cosT). float x0 = 1.0f; float RestingRootZ(float x) => (radius * (1f - cosT) + sinT * x) / cosT; var body = new PhysicsBody { ContactPlaneValid = true, ContactPlane = new Plane(new Vector3(-sinT, 0f, cosT), 0f), ContactPlaneCellId = cellId, ContactPlaneIsWater = false, TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable, }; Vector3 position = new(x0, 0f, RestingRootZ(x0)); const float dxPerTick = 0.12f; const int ticks = 15; for (int tick = 0; tick < ticks; tick++) { Vector3 target = position + new Vector3(dxPerTick, 0f, 0f); ResolveResult result = engine.ResolveWithTransition( currentPos: position, targetPos: target, cellId: cellId, sphereRadius: radius, sphereHeight: 0f, stepUpHeight: 0.4f, stepDownHeight: 0.4f, isOnGround: true, body: body); _out.WriteLine( $"tick {tick}: ok={result.Ok} pos=({result.Position.X:F4},{result.Position.Y:F4}," + $"{result.Position.Z:F4}) inContact={result.InContact} onWalkable={result.OnWalkable} " + $"planeN=({result.ContactPlane.Normal.X:F4},{result.ContactPlane.Normal.Y:F4}," + $"{result.ContactPlane.Normal.Z:F4})"); Assert.True(result.Ok, $"tick {tick}: transition must not get stuck running uphill"); Assert.True(result.InContact, $"tick {tick}: contact must not be lost running uphill (the AD-66 flap symptom)"); Assert.True(result.OnWalkable, $"tick {tick}: OnWalkable must not flap to false running uphill on a walkable " + "slope. Written as the S4/AD-66 STOP condition; AD-66 relanded 2026-08-08 " + "(#341's boundary hunt) and this guards the bare-radius push under the " + "plant-then-lift mechanism."); position = result.Position; } // retail's unchanged, correct into-plane arm (AD-65's register row: // "Uphill (collisionAngle <= 0) is correct and identical to retail") // projects a purely-horizontal request of dx against a theta-degree // incline down to dx*cos^2(theta) of effective horizontal advance — // removing the into-plane component always costs a cos^2(theta) // factor. That is expected slope-climbing physics, not a stall, so // the progress floor below is calibrated to it rather than a naive // dx-per-tick expectation (which the earlier, wrong version of this // test asserted and which redded even though nothing was stuck). float expectedMinimumAdvance = dxPerTick * cosT * cosT * (ticks - 3); Assert.True(position.X - x0 > expectedMinimumAdvance, $"expected at least {expectedMinimumAdvance:F4} m of horizontal advance " + "(dx*cos^2(theta) per tick, retail's unchanged into-plane projection); " + $"got {position.X - x0:F4} m -- a shortfall here would mean the mover " + "stalled, not merely slowed by the expected slope projection."); } private static (PhysicsEngine Engine, PhysicsBSPNode Root) BuildSlopeEngine( float sinT, float cosT, uint cellId) { // Large sloped quad, plane through the origin: N = (-sinT, 0, cosT), // D = 0 -> z(x) = x * tan(theta). Spans far enough in X/Y to hold the // whole multi-tick uphill run away from any polygon edge. float ZAt(float x) => x * sinT / cosT; Vector3[] vertices = [ new(-10f, -30f, ZAt(-10f)), new(60f, -30f, ZAt(60f)), new(60f, 30f, ZAt(60f)), new(-10f, 30f, ZAt(-10f)), ]; var plane = new Plane(new Vector3(-sinT, 0f, cosT), 0f); var root = new PhysicsBSPNode { Type = BSPNodeType.Leaf, BoundingSphere = new Sphere { Origin = new Vector3(25f, 0f, ZAt(25f)), Radius = 100f }, }; root.Polygons.Add(1); var resolved = new Dictionary { [1] = new ResolvedPolygon { Id = 1, Vertices = vertices, Plane = plane, NumPoints = vertices.Length, SidesType = CullMode.None, }, }; var cell = new CellPhysics { BSP = new PhysicsBSPTree { Root = root }, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity, Resolved = resolved, CellBSP = new CellBSPTree { Root = new CellBSPNode { Type = BSPNodeType.Leaf } }, }; var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; var heights = new byte[81]; var heightTable = new float[256]; for (int i = 0; i < 256; i++) heightTable[i] = i * 1f; engine.AddLandblock(0xA9B4FFFFu, new TerrainSurface(heights, heightTable), Array.Empty(), Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); engine.DataCache.RegisterCellStructForTest(cellId, cell); return (engine, root); } }