fix(physics): restore retail edge-response ordering

This commit is contained in:
Erik 2026-07-31 12:55:49 +02:00
parent 4ca7230b36
commit c559c48d80
4 changed files with 525 additions and 180 deletions

View file

@ -1733,6 +1733,40 @@ public sealed class Transition
PhysicsEngine engine)
=> TransitionalInsert(numAttempts, engine);
/// <summary>
/// Retail's one-versus-two-sphere step-down schedule from
/// <c>CTransition::transitional_insert</c> (0050b889-0050b8f0).
/// A one-sphere mover whose requested drop exceeds its diameter is
/// clamped to half its radius and probes once. A two-sphere mover keeps
/// the requested height and splits an over-diameter drop into two equal
/// probes.
/// </summary>
internal static (float ProbeHeight, int ProbeCount) GetStepDownProbePlan(
int numSpheres,
float sphereRadius,
float requestedHeight)
{
float diameter = sphereRadius * 2f;
float probeHeight = requestedHeight;
if (numSpheres < 2 && diameter < probeHeight)
probeHeight = sphereRadius * 0.5f;
if (diameter >= probeHeight)
return (probeHeight, 1);
return (probeHeight * 0.5f, 2);
}
internal TransitionState EdgeSlideAfterStepDownFailedForTest(
PhysicsEngine engine,
float stepDownHeight,
float zVal)
=> EdgeSlideAfterStepDownFailed(engine, stepDownHeight, zVal);
internal TransitionState CliffSlideForTest(Plane contactPlane)
=> CliffSlide(contactPlane);
private TransitionState TransitionalInsert(int numAttempts, PhysicsEngine engine)
{
if (SpherePath.CheckCellId == 0) return TransitionState.OK;
@ -1987,33 +2021,14 @@ public sealed class Transition
}
}
// Handle step-down when in contact but no ground plane found.
// This happens when the player is on a slope edge: they're marked
// 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.
//
// 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;
// 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)
// Retail returns immediately for every valid contact plane,
// including a steep one. The ordinary step-down tail is reached
// only when contact is invalid (0050b818-0050b844).
if (ci.ContactPlaneValid)
return TransitionState.OK;
DumpStepDownBranchGate(contactInvalid: true);
if (oi.Contact && !sp.StepDown && sp.CheckCellId != 0 && oi.StepDown)
{
// L.2.3i (2026-04-29): retail uses FloorZ when OnWalkable,
// LandingZ when not. acdream was unconditionally LandingZ —
@ -2028,7 +2043,11 @@ public sealed class Transition
sp.WalkableAllowance = zVal;
sp.SaveCheckPos();
float radsum = sp.GlobalSphere[0].Radius * 2f;
(float probeHeight, int probeCount) = GetStepDownProbePlan(
sp.NumSphere,
sp.GlobalSphere[0].Radius,
stepDownHeight);
stepDownHeight = probeHeight;
// L.2.3h (2026-04-29): pass runPlacement=false. This
// branch's job is to maintain ground contact during normal
@ -2038,23 +2057,20 @@ public sealed class Transition
// would fail Placement and trigger the L.2.3e edge-block,
// leaving the player stuck near walls. DoStepUp still runs
// Placement for the step-UP-through-walls protection.
if (radsum >= stepDownHeight)
bool steppedDown = false;
for (int probe = 0; probe < probeCount; probe++)
{
if (DoStepDown(stepDownHeight, zVal, engine, runPlacement: false))
{
sp.ClearWalkable();
return TransitionState.OK;
steppedDown = true;
break;
}
}
else
if (steppedDown)
{
stepDownHeight *= 0.5f;
if (DoStepDown(stepDownHeight, zVal, engine, runPlacement: false)
|| DoStepDown(stepDownHeight, zVal, engine, runPlacement: false))
{
sp.ClearWalkable();
return TransitionState.OK;
}
sp.ClearWalkable();
return TransitionState.OK;
}
// L.2c (2026-04-30): step-down failed — the move would put
@ -2205,38 +2221,9 @@ public sealed class Transition
var ci = CollisionInfo;
var oi = ObjectInfo;
// L.4-cliffslide-priority (2026-04-30): the steep-ContactPlane check
// moved BEFORE the OnWalkable/EdgeSlide gate.
//
// Why: by the time this dispatch runs on subsequent frames (player
// standing on a steep slope), ValidateTransition's L.2.3i FloorZ
// test has already CLEARED OnWalkable (steep slope → not a walkable
// surface). The original Branch 1 (`!OnWalkable → restore + OK`)
// therefore fires every frame, stopping the player dead — exactly
// the "stay on the roof" symptom the user reported.
//
// Re-ordering: if the surface is too steep AND we have a contact
// plane on it, run CliffSlide regardless of OnWalkable. The
// cross(currentNormal, lastKnownNormal) deflection plus gravity
// produces visible downhill drift each frame.
//
// Branch 1 (the !OnWalkable stop) still fires when we DON'T have
// a contact plane — the original "walked off into thin air"
// case, which should still stop or fall normally rather than
// CliffSlide on nothing.
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;
ci.ContactPlaneIsWater = false;
return CliffSlide(cliffPlane);
}
// Retail lets non-EdgeSlide movers continue over the boundary. Player
// movement carries EdgeSlide, so the local avatar takes the slide path.
// Retail Branch 1 is first: a mover that is not OnWalkable or does
// not carry EdgeSlide restores the saved candidate and returns OK.
// No steep-plane exception precedes this gate (0050b3d8-0050b3e7).
if (!oi.OnWalkable || !oi.EdgeSlide)
{
DumpEdgeSlideBranch("branch1/!onwalkable-or-!edgeslide", zVal);
@ -2272,49 +2259,8 @@ public sealed class Transition
// rapidly down the stairs. Do not restore stale history here.
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.
//
// TS-1 gap #3 (register AD-54, Campaign P Slice P2 2026-07-30):
// retail's raw SPHEREPATH::edge_slide has NO steepness branch here
// — `if (walkable != null) { ... precipice_slide(...) }` unconditionally
// (acclient_2013_pseudo_c.txt:364-370 per the P2 research quote). The
// LandingZ permissive acceptance itself IS retail-faithful — confirmed
// by TransitionalInsert's own Path-4 Collide branch
// (TransitionTypes.cs, `DoCheckWalkable(PhysicsGlobals.LandingZ, engine)`
// above) and TS-4's BSPTREE::find_collisions read
// (pc:323740-323783: `sphere_path.walkable_allowance = LandingZ`
// unconditionally, no slope test) — so a steep roof really is
// "walkable" in retail too. What is NOT independently verified from
// the raw decomp is whether retail's OUTER caller (transitional_insert)
// absorbs a same-polygon-standing Collided from precipice_slide via
// its own retry loop rather than needing this reroute; see
// docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §2
// gap #3.
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);
sp.RestoreCheckPos();
ci.ContactPlaneValid = false;
ci.ContactPlaneIsWater = false;
return sp.PrecipiceSlide(this);
@ -2382,54 +2328,12 @@ public sealed class Transition
var sp = SpherePath;
var ci = CollisionInfo;
// 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.
//
// TS-1 gap #2 (register AD-53, Campaign P Slice P2 2026-07-30): retail's
// raw CTransition::cliff_slide (pc:272397, 0050a6d0) uses
// this->collision_info.last_known_contact_plane.N DIRECTLY as the second
// cross-product operand — no fallback chain. Confirmed by a fresh read of
// last_known_contact_plane's own maintenance
// (acclient_2013_pseudo_c.txt:272659-272668, pc ~0050ad07): retail
// overwrites last_known_contact_plane from contact_plane UNCONDITIONALLY
// on every validate_transition pass, the same "gets overwritten by
// whatever's current, including a steep plane" behavior this file's
// ContactPlane/LastKnownContactPlane tracking already has — retail does
// NOT maintain a separately-preserved flat-ground history there either.
// This three-source chain (LastWalkablePlane -> LastKnownContactPlane ->
// UnitZ) is therefore a genuine acdream invention, not a retail-matching
// read — kept because it compensates for AP-4's incomplete OnWalkable
// bookkeeping (see DO-NOT-RETRY item 9 in
// docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §0)
// and removing it reintroduces the degenerate-cross "stay on the roof"
// wedge the L.4 session (2026-04-30) fought. See that doc's §2 gap #2.
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";
}
// Retail CTransition::cliff_slide (0050a6d0) consumes the raw
// last-known contact normal directly. It does not substitute the
// remembered walkable plane or world-up. An invalid/default or
// parallel normal naturally takes normalize_check_small's
// degenerate OK return below.
Vector3 referenceNormal = ci.LastKnownContactPlane.Normal;
Vector3 contactNormal = Vector3.Cross(contactPlane.Normal, referenceNormal);
contactNormal.Z = 0f;
@ -2437,7 +2341,7 @@ public sealed class Transition
Vector3 collideNormal = new(-contactNormal.Y, contactNormal.X, 0f);
if (collideNormal.LengthSquared() < PhysicsGlobals.EpsilonSq)
{
DumpCliffSlide($"degenerate-cross/{refSource}", contactPlane,
DumpCliffSlide("degenerate-cross/last-known", contactPlane,
new Plane(referenceNormal, 0f), contactNormal, 0f, false);
return TransitionState.OK;
}
@ -2446,7 +2350,7 @@ public sealed class Transition
Vector3 offset = sp.GlobalSphere[0].Origin - sp.GlobalCurrCenter[0].Origin;
float angle = Vector3.Dot(collideNormal, offset);
DumpCliffSlide($"ok/{refSource}", contactPlane,
DumpCliffSlide("ok/last-known", contactPlane,
new Plane(referenceNormal, 0f), collideNormal, angle, true);
if (angle <= 0f)
@ -2481,7 +2385,7 @@ public sealed class Transition
/// skipped the contact-recovery branch matters for whether CliffSlide
/// has any chance of firing.
/// </summary>
private void DumpStepDownBranchGate(bool contactInvalidOrSteep)
private void DumpStepDownBranchGate(bool contactInvalid)
{
if (!DumpEdgeSlideEnabled) return;
@ -2489,7 +2393,7 @@ public sealed class Transition
var ci = CollisionInfo;
var oi = ObjectInfo;
bool wouldEnter = contactInvalidOrSteep && oi.Contact && !sp.StepDown
bool wouldEnter = contactInvalid && oi.Contact && !sp.StepDown
&& sp.CheckCellId != 0 && oi.StepDown;
if (!wouldEnter) return; // only log when entering, to keep noise low