fix(physics): port retail stair edge backprobe

This commit is contained in:
Erik 2026-07-31 09:26:28 +02:00
parent 5a0f9868a6
commit 2dcb4f1d94
6 changed files with 218 additions and 6 deletions

View file

@ -98,6 +98,38 @@ Copy this block when adding a new issue:
---
## #271 — Stair-side collision reverses uphill movement and rapidly slides the player down
**Status:** DONE — 2026-07-31 (implementation + user live gate)
**Severity:** MEDIUM (movement feel and navigation)
**Component:** physics / `edge_slide` / `precipice_slide`
**Symptom:** while running diagonally up an outdoor staircase and pressing
against its side, the character could suddenly move backward and slide rapidly
to the bottom.
**Root cause:** a 677-quantum live trace caught the first bad frame. A valid
X side-wall collision turned a requested `+0.88377` uphill Y displacement into
`-0.34059`, followed three frames later by a 1.52 m snap to terrain.
`EdgeSlideAfterStepDownFailed` promoted ACDream's separately retained
`LastWalkable` polygon into the current `SPHEREPATH::walkable` slot. At the
stair side this could be the preceding tread, so `PrecipiceSlide` projected
against stale geometry and reversed the tangent.
Named-retail `CTransition::edge_slide @ 0x0050B3D0` never substitutes an older
polygon: when current `walkable` is null it back-probes at the current sphere
center, restores the failed candidate, and only then invokes
`SPHEREPATH::precipice_slide @ 0x0050CC80`. Both stale-history substitutions
are removed. The exact captured frame is pinned against the installed stair
fixture: pre-fix output moved downhill to Y `75.346481`, while the retail-flow
result advances uphill to Y `76.078186` and Z `60.016247`. Core Release passes
4,108 tests / 2 skips; the complete Release solution passes 10,062 tests /
5 skips. The user then repeatedly climbed the same stairs while pressing into
their sides and confirmed the rapid downhill slide was gone. Evidence:
`docs/research/2026-07-31-271-stair-side-slide-capture.md`.
---
## #270 — Stuck spell animations + intermittently missing monster attack animations
**Status:** CLOSED 2026-07-31 — both symptoms user-verified fixed (stuck casts: exhaustion-edge gate `a46c8e65`; missing monster attack animations: spawn settle placement `21b3a3f3` + lost-cell retry `807fdb5f`). Final settle-session log: 14/15 spawn settles grounded; Falling-refusal spam 2,954 → 15 transient pre-settle lines. All #270 probes stripped.

View file

@ -389,6 +389,16 @@ root-caused, retail-ported, and user-accepted in the same session:
are now ported, focused/full gates pass, and the user accepted repeated
slope jumps. Evidence:
`docs/research/2026-07-31-269-slope-stop-capture.md`.
- **#271 closed 2026-07-31** — a bounded stair-side
trace proved ACDream could bypass retail's current-position edge back-probe
by promoting a stale `LastWalkable` tread. That made PrecipiceSlide reverse
an uphill tangent and rapidly carry the player down the stairs. The two
stale-history substitutions are removed; current-walkable, back-probe, and
no-walkable outcomes now follow `CTransition::edge_slide @ 0x0050B3D0`.
The exact captured frame is pinned in the existing installed-stair fixture
and the complete Release suite passes 10,062 tests / 5 skips. The user
accepted repeated uphill runs while pressing into the stair sides. Evidence:
`docs/research/2026-07-31-271-stair-side-slide-capture.md`.
Matrix rows accepted so far: speed parity, roof slide, downhill bounce,
flat pop, uphill landing, and #269's slope-stop feel

View file

@ -0,0 +1,108 @@
# #271 — Stair-side uphill reversal capture
**Date:** 2026-07-31
**Status:** closed; retail control flow restored and user live gate passed
## Symptom
When the local player ran diagonally uphill while pressing into the side of
an outdoor staircase, the character could suddenly move backward and rapidly
slide to the bottom. The symptom was intermittent because it required the
forward candidate to hit the side wall while the step-down recovery crossed a
tread edge.
This is not an RDP, render-rate, animation, or gravity symptom. It reproduced
inside the pure Core collision resolver from one captured input frame.
## Live evidence
The bounded capture is under the ignored local artifact pointer:
`artifacts/issue271-stair-side/LATEST.txt`
It contains 677 local-player physics quanta plus the matching resolver stream.
The first decisive frame is quantum 310:
```text
current = (133.03775, 75.53931, 59.608147)
target = (133.33783, 76.42308, 59.608147)
input = forward + run
result = (133.18779, 75.19872, 59.316677)
normal = (-1, approximately 0, approximately 0)
```
The X side-wall collision was valid, but the tangential Y component reversed:
an uphill request of `+0.88377` produced `-0.34059`. Three frames later,
quantum 313 snapped from Z `59.52598` to terrain Z `58.005`. A second attempt
reproduced the same family at quanta 479482, falling from Z `60.96376` to
`58.005`.
## Retail oracle
Named retail:
- `CTransition::edge_slide` at `0x0050B3D0`
- current-walkable branch at `0x0050B44A`
- no-walkable back-probe at `0x0050B4580x0050B50F`
- `SPHEREPATH::precipice_slide` at `0x0050CC80`
Retail tests only the current `SPHEREPATH::walkable` pointer. If it is null,
retail:
1. offsets the failed candidate back to the current sphere center;
2. runs `step_down` there to rediscover the surface actually under the mover;
3. restores the failed candidate;
4. runs `precipice_slide` against the newly discovered polygon; or
5. returns `COLLIDED_TS` when the back-probe found no walkable polygon.
Retail has no substitution of an older saved walkable polygon in either null
case.
## ACDream divergence and root cause
`EdgeSlideAfterStepDownFailed` previously called
`SpherePath.RestoreLastWalkable()`:
- before deciding whether to enter the retail back-probe; and
- again when the back-probe found no current walkable polygon.
`LastWalkable` is a separate ACDream history used by the still-open
CliffSlide compatibility path. At a staircase side wall it could describe the
preceding tread rather than the surface below the current player position.
Promoting it into the current slot bypassed retail's back-probe.
`PrecipiceSlide` then projected the failed forward candidate along the stale
tread edge, producing the backward/downhill displacement seen in the capture.
The fix removes both stale-history promotions from the edge-slide dispatch.
Current walkable state still takes retail's direct precipice path; absent
state now always takes retail's current-position back-probe.
## Deterministic regression
`Issue185OutdoorStairsSeamReplayTests` reuses the captured
`0x01000AC5` staircase collision fixture and the exact quantum-310 position,
contact plane, movement delta, player flags, and 1.5 m Setup step-down height.
Pre-fix:
```text
out = (133.187790, 75.346481, 59.430882)
```
Fixed:
```text
out = (133.187790, 76.078186, 60.016247)
```
The regression requires meaningful positive uphill progress and forbids a
downhill Z displacement. The complete Core Release suite passes 4,108 tests /
2 skips; the complete Release solution passes 10,062 tests / 5 skips.
## Live acceptance
The user repeatedly ran uphill while pressing into both sides of the affected
staircase. Movement remained stable and the former rapid downhill reversal did
not recur. The client then closed through the normal logout path, with ACE
confirming graceful logout.

View file

@ -87,3 +87,15 @@ InputDispatcher / PlayerMovementController
plane. Both rules are now ported; focused/full tests and the user's repeated
slope-jump gate pass. See
`docs/research/2026-07-31-269-slope-stop-capture.md`.
- 2026-07-31: #271 stair-side uphill reversal. Never promote ACDream's
retained `LastWalkable` history into retail's current
`SPHEREPATH::walkable` slot inside `edge_slide`. When current walkable is
null, retail `CTransition::edge_slide @ 0x0050B3D0` must back-probe at the
current sphere center and either use that newly discovered polygon or
collide. The stale substitution selected a preceding stair tread, reversed
the uphill tangent, and dropped the player rapidly down the stairs. The
exact live frame is pinned in `Issue185OutdoorStairsSeamReplayTests`; Core
passes 4,108 / 2 skips and the complete Release solution passes 10,062 /
5 skips. The user accepted repeated uphill stair-side runs on 2026-07-31.
See
`docs/research/2026-07-31-271-stair-side-slide-capture.md`.

View file

@ -2192,9 +2192,18 @@ public sealed class Transition
return CliffSlide(cliffPlane);
}
if (!sp.HasWalkablePolygon)
sp.RestoreLastWalkable();
// Retail tests only SPHEREPATH::walkable here. When the failed
// step-down has no current walkable polygon it MUST continue into the
// back-probe below, which rediscovers the polygon under CurPos before
// testing the failed candidate against its edge
// (CTransition::edge_slide 0x0050B3D0, 0x0050B44A-0x0050B50F).
//
// #271 (2026-07-31): acdream previously promoted its separately
// retained LastWalkable polygon into the current slot at this point.
// At a staircase side wall that polygon could be the preceding tread.
// PrecipiceSlide then projected the forward candidate along the stale
// tread edge, reversing its uphill tangent and dropping the player
// rapidly down the stairs. Do not restore stale history here.
if (sp.HasWalkablePolygon)
{
// L.4-walkable-steep (2026-04-30): the stored Walkable polygon
@ -2293,9 +2302,8 @@ public sealed class Transition
ci.ContactPlaneIsWater = false;
sp.RestoreCheckPos();
if (!sp.HasWalkablePolygon)
sp.RestoreLastWalkable();
// Retail returns Collided when the back-probe found no walkable.
// In particular, it does not substitute a retained earlier polygon.
if (sp.HasWalkablePolygon)
return sp.PrecipiceSlide(this);

View file

@ -177,6 +177,48 @@ public class Issue185OutdoorStairsSeamReplayTests
"A continuous walkable ramp seam must not persist a horizontal sliding normal (#137 family).");
}
/// <summary>
/// #271 live capture, quantum 310: a forward/uphill displacement that also
/// presses into the staircase's side wall must keep its uphill tangent.
/// Pre-fix the composite retry path reversed that tangent, moving from
/// Y=75.539 to Y=75.199 and rapidly carrying the player back down the stairs.
/// </summary>
[Fact]
public void OutdoorStairs_SideWallContact_DoesNotReverseUphillTangent()
{
var engine = BuildStairEngine();
var body = GroundedOnTread();
body.Position = new Vector3(133.03775f, 75.53931f, 59.608147f);
body.ContactPlane = new Plane(
new Vector3(3.2782555e-07f, -0.62469506f, 0.78086877f),
0.75193405f);
ResolveResult result = engine.ResolveWithTransition(
currentPos: body.Position,
targetPos: body.Position + new Vector3(0.30007935f, 0.8837738f, 0f),
cellId: StairCellId,
sphereRadius: 0.48f,
sphereHeight: 1.835f,
stepUpHeight: 0.6f,
stepDownHeight: 1.5f,
isOnGround: true,
body: body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: 0x01000000u);
_out.WriteLine(
$"out=({result.Position.X:F6},{result.Position.Y:F6},{result.Position.Z:F6}) " +
$"collision={result.CollisionNormalValid} " +
$"normal=({result.CollisionNormal.X:F3},{result.CollisionNormal.Y:F3},{result.CollisionNormal.Z:F3})");
Assert.True(result.Position.Y > body.Position.Y + 0.25f,
$"Side-wall response failed to preserve meaningful uphill motion: " +
$"{body.Position.Y:F6} -> {result.Position.Y:F6}.");
Assert.True(result.Position.Z >= body.Position.Z - 0.001f,
$"Side-wall response dropped the grounded player downhill: " +
$"{body.Position.Z:F6} -> {result.Position.Z:F6}.");
}
private static string SolutionRoot()
{
var dir = AppContext.BaseDirectory;