fix(physics): restore retail edge-response ordering
This commit is contained in:
parent
4ca7230b36
commit
c559c48d80
4 changed files with 525 additions and 180 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue