diff --git a/docs/ISSUES.md b/docs/ISSUES.md index c64ad444..0211aa91 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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. diff --git a/docs/plans/2026-07-29-physics-parity-campaign.md b/docs/plans/2026-07-29-physics-parity-campaign.md index b7bc2d59..a227cd19 100644 --- a/docs/plans/2026-07-29-physics-parity-campaign.md +++ b/docs/plans/2026-07-29-physics-parity-campaign.md @@ -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 diff --git a/docs/research/2026-07-31-271-stair-side-slide-capture.md b/docs/research/2026-07-31-271-stair-side-slide-capture.md new file mode 100644 index 00000000..1e09b459 --- /dev/null +++ b/docs/research/2026-07-31-271-stair-side-slide-capture.md @@ -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 479–482, 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 `0x0050B458–0x0050B50F` +- `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. diff --git a/memory/project_movement_collision_conformance.md b/memory/project_movement_collision_conformance.md index 98eea409..99bdbcf8 100644 --- a/memory/project_movement_collision_conformance.md +++ b/memory/project_movement_collision_conformance.md @@ -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`. diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index d808ac5c..b3f9cf81 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -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); diff --git a/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs b/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs index ec210cbf..9ee07592 100644 --- a/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs @@ -177,6 +177,48 @@ public class Issue185OutdoorStairsSeamReplayTests "A continuous walkable ramp seam must not persist a horizontal sliding normal (#137 family)."); } + /// + /// #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. + /// + [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;