From ec29a732f55c35c4630ef8626098d7214851286f Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 6 Aug 2026 14:08:22 +0200 Subject: [PATCH] =?UTF-8?q?test(physics):=20settle=20#331=20=E2=80=94=20th?= =?UTF-8?q?e=20uphill=20"refusal"=20is=20the=20#137=20anti-parallel=20abso?= =?UTF-8?q?rb,=20not=20a=20defect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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 --- docs/ISSUES.md | 163 +++++++------ .../Physics/RemoteRampHarness.cs | 15 ++ .../RuntimeRemoteUphillProgressTests.cs | 216 ++++++++++++++++++ 3 files changed, 319 insertions(+), 75 deletions(-) create mode 100644 tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteUphillProgressTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 6bc00a51..4655bdfc 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -74,98 +74,111 @@ been vacuous evidence. --- -## #331 — `ResolveWithTransition` refuses ALL uphill motion on a constant-gradient terrain ramp (fixture-or-production unresolved) +## #331 — an 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. --- diff --git a/tests/AcDream.Runtime.Tests/Physics/RemoteRampHarness.cs b/tests/AcDream.Runtime.Tests/Physics/RemoteRampHarness.cs index e7f42443..f1866fa6 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RemoteRampHarness.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RemoteRampHarness.cs @@ -20,6 +20,21 @@ namespace AcDream.Runtime.Tests.Physics; /// unchanged; the only additions are , /// , and the root-motion-driving /// overload. +/// +/// ⚠ This ramp's gradient is EXACTLY along Y (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 +/// CTransition::adjust_offset annihilates when a sliding normal is +/// live: the crease cross(sliding, contact) 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 for the retail anchors), +/// so an axis-aligned uphill push against this fixture moves nothing, and +/// any assertion written that way passes vacuously. 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. /// internal sealed class RemoteRampHarness : IDisposable { diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteUphillProgressTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteUphillProgressTests.cs new file mode 100644 index 00000000..25f30142 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteUphillProgressTests.cs @@ -0,0 +1,216 @@ +using System.Numerics; +using AcDream.Core.Physics; + +namespace AcDream.Runtime.Tests.Physics; + +/// +/// #331 — the coverage whose absence made the issue invisible: nothing in +/// the suite asserted that a body-bearing mover makes UPHILL progress on a +/// walkable slope. Every slope assertion we had ran downhill +/// (), and the one uphill test +/// that was written passed vacuously because the body never moved. +/// +/// Both tests here drive the production +/// tick over +/// and take their expected Z from the +/// fixture's own terrain, never from a re-implementation of the projection. +/// +/// +/// What #331 turned out to be (2026-08-06). The resolver does not +/// refuse uphill motion. It refuses a sub-step offset that is +/// exactly anti-parallel to a persisted sliding normal — the +/// #137-family absorb, already recorded as retail-faithful in +/// claude-memory/project_physics_collision_digest.md. The chain is: +/// a landing (or spawn settle) on a slope reaches +/// OBJECTINFO::validate_walkable with state & 1 (CONTACT) +/// clear, so it calls COLLISIONINFO::set_collision_normal with the +/// terrain plane normal (verified in the PDB-paired binary at +/// 0x0050d251-0x0050d26c); CTransition::validate_transition then +/// unconditionally converts that to a sliding normal +/// (0x0050ac19-0x0050ac30), and COLLISIONINFO::set_sliding_normal +/// flattens Z and re-normalizes (0x0050a060) — so even a 1° +/// slope yields a full-length horizontal normal pointing downhill. +/// SetPositionInternal persists it as SLIDING_TS +/// (0x005154c2/0x005154e1), get_object_info re-seeds it next +/// frame (0x00511d44), and CTransition::adjust_offset 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 that axis, so it is annihilated, the sweep aborts at step 0 +/// (0x0050c0ed: test ebx,ebx / jne — retail returns +/// i != 0 && state == OK, exactly as acdream does), and because a +/// failed transition never reaches the writeback the sliding state is never +/// cleared. Latched. +/// +/// Why it read as "ALL uphill motion". +/// 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 F_EPSILON 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. +/// +public sealed class RuntimeRemoteUphillProgressTests +{ + /// + /// Same ramp the downhill tests use: normal Z = 1/sqrt(1.36) ≈ 0.8575 + /// (30.96°) against retail's 0.6642 floor_z 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. + /// + private const float WalkableSlopeGradient = 0.6f; + + private const int TrackedTicks = 30; + + /// + /// 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. + /// + private static readonly Vector3 UphillRootMotionPerTick = + new(0.02588f, -0.09659f, 0f); + + /// Exactly up-slope: the #331 probe's offset. + private static readonly Vector3 ExactlyUpSlopeRootMotionPerTick = + new(0f, -0.1f, 0f); + + /// + /// Same band the downhill tracking test uses. Measured drift on this + /// fixture is under 1e-4 m. + /// + private const float SurfaceTrackingToleranceMeters = 0.005f; + + /// + /// THE MISSING COVERAGE. A remote with a live + /// running up a walkable slope must gain height every tick and keep its + /// feet on the ground while doing it. + /// + /// Asserted per tick, not start-to-end, so a body that stalls for + /// part of the run and catches up later still fails. + /// + /// Sabotage-verified 2026-08-06, both directions. + /// (SAB-A1) Transition.AdjustOffsetreturn Vector3.Zero; + /// 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. + /// + [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"); + } + + /// + /// 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. + /// + /// Sabotage-verified 2026-08-06, both directions. + /// (SAB-B1) deleting the get_object_info sliding-normal seed in + /// PhysicsEngine.ResolveWithTransition reddens the absorb assertion + /// — the body climbs to z 57.7544 instead of standing still — while + /// leaving the sibling test green. (SAB-A1) AdjustOffset → + /// Vector3.Zero reddens the escape/climb assertions. (SAB-A2) a + /// flat ramp reddens the sliding-normal expectation. + /// + /// What does NOT discriminate here, measured, so nobody infers + /// it later: making the FINAL tick exactly up-slope leaves this green. + /// By then the preceding off-gradient tick has already succeeded and its + /// writeback cleared SLIDING, so there is no persisted normal left + /// to absorb against. The absorb needs a live latch, not a particular + /// heading. + /// + [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})"); + } +}