diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index d48a0a0..afc7607 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -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; + } + /// /// 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 {