acdream/tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteUphillProgressTests.cs
Erik ec29a732f5 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>
2026-08-06 14:08:22 +02:00

216 lines
10 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.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})");
}
}