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);
}
}