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

@ -74,98 +74,111 @@ been vacuous evidence.
---
## #331`ResolveWithTransition` refuses ALL uphill motion on a constant-gradient terrain ramp (fixture-or-production unresolved)
## #331an exactly-up-slope step is absorbed by the sliding normal a landing leaves behind (headline claim REFUTED; behaviour is retail-faithful)
**Status:** OPEN
**Severity:** **RAISED from UNKNOWN 2026-08-06 at the AD-10 architecture
review — the discriminator is now known and it is NOT the fixture.**
**Status:** DONE — settled 2026-08-06. Not a defect. The headline "refuses ALL
uphill motion" is **false**; it was an artifact of an axis-aligned fixture
driven by axis-aligned motion. Coverage added, fixture annotated.
**Severity:** none as a defect. The mechanism below is real and reachable in
production, but it is retail-faithful at the instruction level and already on
the #137 DO-NOT-RETRY list.
The deciding variable is the **`body:` parameter**, not terrain publication:
### The verdict
- `body: null` → the same uphill sweep climbs fine: `ok=True`, moved
`(0, 0.0999, +0.060)`.
- `body:` supplied → `ok=False` and **zero** movement.
- It reproduces under a call profile **identical to the local player's**
(`IsPlayer | EdgeSlide` plus the human two-sphere Setup list).
- A **diagonal** request keeps its cross-slope X and zeroes only the up-slope
Y — i.e. the slope is behaving as a wall in exactly one direction.
- It fires on a **1.1° ramp**, not just steep ones.
`ResolveWithTransition` refuses a step whose **sub-step offset is exactly
anti-parallel to a live sliding normal**, not uphill motion as such. On the
same fixture, at the same gradient, with the same body:
So the original first lead (terrain publication / synthetic fixture) is now the
*less* likely explanation, and "confined to the fixture" is no longer a
comfortable default: the failing call shape is the shape production uses.
Nothing in the suite asserts uphill progress on a walkable slope, which is why
this has never been caught — the test that found it passed **vacuously**,
because the body never moved.
| per-tick root motion | cross-slope component | result over 5 ticks |
|---|---|---|
| `(0, -0.1, 0)` | 0 | zero movement, latched |
| `(0.0001, -0.1, 0)` | 0.0001 m | zero movement, latched |
| `(0.001, -0.1, 0)` | 0.001 m | **climbs 0.176 m**, latch clears on tick 1 |
| `(0.01, -0.1, 0)` | 0.01 m | **climbs 0.176 m**, latch clears on tick 1 |
Prior severity note, retained: if it reproduces on DAT terrain it is severe and
affects the local player as much as remotes; if it is confined to the synthetic
fixture it is a test-infrastructure defect that silently voids any uphill
assertion written against that fixture — which is how it was found.
**Filed:** 2026-08-06, while measuring AD-10 (commits `fe6ee877`, `886333a2`).
**Not caused by AD-10, and unaffected by its deletion** — the behaviour is
identical with the pre-sweep slope projection enabled and disabled.
The escape threshold is exactly retail's `F_EPSILON` small-offset abort: the
adjusted offset must exceed 0.0002 m. At a 0.1 m sub-step that is a heading
more than about 0.11° off the exact gradient — a ±0.11° window out of 360°.
### What was measured
### The mechanism, end to end
Against `tests/AcDream.Runtime.Tests/Physics/RemoteRampHarness.cs` — a single
synthetic landblock whose terrain is one constant-gradient plane, published via
`PhysicsEngine.AddLandblock` with empty `CellSurface[]` and `PortalPlane[]`
`PhysicsEngine.ResolveWithTransition` returns `ok=False` and the UNCHANGED input
position for every uphill step, while the identical downhill step returns
`ok=True` with a correct slope-following result.
1. A landing (or the spawn settle that compresses it, `SpawnPlacementSettler`)
reaches `OBJECTINFO::validate_walkable` with the OBJECTINFO `CONTACT` bit
clear, so it calls `set_collision_normal` with the **terrain** plane normal.
Verified in the PDB-paired binary: `0x0050d251 test byte ptr [ebp+4],1 /
jne` then `0x0050d261 test eax,eax` (`step_down`) then
`0x0050d26c call set_collision_normal`. acdream `TransitionTypes.cs`
`ValidateWalkable` matches (`!oi.Contact && !sp.StepDown`).
2. `CTransition::validate_transition` unconditionally converts it:
`0x0050ac19 test eax,eax / 0x0050ac21 je / 0x0050ac30 call
set_sliding_normal`. acdream matches.
3. `COLLISIONINFO::set_sliding_normal` (`0x0050a060`) zeroes Z **and
re-normalizes**, so even a 1° slope produces a **full-length horizontal
normal pointing downhill**. acdream matches.
4. `SetPositionInternal` persists it as `SLIDING_TS`
(`0x005154c2` / `0x005154e1`); `get_object_info` re-seeds it next frame
(`0x00511d44 test / 0x00511d4f call init_sliding_normal`). acdream matches.
5. `CTransition::adjust_offset` sees `dot(offset, sliding) < 0` and projects
the step onto the crease `cross(sliding, contact)` — a purely horizontal,
purely cross-slope axis. An exactly-up-slope offset has zero component on
it.
6. The sweep aborts at step 0 and reports failure:
`0x0050c0ed test ebx,ebx / jne 0x0050c089` -> `cmp [esp+14h],1 / jne` ->
`xor eax,eax`. Retail returns `i != 0 && state == OK` — **byte-identical to
acdream's `FindTransitionalPosition`** (Binary Ninja typed this function
`void` and dropped the return value; the disassembly settles it).
7. Because the transition failed, the writeback never runs, so the sliding
state is never cleared -> self-latching until a step with a surviving
component succeeds.
```
from=<96, 96.11029, 57.53382> to=<96, 96.01029, 57.53382> (uphill)
=> pos=<96, 96.11029, 57.53382> ok=False onWalkable=False
from=<96, 96.11029, 57.53382> to=<96, 96.21029, 57.53382> (downhill)
=> pos=<96, 96.18382, 57.489704> ok=True onWalkable=True
```
Cross-checked against ACE (`Transition.cs:1027`,
`CollisionInfo.cs:58`) — same shape.
The downhill answer is right in detail: the XY advance is 0.0735 m for a 0.1 m
request, exactly the `cos^2(theta)` shortening `Transition.AdjustOffset`'s
away-plane arm produces at this gradient (see AD-65). So terrain IS being seen
and the sweep IS projecting; only the uphill direction fails.
### Why it read as "ALL uphill motion"
Ruled out by probe, all with `sphereRadius: 0.48`, `stepUpHeight: 0.4`,
`body:` supplied and a valid walkable contact plane at tick start:
`RemoteRampHarness` builds a ramp whose gradient is exactly along Y, and the
probe pushed exactly along -Y. Axis-aligned fixture x axis-aligned motion hits
the measure-zero anti-parallel case with probability 1. Everything the original
report ruled out (gradient, step size, cell boundaries, Z seating, AD-10) was
correctly ruled out; the variable it did not vary was the **heading relative to
the gradient**.
- **Not gradient-dependent.** Fails at 0.05 (2.9 degrees) exactly as at 0.6
(31 degrees). A 2.9-degree slope is ordinary terrain.
- **Not step-size-dependent.** Fails for a 0.1 m and a 0.5 m request.
- **Not a cell-boundary artifact.** Fails from (96,96) — which is exactly on
both cell boundaries — and equally from (100,100), (100,96), (96,100) and
(110,110) with the cell id recomputed for each.
- **Not a Z-seating artifact.** Fails with the body lifted 0.05 m off the
surface as well as seated on it.
- **Not an axis bug — it follows UPHILL.** With the ramp inverted so it rises
along +Y instead of descending, it is +Y that returns `ok=False` and -Y that
succeeds. The failing direction tracks the slope, not the coordinate.
### Production reachability — stated honestly
### Why this is not obviously a production defect
The latch is production-real in mechanism: a pure gravity fall driven by the
production `RuntimeRemotePhysicsUpdater`, with no fixture settle seam involved,
lands on the ramp and leaves `Contact | OnWalkable | Sliding` with
`slidingNormal = (0, 1, 0)`. The local player runs the same
`ResolveWithTransition` with the same body and the same
`IsPlayer | EdgeSlide` profile. So in production:
Players demonstrably walk uphill in acdream, and the local player runs the same
`ResolveWithTransition`. So either production terrain differs from what this
fixture publishes in some way that matters, or something in the live call
arguments does. The fixture publishes terrain through `AddLandblock` only and
registers no flat-collision statics; production goes through
`LandblockPhysicsContentBuilder.PublishStaticCollision`. That difference was
NOT traced end to end and is the first thing to check.
- **After any landing on a slope, the first step's up-slope component is
deleted** (one frame). This is retail behaviour.
- **Holding a heading within ~0.11° of the exact gradient sticks you until you
turn.** Also retail behaviour as written, and only reachable where a real
terrain triangle's gradient happens to align with the held heading.
### How it was found, and why it matters regardless
**NOT established:** a live DAT-terrain / connected-client reproduction. It was
not run because the discriminator turned out to be offset-vs-gradient
alignment, not terrain provenance — the same triangle plane is produced either
way. If a player ever reports "stuck facing uphill until I turn", this is the
mechanism.
An uphill counterpart to the AD-10 surface-tracking test was written and
PASSED — vacuously. The body never moved, so it "stayed on the surface" by
standing still. The test was dropped rather than shipped. Any future assertion
about uphill movement written against this harness will be vacuous in the same
way until this is resolved, which is reason enough to fix or document it even
if production turns out to be fine.
### What landed
### Next step
- `tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteUphillProgressTests.cs`
the missing coverage (`ARemoteWithABodyClimbsAWalkableSlopeAndKeepsItsFeetOnIt`,
per-tick climb + surface tracking under a realistic heading) plus a
characterization pin for the absorb, both sabotage-verified in both
directions.
- `RemoteRampHarness` now carries a warning block naming the axis-alignment
trap, so the next vacuous uphill assertion is caught at authoring time.
Re-run the same two-position probe against a real DAT-published landblock (the
bake/pak path, or a live capture with `ACDREAM_PROBE_RESOLVE=1` while walking
uphill) and compare `ok`. That single comparison decides severity.
**Do NOT** patch the small-offset abort, add per-frame sliding clearing, or
special-case walkable planes in `validate_walkable` — all three are on the
#137 DO-NOT-RETRY list and all three would be deliberate retail divergences.
If the one-frame deletion is ever judged unacceptable, the lever is the
**provenance** of the sliding normal, and it needs its own brainstorm.
---

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})");
}
}