acdream/tests/AcDream.Core.Tests/Physics/S4AdjustOffsetConformanceTests.cs
Erik d73125d3b0
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): S4/AD-65 — the away-from-plane response snaps to the surface, as retail does
Campaign S slice S4, the half that landed. Retail's CTransition::
adjust_offset @0x0050a370 branches on dot(offset, contactPlane.N) at
0x0050a4fa: moving INTO the plane subtracts the normal component
(0x0050a529), moving AWAY calls Plane::snap_to_plane @0x00509c50 —
which preserves X and Y and re-solves ONLY Z so the offset lies in the
plane (the d terms cancel algebraically), no-op under the
0.000199999995f |N.z| epsilon. acdream ran the orthogonal projection in
BOTH directions, shrinking downhill XY travel by cos^2(theta): 25% at
30 degrees, 50% at 45 — AD-65's recorded shortfall, now retired.

The combined Opus review independently re-derived the algebra, the
branch polarity, the epsilon's bit-identity (17b75139), and the
sabotage magnitude (the re-instated projection yields X = 0.75 =
cos^2 30 exactly), and verified the delta is 4 non-comment lines with
the into-plane arm, the crease arm, and both no-plane arms untouched.
Its blast-radius sweep found the away arm exercised but NOT
discriminated by any pre-existing test — every one asserts lower
bounds the snap over-satisfies — so the two new exact-value tests are
the only discriminating coverage, recorded in the test's class doc,
and the felt 33-100% downhill speed-up is the morning gate's one row.

AD-66 (the push-out's bare radius) is WITHHELD: byte-confirmed twice,
implemented, then pulled after the same clean-room binaries measured
contradictory absorbed-tick outcomes flipping with nothing but test
assert shape — issue #341 carries the observation matrix and the
apparatus plan; its two exact-value tests are [Skip]-ed; the retained
substitution's rationale is restored at the site per review F1, with
the review's remaining findings (F2/F3/F4/F5/F6) applied and F8 filed
as #342. AD-69 filed: the same block omits retail's get_block_offset
seam-frame correction, deferred to the AD-66 relanding for
attributability. #340 filed: a fifth load-sensitive flake.

Review verdict: PASS. AD-65 is provably unable to reach the #341
anomaly's code path (the absorb scenario takes the crease arm).
Clean-room suite: 11,239 passed / 6 skipped / 0 failed.

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

374 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>
[Fact(Skip = "AD-66 WITHHELD 2026-08-07: retail's bare radius is byte-confirmed but the landing was pulled after the same clean-room binaries measured contradictory absorbed-tick behaviour flipping with test-assert shape alone. Unskip with the AD-66 relanding. See the S4 outcome note in the contract doc.")]
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.");
}
[Fact(Skip = "AD-66 WITHHELD 2026-08-07: retail's bare radius is byte-confirmed but the landing was pulled after the same clean-room binaries measured contradictory absorbed-tick behaviour flipping with test-assert shape alone. Unskip with the AD-66 relanding. See the S4 outcome note in the contract doc.")]
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 was then WITHHELD (issue #341), so this currently runs
// against the RETAINED radius*N.z substitution and its green is evidence
// about THAT code. It must stay green across the AD-66 relanding too —
// it is the scenario the substitution was originally written to protect.
// =========================================================================
/// <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; with AD-66 withheld (#341) it " +
"guards the RETAINED substitution and must survive the relanding.");
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);
}
}