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.
---