diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 6033efe..6d12894 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -192,10 +192,13 @@ hard-blocks or accepts too much in several of these cases.
`step_up_slide` now feels acceptable in live testing. Local/remote movement
passes the retail-default `EdgeSlide` flag. The first precipice-slide slice now
preserves terrain/BSP walkable polygon vertices and runs the retail back-probe
-before `SPHEREPATH::precipice_slide`; `ACDREAM_DUMP_EDGE_SLIDE=1` now reports
-whether a failed step-down had polygon context. Remaining gaps: real-DAT
-building-edge fixtures, fuller `cliff_slide` coverage, and `NegPolyHit`
-dispatch. Named retail anchors include `CTransition::edge_slide`,
+before `SPHEREPATH::precipice_slide`; edge-slide `Slid` / `Adjusted` results
+now feed the `TransitionalInsert` retry loop instead of being reverted by outer
+validation, and a synthetic diagonal terrain-boundary test covers tangent
+motion. `ACDREAM_DUMP_EDGE_SLIDE=1` now reports whether a failed step-down had
+polygon context. Remaining gaps: live visual confirmation of the retry-loop
+fix, real-DAT building-edge fixtures, fuller `cliff_slide` coverage, and
+`NegPolyHit` dispatch. Named retail anchors include `CTransition::edge_slide`,
`CTransition::cliff_slide`, `SPHEREPATH::precipice_slide`, and
`SPHEREPATH::step_up_slide`.
diff --git a/docs/plans/2026-04-29-movement-collision-conformance.md b/docs/plans/2026-04-29-movement-collision-conformance.md
index 90de943..8bfc2c1 100644
--- a/docs/plans/2026-04-29-movement-collision-conformance.md
+++ b/docs/plans/2026-04-29-movement-collision-conformance.md
@@ -122,8 +122,11 @@ Current shipped slice (2026-04-30): wall-adjacent `step_up_slide` feels
acceptable in live testing; player/remote movers pass `EdgeSlide`; terrain and
BSP step-down/find-walkable now preserve walkable polygon vertices; failed
step-down edge cases perform the retail back-probe before
-`SPHEREPATH::precipice_slide`. Remaining L.2c work is real-DAT building-edge
-fixtures, fuller `cliff_slide` coverage, and `NegPolyHit` dispatch.
+`SPHEREPATH::precipice_slide`; precipice slide results now re-enter the
+`TransitionalInsert` retry loop so tangent edge motion is preserved instead of
+being reverted by outer validation. Remaining L.2c work is live visual
+confirmation at real building/roof edges, real-DAT building-edge fixtures,
+fuller `cliff_slide` coverage, and `NegPolyHit` dispatch.
### L.2d - Shape Fidelity: Sphere / CylSphere / Building Objects
diff --git a/memory/project_movement_collision_conformance.md b/memory/project_movement_collision_conformance.md
index 5db6326..6117dc6 100644
--- a/memory/project_movement_collision_conformance.md
+++ b/memory/project_movement_collision_conformance.md
@@ -72,3 +72,9 @@ InputDispatcher / PlayerMovementController
step-down edge cases run the retail back-probe before precipice slide.
`cliff_slide` has a first port, but `NegPolyHit`, `CELLARRAY`, full
`cell_bsp`, and real-DAT building portal conformance remain open L.2 work.
+- 2026-04-30: Edge-slide retry-loop lesson. `SPHEREPATH::precipice_slide`
+ usually returns `Slid` after applying the tangent offset. That result must
+ be handled inside `TransitionalInsert` like wall slide (`continue` and
+ re-test the adjusted `CheckPos`), not returned to `ValidateTransition`; the
+ outer validator treats non-OK as a collision and restores `CurPos`, making
+ edges feel like hard stops even when the tangent was computed.
diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs
index 64f9f5c..53c0225 100644
--- a/src/AcDream.App/Input/PlayerMovementController.cs
+++ b/src/AcDream.App/Input/PlayerMovementController.cs
@@ -441,6 +441,19 @@ public sealed class PlayerMovementController
moverFlags: AcDream.Core.Physics.ObjectInfoState.IsPlayer
| AcDream.Core.Physics.ObjectInfoState.EdgeSlide);
+ // L.4-diag (2026-04-30): trace position transitions so we can see
+ // whether the body is actually moving frame-to-frame on the steep
+ // roof, or whether it's frozen at the impact point.
+ if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1"
+ && resolveResult.CollisionNormalValid)
+ {
+ Console.WriteLine(
+ $"[steep-roof] FRAME pre=({preIntegratePos.X:F2},{preIntegratePos.Y:F2},{preIntegratePos.Z:F2}) " +
+ $"post=({postIntegratePos.X:F2},{postIntegratePos.Y:F2},{postIntegratePos.Z:F2}) " +
+ $"resolved=({resolveResult.Position.X:F2},{resolveResult.Position.Y:F2},{resolveResult.Position.Z:F2}) " +
+ $"isOnGround={resolveResult.IsOnGround}");
+ }
+
// Apply resolved position.
_body.Position = resolveResult.Position;
@@ -507,6 +520,47 @@ public sealed class PlayerMovementController
? !(prevOnWalkable && nowOnWalkable)
: (!prevOnWalkable && !nowOnWalkable);
+ // L.4-steep-landing-bounce (2026-04-30): also bounce on
+ // landing IF the contact surface is upward-facing but
+ // steeper than walkable (FloorZ ≈ 49°). Per retail and the
+ // user's expectation: jumping onto a steep roof should NOT
+ // result in a landing — the player should bounce off, keep
+ // the falling animation, and slide off.
+ //
+ // Without this: the L.3a base rule suppresses the bounce on
+ // landing transitions (prev air → now ground) to avoid
+ // micro-bounce on flat terrain, but that suppression also
+ // sticks the player to too-steep roofs they shouldn't land
+ // on. This carve-out re-enables the bounce specifically for
+ // steep upward-facing surfaces.
+ //
+ // Range `0 < N.Z < FloorZ` means "facing upward but too
+ // steep" — excludes walls (N.Z ≈ 0) which are handled by the
+ // existing prevAirborne+nowAirborne rule, and ceilings
+ // (N.Z < 0) which the body shouldn't bounce off the same way.
+ if (!applyBounce
+ && resolveResult.CollisionNormalValid
+ && resolveResult.CollisionNormal.Z > 0f
+ && resolveResult.CollisionNormal.Z < PhysicsGlobals.FloorZ)
+ {
+ applyBounce = true;
+ }
+
+ // L.4-diag (2026-04-30): per-frame bounce trace for steep-roof bug.
+ bool diagSteep = Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1";
+ if (diagSteep && resolveResult.CollisionNormalValid)
+ {
+ var n0 = resolveResult.CollisionNormal;
+ var v0 = _body.Velocity;
+ Console.WriteLine(
+ $"[steep-roof] BOUNCE-CHECK applyBounce={applyBounce} " +
+ $"prevWalk={prevOnWalkable} nowWalk={nowOnWalkable} " +
+ $"N=({n0.X:F2},{n0.Y:F2},{n0.Z:F2}) FloorZ={PhysicsGlobals.FloorZ:F2} " +
+ $"V=({v0.X:F2},{v0.Y:F2},{v0.Z:F2}) " +
+ $"dot={Vector3.Dot(v0, n0):F3} " +
+ $"isOnGround={resolveResult.IsOnGround}");
+ }
+
if (applyBounce)
{
if (_body.State.HasFlag(PhysicsStateFlags.Inelastic))
@@ -526,6 +580,13 @@ public sealed class PlayerMovementController
// velocity reflects (subtle bounce).
float k = -(dotVN * (_body.Elasticity + 1f));
_body.Velocity = v + n * k;
+
+ if (diagSteep)
+ {
+ var v1 = _body.Velocity;
+ Console.WriteLine(
+ $"[steep-roof] BOUNCE-APPLIED V_after=({v1.X:F2},{v1.Y:F2},{v1.Z:F2}) k={k:F3}");
+ }
}
}
}
diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs
index b86ccc4..cb49f9f 100644
--- a/src/AcDream.Core/Physics/BSPQuery.cs
+++ b/src/AcDream.Core/Physics/BSPQuery.cs
@@ -1481,7 +1481,6 @@ public static class BSPQuery
var worldVertices = TransformVertices(hitPoly.Vertices, localToWorld, scale, worldOrigin);
var worldPlane = BuildWorldPlane(worldNormal, worldVertices);
collisions.SetContactPlane(worldPlane, path.CheckCellId, false);
-
path.SetWalkable(worldPlane, worldVertices, Vector3.UnitZ);
return TransitionState.Adjusted;
@@ -1572,16 +1571,68 @@ public static class BSPQuery
hitPoly0!, contact0, scale, localToWorld);
}
+ var worldNormal0 = L2W(hitPoly0!.Plane.Normal);
+
+ // L.4-reject-steep-landing (2026-04-30): if the polygon is
+ // too steep to walk on (worldNormal.Z < FloorZ ≈ 0.6642),
+ // do NOT enter the SetCollide → Path-4 → SetContactPlane
+ // landing path. That path commits the player to the
+ // surface (sets ContactPlane), which sticks them to the
+ // steep roof in a falling animation.
+ //
+ // Instead, treat the steep-poly hit as a wall slide:
+ // project the move along the steep face (remove the
+ // into-wall component), set CollisionNormal +
+ // SlidingNormal, return Slid. Same shape as Path 5's
+ // step-up fallback (line 1545-1547) and CylinderCollision
+ // (TransitionTypes.cs:1518-1522). The position is updated
+ // in-place; on the next resolver iteration the sphere is
+ // outside the poly, FindCollisions returns OK, and
+ // ValidateTransition commits the new position. Body stays
+ // airborne, falling animation continues, gravity pulls
+ // down the slope.
+ //
+ // CRITICAL: this MUST happen before path.SetCollide(...)
+ // is called. Once Collide=true is set, TransitionalInsert
+ // Phase 3 either commits via ContactPlane+Placement (the
+ // walkable case, OK on shallow slopes) or RestoreCheckPos
+ // (the reset case, when ContactPlaneValid is false). The
+ // reset path REVERTS our slide and freezes the body.
+ //
+ // Per user 2026-04-30:
+ // "I jump up, I land on it. It should not even let me
+ // land, should just slide with a falling animation."
+ if (worldNormal0.Z < PhysicsGlobals.FloorZ)
+ {
+ Vector3 currWorld = path.GlobalCurrCenter[0].Origin;
+ Vector3 endWorld = path.GlobalSphere[0].Origin;
+ Vector3 gDelta = endWorld - currWorld;
+ float diff = Vector3.Dot(worldNormal0, gDelta);
+ if (diff < 0f)
+ path.AddOffsetToCheckPos(-worldNormal0 * diff);
+
+ collisions.SetCollisionNormal(worldNormal0);
+ collisions.SetSlidingNormal(worldNormal0);
+
+ if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1")
+ {
+ Console.WriteLine(
+ $"[steep-roof] PATH6-STEEP-SLIDE N=({worldNormal0.X:F2},{worldNormal0.Y:F2},{worldNormal0.Z:F2}) " +
+ $"FloorZ={PhysicsGlobals.FloorZ:F2} " +
+ $"diff={diff:F3} " +
+ $"newCheckPos=({path.CheckPos.X:F2},{path.CheckPos.Y:F2},{path.CheckPos.Z:F2})");
+ }
+ return TransitionState.Slid;
+ }
+
// ─── SetCollide response ─────────────────────────────────
- // Airborne sphere hits a polygon. Per retail, call SetCollide
- // which saves backup position, records StepUpNormal = worldNormal,
- // and sets WalkInterp=1. TransitionalInsert's Collide branch will
- // then re-test as Placement to confirm we can land on the surface.
+ // Airborne sphere hits a shallow polygon (walkable surface).
+ // Call SetCollide so TransitionalInsert's Collide branch
+ // re-tests as Placement to confirm we can land on it.
//
// ACE: BSPTree.find_collisions default branch → SpherePath.SetCollide
// + return Adjusted.
// Named-retail: BSPTREE::find_collisions airborne branch → set_collide.
- var worldNormal0 = L2W(hitPoly0!.Plane.Normal);
path.SetCollide(worldNormal0);
path.WalkableAllowance = PhysicsGlobals.LandingZ;
return TransitionState.Adjusted;
@@ -1597,8 +1648,25 @@ public static class BSPQuery
if (hit1 || hitPoly1 is not null)
{
- // Head sphere hit: same SetCollide response.
var worldNormal1 = L2W(hitPoly1!.Plane.Normal);
+
+ // L.4-reject-steep-landing: same steep-poly slide
+ // for head-sphere hits.
+ if (worldNormal1.Z < PhysicsGlobals.FloorZ)
+ {
+ Vector3 currWorld = path.GlobalCurrCenter[0].Origin;
+ Vector3 endWorld = path.GlobalSphere[0].Origin;
+ Vector3 gDelta = endWorld - currWorld;
+ float diff = Vector3.Dot(worldNormal1, gDelta);
+ if (diff < 0f)
+ path.AddOffsetToCheckPos(-worldNormal1 * diff);
+
+ collisions.SetCollisionNormal(worldNormal1);
+ collisions.SetSlidingNormal(worldNormal1);
+ return TransitionState.Slid;
+ }
+
+ // Head sphere hit shallow surface: same SetCollide response.
path.SetCollide(worldNormal1);
path.WalkableAllowance = PhysicsGlobals.LandingZ;
return TransitionState.Adjusted;
diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index ce4bb07..7f28d4f 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -607,6 +607,9 @@ public sealed class Transition
// ── Phase 2: object (static BSP + cylinder) collision ───────
// Env was OK — now test objects.
var objState = FindObjCollisions(engine);
+ // L.4-diag: log Phase outcomes per attempt so we can see whether
+ // we're escaping to the step-down branch or churning in retries.
+ DumpPhase2(attempt, transitState, objState);
if (objState == TransitionState.Collided)
return TransitionState.Collided;
@@ -716,6 +719,8 @@ public sealed class Transition
// is invalid OR steeper than walkable.
bool contactInvalidOrSteep = !ci.ContactPlaneValid
|| ci.ContactPlane.Normal.Z < PhysicsGlobals.FloorZ;
+ // L.4-diag (2026-04-30): trace why we don't slide down roofs.
+ DumpStepDownBranchGate(contactInvalidOrSteep);
if (contactInvalidOrSteep && oi.Contact && !sp.StepDown
&& sp.CheckCellId != 0 && oi.StepDown)
{
@@ -832,6 +837,7 @@ public sealed class Transition
if (ci.ContactPlaneValid && ci.ContactPlane.Normal.Z < zVal && oi.EdgeSlide)
{
var cliffPlane = ci.ContactPlane;
+ DumpEdgeSlideBranch("priority/steep-cliffslide", zVal);
sp.ClearWalkable();
sp.RestoreCheckPos();
ci.ContactPlaneValid = false;
@@ -843,6 +849,7 @@ public sealed class Transition
// movement carries EdgeSlide, so the local avatar takes the slide path.
if (!oi.OnWalkable || !oi.EdgeSlide)
{
+ DumpEdgeSlideBranch("branch1/!onwalkable-or-!edgeslide", zVal);
sp.ClearWalkable();
sp.RestoreCheckPos();
ci.ContactPlaneValid = false;
@@ -853,6 +860,7 @@ public sealed class Transition
if (ci.ContactPlaneValid && ci.ContactPlane.Normal.Z < zVal)
{
var cliffPlane = ci.ContactPlane;
+ DumpEdgeSlideBranch("branch2/steep-cliffslide", zVal);
sp.ClearWalkable();
sp.RestoreCheckPos();
ci.ContactPlaneValid = false;
@@ -865,6 +873,32 @@ public sealed class Transition
if (sp.HasWalkablePolygon)
{
+ // L.4-walkable-steep (2026-04-30): the stored Walkable polygon
+ // can be a too-steep surface (e.g., a roof the player jumped
+ // onto — Path 4's airborne-landing branch uses LandingZ, the
+ // permissive 0.087 threshold, so steep roofs get accepted as
+ // "walkable" for the landing). On subsequent frames the player
+ // is STANDING ON that polygon, not crossing its edge, so
+ // PrecipiceSlide's find_crossed_edge returns false and the
+ // player gets stuck in a Collided revert loop.
+ //
+ // Detect the case: if the walkable polygon's plane is steeper
+ // than FloorZ, route to CliffSlide using that plane instead of
+ // PrecipiceSlide. CliffSlide deflects motion along the ridge
+ // between current-steep and last-known-walkable; gravity then
+ // produces visible downhill drift.
+ if (sp.WalkablePlane.Normal.Z < PhysicsGlobals.FloorZ)
+ {
+ var cliffPlane = sp.WalkablePlane;
+ DumpEdgeSlideBranch("walkable-poly-steep-cliffslide", zVal);
+ sp.ClearWalkable();
+ sp.RestoreCheckPos();
+ ci.ContactPlaneValid = false;
+ ci.ContactPlaneIsWater = false;
+ return CliffSlide(cliffPlane);
+ }
+
+ DumpEdgeSlideBranch("branch3/precipice-slide", zVal);
ci.ContactPlaneValid = false;
ci.ContactPlaneIsWater = false;
return sp.PrecipiceSlide(this);
@@ -872,6 +906,7 @@ public sealed class Transition
if (ci.ContactPlaneValid)
{
+ DumpEdgeSlideBranch("branch4/contact-no-walkable", zVal);
sp.ClearWalkable();
sp.RestoreCheckPos();
ci.ContactPlaneValid = false;
@@ -906,20 +941,53 @@ public sealed class Transition
var sp = SpherePath;
var ci = CollisionInfo;
- if (!ci.LastKnownContactPlaneValid)
- return TransitionState.OK;
+ // L.4-cliffslide-fallback (2026-04-30): use the LAST WALKABLE plane
+ // as the cross-product reference, falling back to world-up when no
+ // walkable history is available. Without this, when the player has
+ // been on a steep slope for >1 frame, ValidateTransition's L.2.3i
+ // FloorZ test propagates the steep plane into LastKnownContactPlane,
+ // so cross(currentSteep, lastKnownSteep) = 0 → degenerate, no
+ // deflection. Using LastWalkable preserves the prior flat-ground
+ // plane across continuous-slope frames; world-up gives a guaranteed
+ // non-zero deflection when no walkable history exists at all.
+ Vector3 referenceNormal;
+ string refSource;
+ if (sp.HasLastWalkablePolygon && sp.LastWalkablePlane.Normal.Z >= PhysicsGlobals.FloorZ)
+ {
+ referenceNormal = sp.LastWalkablePlane.Normal;
+ refSource = "last-walkable";
+ }
+ else if (ci.LastKnownContactPlaneValid && ci.LastKnownContactPlane.Normal.Z >= PhysicsGlobals.FloorZ)
+ {
+ referenceNormal = ci.LastKnownContactPlane.Normal;
+ refSource = "last-known-walkable";
+ }
+ else
+ {
+ // Fallback: world up. cross(steepNormal, UnitZ) gives the
+ // ridge direction (horizontal contour line of the slope).
+ // collideNormal then becomes the downhill horizontal axis.
+ referenceNormal = Vector3.UnitZ;
+ refSource = "world-up-fallback";
+ }
- Vector3 contactNormal = Vector3.Cross(contactPlane.Normal, ci.LastKnownContactPlane.Normal);
+ Vector3 contactNormal = Vector3.Cross(contactPlane.Normal, referenceNormal);
contactNormal.Z = 0f;
Vector3 collideNormal = new(-contactNormal.Y, contactNormal.X, 0f);
if (collideNormal.LengthSquared() < PhysicsGlobals.EpsilonSq)
+ {
+ DumpCliffSlide($"degenerate-cross/{refSource}", contactPlane,
+ new Plane(referenceNormal, 0f), contactNormal, 0f, false);
return TransitionState.OK;
+ }
collideNormal = Vector3.Normalize(collideNormal);
Vector3 offset = sp.GlobalSphere[0].Origin - sp.GlobalCurrCenter[0].Origin;
float angle = Vector3.Dot(collideNormal, offset);
+ DumpCliffSlide($"ok/{refSource}", contactPlane,
+ new Plane(referenceNormal, 0f), collideNormal, angle, true);
if (angle <= 0f)
{
@@ -948,6 +1016,73 @@ public sealed class Transition
$"edge-slide: stepdown-failed cur={Fmt(sp.CurPos)} check={Fmt(sp.CheckPos)} cell=0x{sp.CheckCellId:X8} edgeFlag={oi.EdgeSlide} contactFlag={oi.Contact} onWalkable={oi.OnWalkable} contactPlane={ci.ContactPlaneValid} lastPlane={ci.LastKnownContactPlaneValid} walkableValid={sp.WalkableValid} walkablePoly={sp.HasWalkablePolygon} lastWalkablePoly={sp.HasLastWalkablePolygon} stepDown={stepDownHeight:F3} zVal={zVal:F3}"));
}
+ ///
+ /// L.4-diag: log step-down branch gate decision. Whether we entered or
+ /// skipped the contact-recovery branch matters for whether CliffSlide
+ /// has any chance of firing.
+ ///
+ private void DumpStepDownBranchGate(bool contactInvalidOrSteep)
+ {
+ if (!DumpEdgeSlideEnabled) return;
+
+ var sp = SpherePath;
+ var ci = CollisionInfo;
+ var oi = ObjectInfo;
+
+ bool wouldEnter = contactInvalidOrSteep && oi.Contact && !sp.StepDown
+ && sp.CheckCellId != 0 && oi.StepDown;
+
+ if (!wouldEnter) return; // only log when entering, to keep noise low
+
+ Console.WriteLine(
+ System.FormattableString.Invariant(
+ $"edge-slide: stepdown-branch-enter cur={Fmt(sp.CurPos)} contactValid={ci.ContactPlaneValid} contactN.Z={(ci.ContactPlaneValid ? ci.ContactPlane.Normal.Z : 0f):F3} onWalk={oi.OnWalkable} contact={oi.Contact}"));
+ }
+
+ ///
+ /// L.4-diag: log Phase 2 outcome per inner attempt. Tells us whether
+ /// we're churning in Slid retries or escaping to step-down branch.
+ ///
+ private void DumpPhase2(int attempt, TransitionState envState, TransitionState objState)
+ {
+ if (!DumpEdgeSlideEnabled) return;
+ if (objState == TransitionState.OK) return; // skip clean attempts
+
+ Console.WriteLine(
+ System.FormattableString.Invariant(
+ $"edge-slide: phase2 attempt={attempt} env={envState} obj={objState}"));
+ }
+
+ ///
+ /// L.4-diag: log which branch of EdgeSlideAfterStepDownFailed fired.
+ /// Tells us whether CliffSlide gets called or whether we hit a
+ /// stop-at-edge branch.
+ ///
+ private void DumpEdgeSlideBranch(string branch, float zVal)
+ {
+ if (!DumpEdgeSlideEnabled) return;
+ var sp = SpherePath;
+ var ci = CollisionInfo;
+ var oi = ObjectInfo;
+ Console.WriteLine(
+ System.FormattableString.Invariant(
+ $"edge-slide: branch={branch} contactValid={ci.ContactPlaneValid} contactN.Z={(ci.ContactPlaneValid ? ci.ContactPlane.Normal.Z : 0f):F3} lastValid={ci.LastKnownContactPlaneValid} lastN.Z={(ci.LastKnownContactPlaneValid ? ci.LastKnownContactPlane.Normal.Z : 0f):F3} walkPolyValid={sp.HasWalkablePolygon} walkPolyN.Z={(sp.HasWalkablePolygon ? sp.WalkablePlane.Normal.Z : 0f):F3} lastWalkPolyN.Z={(sp.HasLastWalkablePolygon ? sp.LastWalkablePlane.Normal.Z : 0f):F3} onWalk={oi.OnWalkable} edgeFlag={oi.EdgeSlide} zVal={zVal:F3}"));
+ }
+
+ ///
+ /// L.4-diag: log CliffSlide invocation. Tells us whether the
+ /// cross-product is degenerate (no slide) or producing a real
+ /// deflection.
+ ///
+ private void DumpCliffSlide(string outcome, Plane current, Plane lastKnown,
+ Vector3 collideNormal, float angle, bool willApply)
+ {
+ if (!DumpEdgeSlideEnabled) return;
+ Console.WriteLine(
+ System.FormattableString.Invariant(
+ $"edge-slide: cliffslide outcome={outcome} curN={Fmt(current.Normal)} lastN={Fmt(lastKnown.Normal)} collideN={Fmt(collideNormal)} angle={angle:F4} apply={willApply}"));
+ }
+
private static string Fmt(Vector3 value) =>
System.FormattableString.Invariant($"({value.X:F3},{value.Y:F3},{value.Z:F3})");
diff --git a/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs b/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs
index 258fd58..47bdfb8 100644
--- a/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs
@@ -506,6 +506,85 @@ public class BSPStepUpTests
"indicates Path 5 recursing through DoStepUp without guard.");
}
+ ///
+ /// L.2c regression: an airborne mover jumping/falling into a vertical wall
+ /// must keep its vertical displacement. With no live or last-known contact
+ /// plane, SlideSphere must remove only the component into the wall; inventing
+ /// a flat UnitZ plane projects the displacement onto the wall/floor crease
+ /// and leaves the character stuck in falling animation against the wall.
+ ///
+ [Fact]
+ public void D3_AirborneMover_TallWall_PreservesVerticalMotion()
+ {
+ var (root, resolved) = BSPStepUpFixtures.TallWall();
+
+ var from = new Vector3(0.1f, 0f, 2.0f);
+ var to = new Vector3(0.6f, 0f, 1.5f);
+
+ var t = BSPStepUpFixtures.MakeAirborneTransition(from, to);
+ var engine = MakeTestEngine(root, resolved, terrainZ: -50f);
+
+ t.FindTransitionalPosition(engine);
+
+ Assert.True(t.SpherePath.CurPos.Z < from.Z - 0.1f,
+ $"Expected airborne wall-slide to preserve downward motion; " +
+ $"from.Z={from.Z:F3}, CurPos.Z={t.SpherePath.CurPos.Z:F3}");
+ Assert.True(t.SpherePath.CurPos.X <= 0.5f - BSPStepUpFixtures.SphereRadius + PhysicsGlobals.EPSILON * 20f,
+ $"Expected wall to block X penetration; got CurPos.X={t.SpherePath.CurPos.X:F3}");
+ }
+
+ ///
+ /// L.2c regression: if an airborne wall collision happens in a one-substep
+ /// frame, the collision normal has to survive into the next frame. Retail
+ /// does this with transient_state bit 2 + InitSlidingNormal. Without that,
+ /// every frame replays the same hard stop and the character hangs in falling
+ /// animation until another correction breaks the loop.
+ ///
+ [Fact]
+ public void D4_AirborneMover_TallWall_PersistsSlidingNormalAcrossFrames()
+ {
+ var (root, resolved) = BSPStepUpFixtures.TallWall();
+ var engine = MakeTestEngine(root, resolved, terrainZ: -50f);
+ var body = new PhysicsBody
+ {
+ Position = new Vector3(0.25f, 0f, 2.0f),
+ TransientState = TransientStateFlags.Active,
+ };
+
+ var frame1 = engine.ResolveWithTransition(
+ currentPos: body.Position,
+ targetPos: new Vector3(0.36f, 0f, 1.92f),
+ cellId: 0xA9B40001u,
+ sphereRadius: BSPStepUpFixtures.SphereRadius,
+ sphereHeight: 0f,
+ stepUpHeight: 0.04f,
+ stepDownHeight: 0.04f,
+ isOnGround: false,
+ body: body);
+
+ body.Position = frame1.Position;
+
+ Assert.True(body.TransientState.HasFlag(TransientStateFlags.Sliding),
+ "First airborne wall hit should cache SlidingNormal for the next frame.");
+ Assert.Equal(2.0f, frame1.Position.Z, precision: 3);
+
+ var frame2 = engine.ResolveWithTransition(
+ currentPos: body.Position,
+ targetPos: body.Position + new Vector3(0.11f, 0f, -0.08f),
+ cellId: 0xA9B40001u,
+ sphereRadius: BSPStepUpFixtures.SphereRadius,
+ sphereHeight: 0f,
+ stepUpHeight: 0.04f,
+ stepDownHeight: 0.04f,
+ isOnGround: false,
+ body: body);
+
+ Assert.True(frame2.Position.Z < frame1.Position.Z - 0.05f,
+ $"Expected cached wall-slide normal to allow falling on frame 2; " +
+ $"frame1.Z={frame1.Position.Z:F3}, frame2.Z={frame2.Position.Z:F3}");
+ Assert.InRange(frame2.Position.X, 0.24f, 0.31f);
+ }
+
// =========================================================================
// Helpers
// =========================================================================
diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
index 79d6ac8..dc53d93 100644
--- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
@@ -261,6 +261,52 @@ public class PhysicsEngineTests
Assert.Equal(50f, result.Position.Z, precision: 2);
}
+ [Fact]
+ public void ResolveWithTransition_EdgeSlideAtLoadedTerrainBoundary_PreservesTangentMotion()
+ {
+ var engine = MakeFlatEngine(terrainZ: 50f);
+ var body = new PhysicsBody
+ {
+ Position = new Vector3(191f, 96f, 50f),
+ TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
+ ContactPlaneValid = true,
+ ContactPlane = new Plane(Vector3.UnitZ, -50f),
+ ContactPlaneCellId = 0x003Du,
+ };
+
+ var settled = engine.ResolveWithTransition(
+ currentPos: new Vector3(191f, 96f, 50f),
+ targetPos: new Vector3(191.25f, 96f, 50f),
+ cellId: 0x003Du,
+ sphereRadius: 0.5f,
+ sphereHeight: 1.2f,
+ stepUpHeight: 0.4f,
+ stepDownHeight: 0.4f,
+ isOnGround: true,
+ body: body,
+ moverFlags: ObjectInfoState.EdgeSlide);
+
+ Assert.True(body.WalkablePolygonValid);
+ Assert.NotNull(body.WalkableVertices);
+
+ var result = engine.ResolveWithTransition(
+ currentPos: settled.Position,
+ targetPos: new Vector3(193f, 98f, 50f),
+ cellId: 0x003Du,
+ sphereRadius: 0.5f,
+ sphereHeight: 1.2f,
+ stepUpHeight: 0.4f,
+ stepDownHeight: 0.4f,
+ isOnGround: true,
+ body: body,
+ moverFlags: ObjectInfoState.EdgeSlide);
+
+ Assert.True(result.IsOnGround);
+ Assert.InRange(result.Position.X, 190.75f, 192.0001f);
+ Assert.True(result.Position.Y > 96.2f);
+ Assert.Equal(50f, result.Position.Z, precision: 2);
+ }
+
[Fact]
public void ResolveWithTransition_LandblockBoundary_UpdatesFullOutdoorCellId()
{