fix(physics): L.2.3d/e/f — wall slide, edge block, step-up diagnostic

Three follow-up fixes from live testing of the L.2.3 step-height pass.

L.2.3d — StepUpSlide actually applies the slide
  Previously SpherePath.StepUpSlide only set ci.SlidingNormal as a flag and
  returned Slid; the CURRENT step's CheckPos was never adjusted, so the
  sphere stopped dead at the wall. ValidateTransition's "default to UnitZ"
  branch then propagated UnitZ into SlidingNormal, overwriting the wall
  normal entirely. Net effect: stop-at-wall, no horizontal slide.

  ACE's StepUpSlide (SpherePath.cs:309-317) calls Sphere.SlideSphere which
  computes the actual slide offset against the contact-plane / wall-normal
  crease and applies it to CheckPos. acdream already had the same logic in
  Transition.SlideSphere as a private helper. Exposed as internal
  SlideSphereInternal; routed StepUpSlide through it.

L.2.3e — step-down failure returns Collided (always-on edge block)
  When walking forward off a balcony / cliff, the step-down probe in
  TransitionalInsert searches stepDownHeight below CheckPos for a
  walkable surface. On failure the previous code returned OK, which
  ValidateTransition accepted — the player walked off the edge anyway,
  with `RestoreCheckPos` reverting only to the position right after the
  outer step's offset (still post-edge).

  Per ACE Transition.cs:268-320 (EdgeSlide), retail's always-on default
  for OnWalkable + !EdgeSlide-flag movers is to reject the move. Returning
  Collided here makes ValidateTransition revert CheckPos to CurPos
  (pre-step), giving the retail-faithful "stop at edge" behavior — both
  on terrain cliffs and on building/balcony edges.

L.2.3f — diagnostic instrumentation for steep-roof investigation
  GameWindow logs the player's actual StepUpHeight + StepDownHeight at
  world-entry (along with the raw Setup.* values for comparison) so we
  can confirm whether the dat-derived value matches retail's spec
  (~0.4m) or is overriding to something larger.

  Transition.DoStepUp logs the polygon's collision-normal Z (gated on
  ACDREAM_DUMP_STEPUP=1 to keep cold-path noise low) so we can tell
  whether step-up is being triggered against truly-walkable polygons
  (Z >= FloorZ ≈ 0.66) or whether something steeper is sneaking through.

Tests: 825/825 still pass. The L.2 conformance fixtures cover the slide
path; D1 + D2 regression tests still pass with the StepUpSlide port.

Live verification needed for:
  - #2 Wall slide: running close to a wall should slide along it.
  - #4 Edge block: running off a balcony should stop at the edge.
  - #3 Steep roof: launch with ACDREAM_DUMP_STEPUP=1 and report the
    "stepup: normal=..." log lines when climbing the offending roof.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-29 17:56:22 +02:00
parent d2f6067960
commit 8fe178ee5c
3 changed files with 71 additions and 10 deletions

View file

@ -7034,11 +7034,22 @@ public sealed class GameWindow : IDisposable
_playerController.StepDownHeight = (playerSetup is not null && playerSetup.StepDownHeight > 0f)
? playerSetup.StepDownHeight
: 0.4f;
// L.2.3f (2026-04-29): diagnostic — confirm what the actual
// values from the player's Setup dat are. Retail's spec says ~0.4 m
// for humans, but we want to verify rather than guess. If the
// dat-derived value is large (e.g. 1.5 m+) it explains why the
// player can mount steep roofs via the step-up scan reach.
Console.WriteLine(
$"physics: player step heights — StepUp={_playerController.StepUpHeight:F3} m " +
$"(Setup.StepUpHeight={(playerSetup?.StepUpHeight ?? 0f):F3}), " +
$"StepDown={_playerController.StepDownHeight:F3} m " +
$"(Setup.StepDownHeight={(playerSetup?.StepDownHeight ?? 0f):F3})");
}
else
{
_playerController.StepUpHeight = 0.4f;
_playerController.StepDownHeight = 0.4f;
Console.WriteLine($"physics: player step heights — defaulting to 0.4 m (no setup dat)");
}
int plbX = _liveCenterX + (int)MathF.Floor(playerEntity.Position.X / 192f);
int plbY = _liveCenterY + (int)MathF.Floor(playerEntity.Position.Y / 192f);

View file

@ -1106,7 +1106,7 @@ public static class BSPQuery
if (transition.DoStepUp(collisionNormal, engine!))
return TransitionState.OK;
return transition.SpherePath.StepUpSlide(transition.CollisionInfo);
return transition.SpherePath.StepUpSlide(transition);
}
// -------------------------------------------------------------------------

View file

@ -248,15 +248,29 @@ public sealed class SpherePath
/// <summary>
/// Slide fallback when step-up fails. Clears the contact-plane state that
/// caused the step-up attempt and issues a slide along StepUpNormal.
/// ACE: SpherePath.StepUpSlide (ACE SpherePath.cs:309-317).
/// caused the step-up attempt and runs the full sphere-slide computation
/// to actually move the sphere along the wall.
///
/// <para>
/// L.2.3d (2026-04-29): the previous version only set <see cref="SlidingNormal"/>
/// as a flag; it never applied a slide offset. The user observed "running
/// close to the wall now I stop" — the sphere stayed pinned at the wall
/// and the slide normal got overwritten by ValidateTransition's
/// default-to-UnitZ branch. ACE actually computes the slide offset and
/// applies it to <see cref="CheckPos"/> via <c>Sphere.SlideSphere</c>;
/// we delegate to <see cref="Transition.SlideSphereInternal"/> which does
/// the same thing.
/// </para>
///
/// ACE: <c>SpherePath.StepUpSlide</c> + <c>Sphere.SlideSphere</c>
/// (SpherePath.cs:309-317, Sphere.cs:558-604).
/// </summary>
public TransitionState StepUpSlide(CollisionInfo collisions)
public TransitionState StepUpSlide(Transition transition)
{
collisions.ContactPlaneValid = false;
collisions.ContactPlaneIsWater = false;
collisions.SetSlidingNormal(StepUpNormal);
return TransitionState.Slid;
var ci = transition.CollisionInfo;
ci.ContactPlaneValid = false;
ci.ContactPlaneIsWater = false;
return transition.SlideSphereInternal(StepUpNormal, GlobalCurrCenter[0].Origin);
}
/// <summary>
@ -635,9 +649,20 @@ public sealed class Transition
}
}
// Step-down failed: stay at current position.
// L.2.3e (2026-04-29): step-down failed — the move would put
// the player off an edge with no walkable surface within reach.
// Retail's EdgeSlide (ACE Transition.cs:268-320) maps this to
// SetEdgeSlide(true, true, OK) which restores CheckPos to the
// saved (post-step) position — but our outer ValidateTransition
// accepts CheckPos as the new CurPos, defeating the intent.
//
// Returning Collided here makes ValidateTransition revert to
// CurPos (pre-step) — the always-on retail "stop at edge"
// behavior. Confirmed against ACE Transition.cs:317 where
// EdgeSlide returns Collided when no walkable surface is
// found and the EdgeSlide flag is unset (player default).
sp.RestoreCheckPos();
return TransitionState.OK;
return TransitionState.Collided;
}
return TransitionState.OK;
@ -1082,6 +1107,14 @@ public sealed class Transition
/// normal variant). ACE: Sphere.SlideSphere(Transition, ref Vector3, Vector3).
/// Decompiled: FUN_00538180.
/// </summary>
/// <summary>
/// L.2.3d: exposed as <c>internal</c> so <see cref="SpherePath.StepUpSlide"/>
/// can apply the same slide computation ACE's <c>Sphere.SlideSphere</c> uses
/// for failed step-up. Mirror of ACE Sphere.cs:558-604 (Plane variant).
/// </summary>
internal TransitionState SlideSphereInternal(Vector3 collisionNormal, Vector3 currPos)
=> SlideSphere(collisionNormal, currPos);
private TransitionState SlideSphere(Vector3 collisionNormal, Vector3 currPos)
{
var sp = SpherePath;
@ -1356,6 +1389,23 @@ public sealed class Transition
var ci = CollisionInfo;
var oi = ObjectInfo;
// L.2.3f (2026-04-29): diagnostic for steep-roof bug. Log the
// collision normal Z so we can confirm whether the polygon being
// stepped up onto is "walkable" (normal.Z >= FloorZ ≈ 0.66) or
// not (≈ 0.087 LandingZ when not OnWalkable). If we see steep
// normals being accepted, the issue is in the find_walkable
// threshold rather than the StepUpHeight reach.
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEPUP") == "1")
{
float floor = PhysicsGlobals.FloorZ;
string verdict = collisionNormal.Z >= floor ? "WALKABLE" : "STEEP";
Console.WriteLine(
$"stepup: normal=({collisionNormal.X:F3},{collisionNormal.Y:F3},{collisionNormal.Z:F3}) " +
$"|Z|={collisionNormal.Z:F3} vs FloorZ={floor:F3} → {verdict}, " +
$"OnWalkable={oi.State.HasFlag(ObjectInfoState.OnWalkable)}, " +
$"StepUpHeight={oi.StepUpHeight:F3}");
}
// L.2.3c (2026-04-29): capture the existing contact plane BEFORE
// clearing it. On step-up failure (too-tall wall) we restore it so
// the mover stays grounded — without this, walking into a wall