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
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>
384 lines
18 KiB
C#
384 lines
18 KiB
C#
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 > 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->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 < 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 < radius - EPSILON</c>) DOES fire
|
||
/// (0.47 < 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 >
|
||
/// 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.
|
||
/// <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 33–100% 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);
|
||
}
|
||
}
|