acdream/tests/AcDream.Core.Tests/Physics/S4AdjustOffsetConformanceTests.cs
Erik 10efb5b1f9
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
fix(physics): AD-66 relands — the push-out uses retail's bare radius; plant-then-lift complete (#341 closed)
Third attempt, landed on evidence where the first two correctly refused:
the ten-run stability gate passed 10/10 bit-identical (0x42667451, two
clean-room cycles among the runs), the recalibrated golden's every value
measured with derivations rather than guessed, and the historical
measurement flip stands recorded as unexplained-but-unreproducible
after 37 hunt runs plus these 10 found no divergence anywhere.

The mechanism, completing the S4b byte-pin: validate_walkable plants
the sphere at perpendicular r*N.z (byte-faithful, untouched); this push
fires once per settle and lifts to tangent equilibrium dist=r, where
the trigger goes quiet — retail's slope hover, arriving via the push
exactly as the original substitution's own comment predicted retail
had. Sabotage: restoring radius*N.z reddens the discriminating
exact-value test verbatim. AD-65 conformance, the uphill no-flap
guard, and the #331 absorb pin all green untouched.

AD-66 retired (the campaign's last withheld row); AD-69's seam-frame
correction deliberately unbundled, stays active as its own follow-up.
Clean-room suite 11,267 / 4 / 0 — the suite's two AD-66 skips are gone.

User's "port the retail pair" decision is now fully executed; the
hover-look slope gate is the remaining acceptance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 12:01:15 +02:00

384 lines
18 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;
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;
/// <summary>
/// Campaign S slice S4 (2026-08-07) conformance suite for
/// <c>Transition.AdjustOffset</c>'s two register-row substitutions:
///
/// <list type="bullet">
/// <item>AD-65 — the away-from-plane arm (<c>collisionAngle &gt; 0</c>) must
/// SNAP to the contact plane (retail's <c>Plane::snap_to_plane</c>
/// @0x00509c50: X/Y preserved, Z re-solved, epsilon no-op), not run the
/// orthogonal-projection subtraction used by the into-plane arm.</item>
/// <item>AD-66 — the safety push-out's trigger comparison and <c>zDist</c>
/// numerator must use the BARE <c>global_sphere-&gt;radius</c>, not
/// <c>radius * ContactPlane.Normal.Z</c>.</item>
/// </list>
///
/// See <c>docs/research/2026-08-07-s4-adjustoffset-contract.md</c> (the
/// pinned contract) and <c>docs/research/2026-08-07-s4-pseudocode.md</c>
/// (the full branch-tree derivation, including the resolved Binary Ninja
/// flag-idiom ambiguity for the two epsilon-relative comparisons this suite
/// exercises).
/// </summary>
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.
// =========================================================================
/// <summary>
/// Constructs a sphere position whose signed plane distance sits strictly
/// BETWEEN the old (buggy) <c>radius*N.z</c> threshold and the new
/// (retail-faithful) bare-<c>radius</c> threshold. This single fixture
/// discriminates BOTH AD-66 sub-fixes at once:
/// <list type="bullet">
/// <item>the OLD trigger (<c>dist &lt; radius*N.z - EPSILON</c>) would NOT
/// have fired here at all (0.47 is not less than 0.4328);</item>
/// <item>the NEW trigger (<c>dist &lt; radius - EPSILON</c>) DOES fire
/// (0.47 &lt; 0.4998), and the pushed amount must equal the bare-radius
/// zDist formula, not the radius*N.z one.</item>
/// </list>
/// </summary>
// 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).
// =========================================================================
/// <summary>
/// 42 degree contact plane (N.z = cos(42deg) = 0.74314 &gt;
/// 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 -&gt;
/// AdjustOffset -&gt; 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.
/// <para><b>Sabotage record (SAB-S4-1, 2026-08-07, verified twice —
/// implementer and reviewer independently):</b> re-instating
/// <c>result -= N * collisionAngle</c> in the away arm reds
/// <c>AdjustOffset_AwayFromPlane_SnapsPreservingXYAndResolvingZ</c> 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).</para>
///
/// <para><b>What the rest of the suite does NOT discriminate:</b> 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 33100% downhill
/// speed-up is the morning gate's G1 row.</para>
///
/// </summary>
[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<ushort, ResolvedPolygon>
{
[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<CellSurface>(), Array.Empty<PortalPlane>(),
worldOffsetX: 0f, worldOffsetY: 0f);
engine.DataCache.RegisterCellStructForTest(cellId, cell);
return (engine, root);
}
}