test(physics): settle #331 — the uphill "refusal" is the #137 anti-parallel absorb, not a defect

#331 reported that `PhysicsEngine.ResolveWithTransition` refuses ALL uphill
motion whenever a `body:` is supplied. It does not. It refuses a step whose
sub-step offset is exactly anti-parallel to a live sliding normal — the
#137-family absorb this project already recorded as retail-faithful.

Measured on the same fixture, same gradient, same body, varying only the
heading relative to the slope gradient:

  (0,     -0.1, 0)  cross-slope 0        -> zero movement, latched
  (0.0001,-0.1, 0)  cross-slope 0.0001 m -> zero movement, latched
  (0.001, -0.1, 0)  cross-slope 0.001 m  -> climbs 0.176 m in 5 ticks
  (0.01,  -0.1, 0)  cross-slope 0.01 m   -> climbs 0.176 m in 5 ticks

The threshold is retail's own F_EPSILON small-offset abort (0.0002 m): about
0.11 degrees off the exact gradient at a 0.1 m step. `RemoteRampHarness`
builds a ramp whose gradient is exactly along Y and the original probe pushed
exactly along -Y, so it hit the measure-zero case with probability 1.

The latch itself is production-real in mechanism — a pure gravity fall under
the production RuntimeRemotePhysicsUpdater, with no fixture settle seam
involved, lands leaving Contact|OnWalkable|Sliding with slidingNormal (0,1,0)
— but every link is faithful to retail, verified in the PDB-paired binary
rather than Binary Ninja (BN typed find_transitional_position `void` and
dropped the load-bearing return value):

  validate_walkable sets collision_normal from the terrain plane when
    OBJECTINFO CONTACT is clear      0x0050d251 / 0x0050d261 / 0x0050d26c
  validate_transition converts it unconditionally  0x0050ac19-0x0050ac30
  set_sliding_normal zeroes Z AND re-normalizes    0x0050a060
  SetPositionInternal persists SLIDING_TS          0x005154c2 / 0x005154e1
  get_object_info re-seeds it next frame           0x00511d44 / 0x00511d4f
  find_transitional_position returns
    `i != 0 && state == OK` on the step-0 abort     0x0050c0ed -> 0x0050c089

ACE agrees (Transition.cs:1027, CollisionInfo.cs:58). No production code
changed; no divergence introduced, so no register row.

What lands is the coverage whose absence made this invisible — nothing in the
suite asserted that a body-bearing mover makes uphill progress on a walkable
slope, and the test that found #331 passed vacuously because the body never
moved:

  RuntimeRemoteUphillProgressTests.ARemoteWithABodyClimbsAWalkableSlopeAndKeepsItsFeetOnIt
    per-tick climb + surface tracking under a realistic off-gradient heading.
    SAB-A1 AdjustOffset -> Vector3.Zero            reddens at tick 1
    SAB-A2 fixture gradient -> 0 (flat)            reddens at tick 1
  RuntimeRemoteUphillProgressTests.AnExactlyUpSlopeOffsetIsAbsorbedByThePersistedSlidingNormal
    characterization pin for the absorb, with the retail anchors inline.
    SAB-B1 delete the get_object_info sliding seed  reddens (climbs to 57.7544)
    SAB-A1                                          reddens
    SAB-A2                                          reddens
    NON-discriminating, measured and documented: making the final tick
    exactly up-slope leaves it green — by then the latch is already cleared.

RemoteRampHarness gains a warning block naming the axis-alignment trap so the
next vacuous uphill assertion is caught at authoring time.

Suite re-measured from a full clean (43 bin/obj removed): 11,198 passed /
4 skipped / 0 failed, against the 11,196/4/0 baseline at 0d62a5ff — exactly
the two tests added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-06 14:08:22 +02:00
parent 0d62a5ffeb
commit ec29a732f5
3 changed files with 319 additions and 75 deletions

View file

@ -20,6 +20,21 @@ namespace AcDream.Runtime.Tests.Physics;
/// unchanged; the only additions are <see cref="Surface"/>,
/// <see cref="SurfaceZ"/>, and the root-motion-driving
/// <see cref="Tick(int, Vector3, float)"/> overload.</para>
///
/// <para><b>⚠ This ramp's gradient is EXACTLY along Y</b> (the heightmap
/// varies only with y), so a test that pushes exactly along ±Y is exactly
/// parallel to the slope gradient. That is the measure-zero case retail's
/// <c>CTransition::adjust_offset</c> annihilates when a sliding normal is
/// live: the crease <c>cross(sliding, contact)</c> is the pure cross-slope
/// axis, an exactly-up-slope offset has zero component on it, and the sweep
/// aborts at step 0 leaving the body where it was. A settled or freshly
/// landed body always carries such a normal (the flattened contact plane —
/// see <see cref="RuntimeRemoteUphillProgressTests"/> for the retail anchors),
/// so <b>an axis-aligned uphill push against this fixture moves nothing, and
/// any assertion written that way passes vacuously.</b> This is what #331
/// measured. Push at a realistic off-gradient heading (≥ 0.0002 m of
/// cross-slope component per sub-step, i.e. more than about 0.11° off the
/// gradient at a 0.1 m step) unless the absorb is the thing under test.</para>
/// </summary>
internal sealed class RemoteRampHarness : IDisposable
{

View file

@ -0,0 +1,216 @@
using System.Numerics;
using AcDream.Core.Physics;
namespace AcDream.Runtime.Tests.Physics;
/// <summary>
/// #331 — the coverage whose absence made the issue invisible: <b>nothing in
/// the suite asserted that a body-bearing mover makes UPHILL progress on a
/// walkable slope</b>. Every slope assertion we had ran downhill
/// (<see cref="RuntimeRemoteSlopeProjectionTests"/>), and the one uphill test
/// that was written passed <i>vacuously</i> because the body never moved.
///
/// <para>Both tests here drive the production
/// <see cref="AcDream.Runtime.Physics.RuntimeRemotePhysicsUpdater"/> tick over
/// <see cref="RemoteRampHarness"/> and take their expected Z from the
/// fixture's own terrain, never from a re-implementation of the projection.
/// </para>
///
/// <para><b>What #331 turned out to be (2026-08-06).</b> The resolver does not
/// refuse uphill motion. It refuses a sub-step offset that is
/// <i>exactly anti-parallel</i> to a persisted sliding normal — the
/// #137-family absorb, already recorded as retail-faithful in
/// <c>claude-memory/project_physics_collision_digest.md</c>. The chain is:
/// a landing (or spawn settle) on a slope reaches
/// <c>OBJECTINFO::validate_walkable</c> with <c>state &amp; 1</c> (CONTACT)
/// clear, so it calls <c>COLLISIONINFO::set_collision_normal</c> with the
/// <i>terrain</i> plane normal (verified in the PDB-paired binary at
/// <c>0x0050d251-0x0050d26c</c>); <c>CTransition::validate_transition</c> then
/// unconditionally converts that to a sliding normal
/// (<c>0x0050ac19-0x0050ac30</c>), and <c>COLLISIONINFO::set_sliding_normal</c>
/// flattens Z and <b>re-normalizes</b> (<c>0x0050a060</c>) — so even a 1°
/// slope yields a full-length horizontal normal pointing downhill.
/// <c>SetPositionInternal</c> persists it as <c>SLIDING_TS</c>
/// (<c>0x005154c2/0x005154e1</c>), <c>get_object_info</c> re-seeds it next
/// frame (<c>0x00511d44</c>), and <c>CTransition::adjust_offset</c> projects
/// the step onto the crease <c>cross(sliding, contact)</c> — a purely
/// horizontal, purely cross-slope axis. An exactly-up-slope offset has zero
/// component on that axis, so it is annihilated, the sweep aborts at step 0
/// (<c>0x0050c0ed</c>: <c>test ebx,ebx / jne</c> — retail returns
/// <c>i != 0 &amp;&amp; state == OK</c>, exactly as acdream does), and because a
/// failed transition never reaches the writeback the sliding state is never
/// cleared. Latched.</para>
///
/// <para><b>Why it read as "ALL uphill motion".</b> <see cref="RemoteRampHarness"/>
/// builds a ramp whose gradient is exactly along Y, and the probe that found
/// #331 pushed exactly along Y. Axis-aligned fixture × axis-aligned motion
/// hits the measure-zero anti-parallel case with probability 1. The escape
/// window is the retail <c>F_EPSILON</c> abort: the step needs
/// ≥ 0.0002 m of cross-slope component, i.e. a heading more than about 0.11°
/// off the exact gradient at a 0.1 m step. Measured on this fixture: 0.0001 m
/// of cross-slope stays latched, 0.001 m climbs 0.176 m in five ticks.</para>
/// </summary>
public sealed class RuntimeRemoteUphillProgressTests
{
/// <summary>
/// Same ramp the downhill tests use: normal Z = 1/sqrt(1.36) ≈ 0.8575
/// (30.96°) against retail's 0.6642 <c>floor_z</c> limit (48.4°), so the
/// slope is comfortably walkable and a failure to climb is unmistakable.
/// The ramp descends along +Y, so Y is uphill.
/// </summary>
private const float WalkableSlopeGradient = 0.6f;
private const int TrackedTicks = 30;
/// <summary>
/// Body-local root displacement per tick for a running remote: 0.1 m at
/// 30 Hz is a 3 m/s run, heading about 15° off the exact up-slope
/// direction — an ordinary heading, well outside the 0.11° absorb window
/// documented on the class.
/// </summary>
private static readonly Vector3 UphillRootMotionPerTick =
new(0.02588f, -0.09659f, 0f);
/// <summary>Exactly up-slope: the #331 probe's offset.</summary>
private static readonly Vector3 ExactlyUpSlopeRootMotionPerTick =
new(0f, -0.1f, 0f);
/// <summary>
/// Same band the downhill tracking test uses. Measured drift on this
/// fixture is under 1e-4 m.
/// </summary>
private const float SurfaceTrackingToleranceMeters = 0.005f;
/// <summary>
/// THE MISSING COVERAGE. A remote with a live <see cref="PhysicsBody"/>
/// running up a walkable slope must gain height every tick and keep its
/// feet on the ground while doing it.
///
/// <para>Asserted per tick, not start-to-end, so a body that stalls for
/// part of the run and catches up later still fails.</para>
///
/// <para><b>Sabotage-verified 2026-08-06</b>, both directions.
/// (SAB-A1) <c>Transition.AdjustOffset</c> → <c>return Vector3.Zero;</c>
/// reddens it at tick 1 with zero climb. (SAB-A2) flattening the fixture
/// ramp to gradient 0 reddens it at tick 1 (z 0.00000 → 0.00000), proving
/// the climb is not an artifact of the settle.</para>
/// </summary>
[Fact]
public void ARemoteWithABodyClimbsAWalkableSlopeAndKeepsItsFeetOnIt()
{
using RemoteRampHarness harness =
RemoteRampHarness.OnRamp(WalkableSlopeGradient);
PhysicsBody body = harness.Remote.Body;
Assert.True(body.OnWalkable);
// A body that has just landed — or been settled, which is the same
// thing compressed — carries SLIDING with the flattened contact
// normal (see the sibling test). Retail deletes the up-slope
// component of exactly ONE step against it, and that step clears the
// latch. Consume it explicitly, and assert it really was only one, so
// the climb assertions below measure steady-state running rather than
// silently tolerating a stall.
harness.Tick(1, UphillRootMotionPerTick);
Assert.True(
(body.TransientState & TransientStateFlags.Sliding) == 0,
"the landing sliding latch survived its first off-gradient step");
float startZ = body.Position.Z;
float previousZ = startZ;
// The settled resting offset between the body's root and the terrain
// directly beneath it. Measured, not assumed.
float restingOffset = body.Position.Z - harness.SurfaceZUnderBody();
for (int tick = 1; tick <= TrackedTicks; tick++)
{
harness.Tick(1, UphillRootMotionPerTick);
Assert.True(
body.Position.Z > previousZ,
$"tick {tick}: body gained no height running uphill "
+ $"(z {previousZ:F5} -> {body.Position.Z:F5}, pos {body.Position})");
float offset = body.Position.Z - harness.SurfaceZUnderBody();
Assert.True(
MathF.Abs(offset - restingOffset) < SurfaceTrackingToleranceMeters,
$"tick {tick}: body root sits {offset:F5} m above the terrain "
+ $"under it, expected {restingOffset:F5} m (pos {body.Position})");
previousZ = body.Position.Z;
}
float ascent = body.Position.Z - startZ;
Assert.True(
ascent > 1.0f,
$"fixture is not exercising slope ascent: dz = {ascent:F4} m");
}
/// <summary>
/// Characterization pin for the #331 absorb itself, so the next person to
/// hit it finds the answer instead of re-deriving it. This asserts
/// RETAIL-FAITHFUL behaviour (every link verified in the PDB-paired binary
/// — see the class doc comment); it is NOT an approved-defect marker and
/// must not be "fixed" by loosening the small-offset abort or clearing the
/// sliding state per frame. Both of those are explicitly on the #137
/// DO-NOT-RETRY list; the lever, if one is ever wanted, is the PROVENANCE
/// of the sliding normal.
///
/// <para><b>Sabotage-verified 2026-08-06</b>, both directions.
/// (SAB-B1) deleting the <c>get_object_info</c> sliding-normal seed in
/// <c>PhysicsEngine.ResolveWithTransition</c> reddens the absorb assertion
/// — the body climbs to z 57.7544 instead of standing still — while
/// leaving the sibling test green. (SAB-A1) <c>AdjustOffset</c> →
/// <c>Vector3.Zero</c> reddens the escape/climb assertions. (SAB-A2) a
/// flat ramp reddens the sliding-normal expectation.</para>
///
/// <para><b>What does NOT discriminate here, measured, so nobody infers
/// it later:</b> making the FINAL tick exactly up-slope leaves this green.
/// By then the preceding off-gradient tick has already succeeded and its
/// writeback cleared <c>SLIDING</c>, so there is no persisted normal left
/// to absorb against. The absorb needs a live latch, not a particular
/// heading.</para>
/// </summary>
[Fact]
public void AnExactlyUpSlopeOffsetIsAbsorbedByThePersistedSlidingNormal()
{
using RemoteRampHarness harness =
RemoteRampHarness.OnRamp(WalkableSlopeGradient);
PhysicsBody body = harness.Remote.Body;
// The state a landing (or the spawn settle that compresses it) leaves
// behind on any slope: SLIDING carrying the contact plane's normal
// flattened to XY and re-normalized — here the ramp's exact downhill
// direction, at full length despite the slope being only 31°.
Assert.True((body.TransientState & TransientStateFlags.Sliding) != 0);
Assert.True(
Vector3.Distance(body.SlidingNormal, new Vector3(0f, 1f, 0f)) < 0.001f,
$"expected the flattened ramp normal, got {body.SlidingNormal}");
Vector3 latched = body.Position;
harness.Tick(5, ExactlyUpSlopeRootMotionPerTick);
Assert.Equal(latched, body.Position);
Assert.True((body.TransientState & TransientStateFlags.Sliding) != 0);
// One ordinary off-gradient tick is itself absorbed — the crease is the
// pure cross-slope axis, so only the X component survives — but it
// succeeds, so the writeback clears the latch.
harness.Tick(1, UphillRootMotionPerTick);
Assert.True((body.TransientState & TransientStateFlags.Sliding) == 0);
Assert.True(
body.Position.X > latched.X,
$"the cross-slope component was absorbed too (pos {body.Position})");
Assert.Equal(latched.Z, body.Position.Z, 4);
// From the next tick on the body climbs normally.
harness.Tick(1, UphillRootMotionPerTick);
Assert.True(
body.Position.Z > latched.Z,
$"body did not climb once the latch cleared (pos {body.Position})");
}
}