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.AdjustOffset → return 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})");
+ }
+}