fix(physics): S4/AD-65 — the away-from-plane response snaps to the surface, as retail does
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

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>
This commit is contained in:
Erik 2026-08-07 02:45:03 +02:00
parent 4721838916
commit d73125d3b0
8 changed files with 859 additions and 40 deletions

View file

@ -0,0 +1,374 @@
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);
}
}