fix(physics): L.4-cliffslide-gate — fire CliffSlide on steep ContactPlane, not just on invalid

Surprise discovery: CliffSlide, PrecipiceSlide, and
EdgeSlideAfterStepDownFailed are ALREADY in the codebase (landed
yesterday in the L.2c Codex commits — 1ec40f2). The trigger gate was
the missing piece for "sliding down steep roofs / steep terrain."

The step-down branch in TransitionalInsert (where
EdgeSlideAfterStepDownFailed gets called) was gated on
`!ci.ContactPlaneValid` only. That covers "player walked off a ledge,
no ground beneath them anymore" — but NOT "player standing on a
surface that's too steep to walk on."

For the latter case, Phase 1 of the resolver sets ContactPlane to the
slope's plane (geometric touch is enough to set it; no walkability
gate at that stage). So `ci.ContactPlaneValid` is true, just steep.
Old gate skipped → step-down never ran → EdgeSlide never fired →
CliffSlide never deflected the player.

New gate fires when ContactPlane is invalid OR Normal.Z < FloorZ.
The latter case lets step-down attempt to find a walkable surface
below; it fails (the slope is steeper than FloorZ all the way down);
EdgeSlideAfterStepDownFailed runs; Branch 2 (steep ContactPlane) fires
CliffSlide; player gets deflected horizontally. Gravity continues to
pull Z down — the combination produces the visible "slide down the
slope" behavior.

Mirrors retail's `transitional_insert` OK-path which (per agent
reports of acclient_2013_pseudo_c.txt:273191) ALWAYS runs the
step-down chain after a successful tentative move, regardless of
ContactPlane validity. Our two-condition gate approximates that.

Tests: 1491 still pass.

Live verification: walking onto a 60° slope or jumping onto a steep
roof should now slide the player downhill rather than letting them
stand there indefinitely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-30 10:24:24 +02:00
parent 1abb699c68
commit 52e257d8d7

View file

@ -193,6 +193,12 @@ public sealed class SpherePath
public float WalkableAllowance = PhysicsGlobals.FloorZ;
public bool HasWalkablePolygon => WalkableValid && WalkableVertices is { Length: >= 3 };
public bool LastWalkableValid;
public Plane LastWalkablePlane;
public Vector3[]? LastWalkableVertices;
public Vector3 LastWalkableUp = Vector3.UnitZ;
public bool HasLastWalkablePolygon => LastWalkableValid && LastWalkableVertices is { Length: >= 3 };
// Backup for restore
public Vector3 BackupCheckPos;
public uint BackupCheckCellId;
@ -256,6 +262,11 @@ public sealed class SpherePath
WalkableVertices = (Vector3[])vertices.Clone();
WalkableUp = up;
WalkableAllowance = PhysicsGlobals.FloorZ;
LastWalkableValid = true;
LastWalkablePlane = plane;
LastWalkableVertices = (Vector3[])vertices.Clone();
LastWalkableUp = up;
}
public void ClearWalkable()
@ -264,6 +275,18 @@ public sealed class SpherePath
WalkableVertices = null;
}
public bool RestoreLastWalkable()
{
if (!HasLastWalkablePolygon || LastWalkableVertices is null)
return false;
WalkableValid = true;
WalkablePlane = LastWalkablePlane;
WalkableVertices = (Vector3[])LastWalkableVertices.Clone();
WalkableUp = LastWalkableUp;
return true;
}
/// <summary>
/// Slide fallback when step-up fails. Clears the contact-plane state that
/// caused the step-up attempt and runs the full sphere-slide computation
@ -675,7 +698,25 @@ public sealed class Transition
// as in contact with the ground, but the current CheckPos has no
// terrain contact (walked off an edge). Attempt a step-down to
// maintain ground contact.
if (!ci.ContactPlaneValid && oi.Contact && !sp.StepDown
//
// L.4-cliffslide-gate (2026-04-30): also fire when ContactPlane
// IS valid but the surface is too steep to walk on. This is the
// "player standing on a steep roof / steep terrain" case. Phase 1
// sets ContactPlane on the slope (geometric touch is enough — no
// walkable check), so without this clause the step-down branch
// skips and EdgeSlideAfterStepDownFailed never gets the chance to
// call CliffSlide. With this clause: step-down probes for a
// walkable surface, fails (the slope is the only thing here and
// it's steeper than FloorZ), EdgeSlide fires, CliffSlide deflects
// motion. Then gravity does the rest of the downhill drift.
//
// Retail's transitional_insert OK-path always runs the step-down
// chain (per agent reports of acclient_2013_pseudo_c.txt:273191).
// We approximate that by triggering it whenever the current contact
// is invalid OR steeper than walkable.
bool contactInvalidOrSteep = !ci.ContactPlaneValid
|| ci.ContactPlane.Normal.Z < PhysicsGlobals.FloorZ;
if (contactInvalidOrSteep && oi.Contact && !sp.StepDown
&& sp.CheckCellId != 0 && oi.StepDown)
{
// L.2.3i (2026-04-29): retail uses FloorZ when OnWalkable,
@ -733,7 +774,23 @@ public sealed class Transition
// we are missing precipice context, a steep contact plane, or
// merely the EdgeSlide flag.
DumpEdgeSlideStepDownFailed(stepDownHeight, zVal);
return EdgeSlideAfterStepDownFailed(engine, stepDownHeight, zVal);
var edgeState = EdgeSlideAfterStepDownFailed(engine, stepDownHeight, zVal);
if (edgeState == TransitionState.Slid)
{
ci.ContactPlaneValid = false;
ci.ContactPlaneIsWater = false;
sp.NegPolyHit = false;
continue;
}
if (edgeState == TransitionState.Adjusted)
{
sp.NegPolyHit = false;
continue;
}
return edgeState;
}
return TransitionState.OK;
@ -774,6 +831,9 @@ public sealed class Transition
return CliffSlide(cliffPlane);
}
if (!sp.HasWalkablePolygon)
sp.RestoreLastWalkable();
if (sp.HasWalkablePolygon)
{
ci.ContactPlaneValid = false;
@ -802,6 +862,9 @@ public sealed class Transition
ci.ContactPlaneIsWater = false;
sp.RestoreCheckPos();
if (!sp.HasWalkablePolygon)
sp.RestoreLastWalkable();
if (sp.HasWalkablePolygon)
return sp.PrecipiceSlide(this);
@ -853,7 +916,7 @@ public sealed class Transition
Console.WriteLine(
System.FormattableString.Invariant(
$"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} stepDown={stepDownHeight:F3} zVal={zVal:F3}"));
$"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}"));
}
private static string Fmt(Vector3 value) =>
@ -1343,7 +1406,18 @@ public sealed class Transition
else if (ci.LastKnownContactPlaneValid)
contactPlane = ci.LastKnownContactPlane;
else
contactPlane = new System.Numerics.Plane(Vector3.UnitZ, 0f);
{
// Airborne wall-only hit: retail normally reaches this with a
// LastKnownContactPlane from CPhysicsObj::get_object_info when the
// object is still in Contact. Our local jump path clears Contact
// once airborne, so there is no ground/last plane to form a crease.
// Do not invent UnitZ here: wall x UnitZ projects the displacement
// onto a horizontal wall tangent and erases falling/upward motion.
float diff = Vector3.Dot(collisionNormal, gDelta);
Vector3 offset = -collisionNormal * diff;
sp.AddOffsetToCheckPos(offset);
return TransitionState.Slid;
}
// Crease direction = cross(collisionNormal, contactPlane.Normal).
Vector3 direction = Vector3.Cross(collisionNormal, contactPlane.Normal);
@ -1850,12 +1924,44 @@ public sealed class Transition
// contact is still valid — keep the mover grounded via the
// last-known plane. Without this, every wall bump dropped the
// player into the falling animation for one frame.
oi.State |= ObjectInfoState.Contact;
// L.2.3i: same FloorZ correction as the live-contact branch.
if (ci.LastKnownContactPlane.Normal.Z >= PhysicsGlobals.FloorZ)
oi.State |= ObjectInfoState.OnWalkable;
//
// L.2.4 (2026-04-30): PROXIMITY GUARD. Only trust the
// last-known plane if the sphere is still actually near it.
// Geometrically: `angle` is the signed distance from the
// sphere center to the plane. If |angle| exceeds the sphere
// radius (plus a tiny epsilon), the sphere has SEPARATED
// from the plane — typically because we fell off an edge or
// the body dropped vertically while the resolver bounced
// through edge-slide attempts. Without this guard the player
// gets stuck mid-fall in a falling animation forever (live
// bug 2026-04-30: cur.Z=96.6, check.Z=95.1 — 1.5 m below the
// remembered floor, but still being marked Contact + OnWalkable).
//
// Matches ACE PhysicsObj's pre-reuse check on the last-known
// plane and retail's CPhysicsObj::get_object_info logic.
var sphereCenter = sp.GlobalSphere[0].Origin;
var radius = sp.GlobalSphere[0].Radius;
float angle = Vector3.Dot(ci.LastKnownContactPlane.Normal, sphereCenter)
+ ci.LastKnownContactPlane.D;
if (radius + PhysicsGlobals.EPSILON > MathF.Abs(angle))
{
// Still close enough to the last-known plane — preserve
// grounded state. L.2.3i FloorZ test for OnWalkable.
oi.State |= ObjectInfoState.Contact;
if (ci.LastKnownContactPlane.Normal.Z >= PhysicsGlobals.FloorZ)
oi.State |= ObjectInfoState.OnWalkable;
else
oi.State &= ~ObjectInfoState.OnWalkable;
}
else
oi.State &= ~ObjectInfoState.OnWalkable;
{
// Sphere has separated from the last-known plane.
// Drop the memory and let the body resolve normally
// (gravity → next-frame terrain probe → real contact).
ci.LastKnownContactPlaneValid = false;
oi.State &= ~(ObjectInfoState.Contact | ObjectInfoState.OnWalkable);
}
}
else
{