fix(physics): L.4 — steep airborne hits slide-tangent (interim, deviates from retail)

Phase L.4 closes the "stuck in falling animation on a steep roof" bug
the user reported on 2026-04-30 ("I jump up, I land on it. It should not
even let me land, should just slide with a falling animation"). After
this commit the body no longer sticks to a steep roof when jumping
into it — it slides along the slope while keeping the falling animation.

Two pieces:

1. BSPQuery Path 6 steep-poly slide
   When an airborne sphere hits a polygon whose world normal Z is below
   FloorZ (≈ 0.6642, slope > ~49°), the previous flow was:
   Path 6 SetCollide → Path 4 set_walkable → ContactPlane committed →
   body "lands" on the steep poly with Contact bit + falling animation.
   This left the player stuck mid-slope because OnWalkable was cleared
   but Contact stayed set.

   The new branch detects the steep normal in Path 6 BEFORE SetCollide
   is called. Instead of entering the landing path, it removes the
   into-wall component of the move (project onto the steep face), sets
   CollisionNormal + SlidingNormal, and returns Slid. Same shape as
   Path 5's step-up fallback and CylinderCollision. The resolver retries;
   the sphere is now outside the poly; FindCollisions returns OK;
   ValidateTransition commits the slid position. ContactPlane is never
   set, so the body stays airborne with falling animation.

2. PlayerMovementController L.3a-bounce carve-out + Inelastic stop
   Re-enables the velocity-reflection bounce when the contact normal is
   upward-facing but steeper than walkable (0 < N.Z < FloorZ). The base
   L.3a rule suppresses bounce on landing transitions to avoid micro-
   bounce on flat terrain; that suppression also stuck the player to
   too-steep roofs they shouldn't land on. This carve-out re-enables
   the reflection specifically for the steep upward case.

Also lands related L.2c precipice / edge-slide work that was in flight:

- TransitionTypes EdgeSlideAfterStepDownFailed: walkable-poly-steep
  cliff route + steep-ContactPlane cliff route ordering, so that
  CliffSlide fires when the stored walkable polygon itself is too
  steep (Path 4 had previously accepted it as a "landing" via the
  permissive LandingZ threshold).
- CliffSlide reference-normal selection: prefer LastWalkable, fall back
  to LastKnownContactPlane only when walkable, else use world-up. This
  prevents the cross(steepN, steepN) = 0 degenerate case that left the
  cliff slide as a no-op when both current and last-known were steep.
- Phase 2 / step-down branch / edge-slide branch / cliff-slide
  diagnostic helpers gated on ACDREAM_DUMP_EDGE_SLIDE / ACDREAM_DUMP_STEEP_ROOF.
- Two new airborne-mover regression tests in BSPStepUpTests +
  PhysicsEngineTests covering wall-slide and edge tangent motion.

DEVIATION FROM RETAIL — DOCUMENTED FOR FOLLOW-UP

The Path 6 steep slide is NOT what retail does. Retail's flow on the
same hit is:

  Path 6 SetCollide (no steep check) → Path 4 find_walkable returns
  nothing for steep → Phase 3 reset path: restore_check_pos +
  kill_velocity → return COLLIDED → validate_transition reverts CheckPos
  to CurPos and forces OK.

Net retail behavior: position reverts to pre-failed-move (typically
just below the roof in the common jump-up case), velocity zeroed,
gravity rebuilds Z next frame, body falls back down naturally with
the falling animation. The "freeze" framing I used earlier was wrong;
in the typical case retail just bounces the body off and lets gravity
take over.

Strict retail behavior would match the user's intent better in the
common case AND avoid the bounce-energy-accumulation we saw with the
slide-tangent approach (V grew to ~50 m/s in continuous-contact frames).
However, retail's behavior degenerates in the edge case of an overhead
landing onto a steep slope (body would freeze mid-air above the roof).

This commit ships the slide-tangent fix as an interim "much better"
state per user verification on 2026-04-30. Follow-up work to match
retail strictly: revert Path 6 steep-slide, audit Phase 3 reset to
ensure kill_velocity (matching OBJECTINFO::kill_velocity ->
CPhysicsObj::set_velocity({0,0,0}, 0)) actually fires, and re-test.

Refs:
  - acclient_2013_pseudo_c.txt:323784-323821 (Path 6 SetCollide)
  - acclient_2013_pseudo_c.txt:273191-273239 (Phase 3 reset path)
  - acclient_2013_pseudo_c.txt:272563-272596 (validate_transition revert)
  - acclient_2013_pseudo_c.txt:274467-274475 (kill_velocity)
  - acclient_2013_pseudo_c.txt:282699-282715 (handle_all_collisions bounce)

Tests: 833/833 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-30 13:22:07 +02:00
parent 5210bd3d55
commit b1af56eb19
8 changed files with 417 additions and 16 deletions

View file

@ -192,10 +192,13 @@ hard-blocks or accepts too much in several of these cases.
`step_up_slide` now feels acceptable in live testing. Local/remote movement `step_up_slide` now feels acceptable in live testing. Local/remote movement
passes the retail-default `EdgeSlide` flag. The first precipice-slide slice now passes the retail-default `EdgeSlide` flag. The first precipice-slide slice now
preserves terrain/BSP walkable polygon vertices and runs the retail back-probe preserves terrain/BSP walkable polygon vertices and runs the retail back-probe
before `SPHEREPATH::precipice_slide`; `ACDREAM_DUMP_EDGE_SLIDE=1` now reports before `SPHEREPATH::precipice_slide`; edge-slide `Slid` / `Adjusted` results
whether a failed step-down had polygon context. Remaining gaps: real-DAT now feed the `TransitionalInsert` retry loop instead of being reverted by outer
building-edge fixtures, fuller `cliff_slide` coverage, and `NegPolyHit` validation, and a synthetic diagonal terrain-boundary test covers tangent
dispatch. Named retail anchors include `CTransition::edge_slide`, motion. `ACDREAM_DUMP_EDGE_SLIDE=1` now reports whether a failed step-down had
polygon context. Remaining gaps: live visual confirmation of the retry-loop
fix, real-DAT building-edge fixtures, fuller `cliff_slide` coverage, and
`NegPolyHit` dispatch. Named retail anchors include `CTransition::edge_slide`,
`CTransition::cliff_slide`, `SPHEREPATH::precipice_slide`, and `CTransition::cliff_slide`, `SPHEREPATH::precipice_slide`, and
`SPHEREPATH::step_up_slide`. `SPHEREPATH::step_up_slide`.

View file

@ -122,8 +122,11 @@ Current shipped slice (2026-04-30): wall-adjacent `step_up_slide` feels
acceptable in live testing; player/remote movers pass `EdgeSlide`; terrain and acceptable in live testing; player/remote movers pass `EdgeSlide`; terrain and
BSP step-down/find-walkable now preserve walkable polygon vertices; failed BSP step-down/find-walkable now preserve walkable polygon vertices; failed
step-down edge cases perform the retail back-probe before step-down edge cases perform the retail back-probe before
`SPHEREPATH::precipice_slide`. Remaining L.2c work is real-DAT building-edge `SPHEREPATH::precipice_slide`; precipice slide results now re-enter the
fixtures, fuller `cliff_slide` coverage, and `NegPolyHit` dispatch. `TransitionalInsert` retry loop so tangent edge motion is preserved instead of
being reverted by outer validation. Remaining L.2c work is live visual
confirmation at real building/roof edges, real-DAT building-edge fixtures,
fuller `cliff_slide` coverage, and `NegPolyHit` dispatch.
### L.2d - Shape Fidelity: Sphere / CylSphere / Building Objects ### L.2d - Shape Fidelity: Sphere / CylSphere / Building Objects

View file

@ -72,3 +72,9 @@ InputDispatcher / PlayerMovementController
step-down edge cases run the retail back-probe before precipice slide. step-down edge cases run the retail back-probe before precipice slide.
`cliff_slide` has a first port, but `NegPolyHit`, `CELLARRAY`, full `cliff_slide` has a first port, but `NegPolyHit`, `CELLARRAY`, full
`cell_bsp`, and real-DAT building portal conformance remain open L.2 work. `cell_bsp`, and real-DAT building portal conformance remain open L.2 work.
- 2026-04-30: Edge-slide retry-loop lesson. `SPHEREPATH::precipice_slide`
usually returns `Slid` after applying the tangent offset. That result must
be handled inside `TransitionalInsert` like wall slide (`continue` and
re-test the adjusted `CheckPos`), not returned to `ValidateTransition`; the
outer validator treats non-OK as a collision and restores `CurPos`, making
edges feel like hard stops even when the tangent was computed.

View file

@ -441,6 +441,19 @@ public sealed class PlayerMovementController
moverFlags: AcDream.Core.Physics.ObjectInfoState.IsPlayer moverFlags: AcDream.Core.Physics.ObjectInfoState.IsPlayer
| AcDream.Core.Physics.ObjectInfoState.EdgeSlide); | AcDream.Core.Physics.ObjectInfoState.EdgeSlide);
// L.4-diag (2026-04-30): trace position transitions so we can see
// whether the body is actually moving frame-to-frame on the steep
// roof, or whether it's frozen at the impact point.
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1"
&& resolveResult.CollisionNormalValid)
{
Console.WriteLine(
$"[steep-roof] FRAME pre=({preIntegratePos.X:F2},{preIntegratePos.Y:F2},{preIntegratePos.Z:F2}) " +
$"post=({postIntegratePos.X:F2},{postIntegratePos.Y:F2},{postIntegratePos.Z:F2}) " +
$"resolved=({resolveResult.Position.X:F2},{resolveResult.Position.Y:F2},{resolveResult.Position.Z:F2}) " +
$"isOnGround={resolveResult.IsOnGround}");
}
// Apply resolved position. // Apply resolved position.
_body.Position = resolveResult.Position; _body.Position = resolveResult.Position;
@ -507,6 +520,47 @@ public sealed class PlayerMovementController
? !(prevOnWalkable && nowOnWalkable) ? !(prevOnWalkable && nowOnWalkable)
: (!prevOnWalkable && !nowOnWalkable); : (!prevOnWalkable && !nowOnWalkable);
// L.4-steep-landing-bounce (2026-04-30): also bounce on
// landing IF the contact surface is upward-facing but
// steeper than walkable (FloorZ ≈ 49°). Per retail and the
// user's expectation: jumping onto a steep roof should NOT
// result in a landing — the player should bounce off, keep
// the falling animation, and slide off.
//
// Without this: the L.3a base rule suppresses the bounce on
// landing transitions (prev air → now ground) to avoid
// micro-bounce on flat terrain, but that suppression also
// sticks the player to too-steep roofs they shouldn't land
// on. This carve-out re-enables the bounce specifically for
// steep upward-facing surfaces.
//
// Range `0 < N.Z < FloorZ` means "facing upward but too
// steep" — excludes walls (N.Z ≈ 0) which are handled by the
// existing prevAirborne+nowAirborne rule, and ceilings
// (N.Z < 0) which the body shouldn't bounce off the same way.
if (!applyBounce
&& resolveResult.CollisionNormalValid
&& resolveResult.CollisionNormal.Z > 0f
&& resolveResult.CollisionNormal.Z < PhysicsGlobals.FloorZ)
{
applyBounce = true;
}
// L.4-diag (2026-04-30): per-frame bounce trace for steep-roof bug.
bool diagSteep = Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1";
if (diagSteep && resolveResult.CollisionNormalValid)
{
var n0 = resolveResult.CollisionNormal;
var v0 = _body.Velocity;
Console.WriteLine(
$"[steep-roof] BOUNCE-CHECK applyBounce={applyBounce} " +
$"prevWalk={prevOnWalkable} nowWalk={nowOnWalkable} " +
$"N=({n0.X:F2},{n0.Y:F2},{n0.Z:F2}) FloorZ={PhysicsGlobals.FloorZ:F2} " +
$"V=({v0.X:F2},{v0.Y:F2},{v0.Z:F2}) " +
$"dot={Vector3.Dot(v0, n0):F3} " +
$"isOnGround={resolveResult.IsOnGround}");
}
if (applyBounce) if (applyBounce)
{ {
if (_body.State.HasFlag(PhysicsStateFlags.Inelastic)) if (_body.State.HasFlag(PhysicsStateFlags.Inelastic))
@ -526,6 +580,13 @@ public sealed class PlayerMovementController
// velocity reflects (subtle bounce). // velocity reflects (subtle bounce).
float k = -(dotVN * (_body.Elasticity + 1f)); float k = -(dotVN * (_body.Elasticity + 1f));
_body.Velocity = v + n * k; _body.Velocity = v + n * k;
if (diagSteep)
{
var v1 = _body.Velocity;
Console.WriteLine(
$"[steep-roof] BOUNCE-APPLIED V_after=({v1.X:F2},{v1.Y:F2},{v1.Z:F2}) k={k:F3}");
}
} }
} }
} }

View file

@ -1481,7 +1481,6 @@ public static class BSPQuery
var worldVertices = TransformVertices(hitPoly.Vertices, localToWorld, scale, worldOrigin); var worldVertices = TransformVertices(hitPoly.Vertices, localToWorld, scale, worldOrigin);
var worldPlane = BuildWorldPlane(worldNormal, worldVertices); var worldPlane = BuildWorldPlane(worldNormal, worldVertices);
collisions.SetContactPlane(worldPlane, path.CheckCellId, false); collisions.SetContactPlane(worldPlane, path.CheckCellId, false);
path.SetWalkable(worldPlane, worldVertices, Vector3.UnitZ); path.SetWalkable(worldPlane, worldVertices, Vector3.UnitZ);
return TransitionState.Adjusted; return TransitionState.Adjusted;
@ -1572,16 +1571,68 @@ public static class BSPQuery
hitPoly0!, contact0, scale, localToWorld); hitPoly0!, contact0, scale, localToWorld);
} }
var worldNormal0 = L2W(hitPoly0!.Plane.Normal);
// L.4-reject-steep-landing (2026-04-30): if the polygon is
// too steep to walk on (worldNormal.Z < FloorZ ≈ 0.6642),
// do NOT enter the SetCollide → Path-4 → SetContactPlane
// landing path. That path commits the player to the
// surface (sets ContactPlane), which sticks them to the
// steep roof in a falling animation.
//
// Instead, treat the steep-poly hit as a wall slide:
// project the move along the steep face (remove the
// into-wall component), set CollisionNormal +
// SlidingNormal, return Slid. Same shape as Path 5's
// step-up fallback (line 1545-1547) and CylinderCollision
// (TransitionTypes.cs:1518-1522). The position is updated
// in-place; on the next resolver iteration the sphere is
// outside the poly, FindCollisions returns OK, and
// ValidateTransition commits the new position. Body stays
// airborne, falling animation continues, gravity pulls
// down the slope.
//
// CRITICAL: this MUST happen before path.SetCollide(...)
// is called. Once Collide=true is set, TransitionalInsert
// Phase 3 either commits via ContactPlane+Placement (the
// walkable case, OK on shallow slopes) or RestoreCheckPos
// (the reset case, when ContactPlaneValid is false). The
// reset path REVERTS our slide and freezes the body.
//
// Per user 2026-04-30:
// "I jump up, I land on it. It should not even let me
// land, should just slide with a falling animation."
if (worldNormal0.Z < PhysicsGlobals.FloorZ)
{
Vector3 currWorld = path.GlobalCurrCenter[0].Origin;
Vector3 endWorld = path.GlobalSphere[0].Origin;
Vector3 gDelta = endWorld - currWorld;
float diff = Vector3.Dot(worldNormal0, gDelta);
if (diff < 0f)
path.AddOffsetToCheckPos(-worldNormal0 * diff);
collisions.SetCollisionNormal(worldNormal0);
collisions.SetSlidingNormal(worldNormal0);
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1")
{
Console.WriteLine(
$"[steep-roof] PATH6-STEEP-SLIDE N=({worldNormal0.X:F2},{worldNormal0.Y:F2},{worldNormal0.Z:F2}) " +
$"FloorZ={PhysicsGlobals.FloorZ:F2} " +
$"diff={diff:F3} " +
$"newCheckPos=({path.CheckPos.X:F2},{path.CheckPos.Y:F2},{path.CheckPos.Z:F2})");
}
return TransitionState.Slid;
}
// ─── SetCollide response ───────────────────────────────── // ─── SetCollide response ─────────────────────────────────
// Airborne sphere hits a polygon. Per retail, call SetCollide // Airborne sphere hits a shallow polygon (walkable surface).
// which saves backup position, records StepUpNormal = worldNormal, // Call SetCollide so TransitionalInsert's Collide branch
// and sets WalkInterp=1. TransitionalInsert's Collide branch will // re-tests as Placement to confirm we can land on it.
// then re-test as Placement to confirm we can land on the surface.
// //
// ACE: BSPTree.find_collisions default branch → SpherePath.SetCollide // ACE: BSPTree.find_collisions default branch → SpherePath.SetCollide
// + return Adjusted. // + return Adjusted.
// Named-retail: BSPTREE::find_collisions airborne branch → set_collide. // Named-retail: BSPTREE::find_collisions airborne branch → set_collide.
var worldNormal0 = L2W(hitPoly0!.Plane.Normal);
path.SetCollide(worldNormal0); path.SetCollide(worldNormal0);
path.WalkableAllowance = PhysicsGlobals.LandingZ; path.WalkableAllowance = PhysicsGlobals.LandingZ;
return TransitionState.Adjusted; return TransitionState.Adjusted;
@ -1597,8 +1648,25 @@ public static class BSPQuery
if (hit1 || hitPoly1 is not null) if (hit1 || hitPoly1 is not null)
{ {
// Head sphere hit: same SetCollide response.
var worldNormal1 = L2W(hitPoly1!.Plane.Normal); var worldNormal1 = L2W(hitPoly1!.Plane.Normal);
// L.4-reject-steep-landing: same steep-poly slide
// for head-sphere hits.
if (worldNormal1.Z < PhysicsGlobals.FloorZ)
{
Vector3 currWorld = path.GlobalCurrCenter[0].Origin;
Vector3 endWorld = path.GlobalSphere[0].Origin;
Vector3 gDelta = endWorld - currWorld;
float diff = Vector3.Dot(worldNormal1, gDelta);
if (diff < 0f)
path.AddOffsetToCheckPos(-worldNormal1 * diff);
collisions.SetCollisionNormal(worldNormal1);
collisions.SetSlidingNormal(worldNormal1);
return TransitionState.Slid;
}
// Head sphere hit shallow surface: same SetCollide response.
path.SetCollide(worldNormal1); path.SetCollide(worldNormal1);
path.WalkableAllowance = PhysicsGlobals.LandingZ; path.WalkableAllowance = PhysicsGlobals.LandingZ;
return TransitionState.Adjusted; return TransitionState.Adjusted;

View file

@ -607,6 +607,9 @@ public sealed class Transition
// ── Phase 2: object (static BSP + cylinder) collision ─────── // ── Phase 2: object (static BSP + cylinder) collision ───────
// Env was OK — now test objects. // Env was OK — now test objects.
var objState = FindObjCollisions(engine); var objState = FindObjCollisions(engine);
// L.4-diag: log Phase outcomes per attempt so we can see whether
// we're escaping to the step-down branch or churning in retries.
DumpPhase2(attempt, transitState, objState);
if (objState == TransitionState.Collided) if (objState == TransitionState.Collided)
return TransitionState.Collided; return TransitionState.Collided;
@ -716,6 +719,8 @@ public sealed class Transition
// is invalid OR steeper than walkable. // is invalid OR steeper than walkable.
bool contactInvalidOrSteep = !ci.ContactPlaneValid bool contactInvalidOrSteep = !ci.ContactPlaneValid
|| ci.ContactPlane.Normal.Z < PhysicsGlobals.FloorZ; || 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 if (contactInvalidOrSteep && oi.Contact && !sp.StepDown
&& sp.CheckCellId != 0 && oi.StepDown) && sp.CheckCellId != 0 && oi.StepDown)
{ {
@ -832,6 +837,7 @@ public sealed class Transition
if (ci.ContactPlaneValid && ci.ContactPlane.Normal.Z < zVal && oi.EdgeSlide) if (ci.ContactPlaneValid && ci.ContactPlane.Normal.Z < zVal && oi.EdgeSlide)
{ {
var cliffPlane = ci.ContactPlane; var cliffPlane = ci.ContactPlane;
DumpEdgeSlideBranch("priority/steep-cliffslide", zVal);
sp.ClearWalkable(); sp.ClearWalkable();
sp.RestoreCheckPos(); sp.RestoreCheckPos();
ci.ContactPlaneValid = false; ci.ContactPlaneValid = false;
@ -843,6 +849,7 @@ public sealed class Transition
// movement carries EdgeSlide, so the local avatar takes the slide path. // movement carries EdgeSlide, so the local avatar takes the slide path.
if (!oi.OnWalkable || !oi.EdgeSlide) if (!oi.OnWalkable || !oi.EdgeSlide)
{ {
DumpEdgeSlideBranch("branch1/!onwalkable-or-!edgeslide", zVal);
sp.ClearWalkable(); sp.ClearWalkable();
sp.RestoreCheckPos(); sp.RestoreCheckPos();
ci.ContactPlaneValid = false; ci.ContactPlaneValid = false;
@ -853,6 +860,7 @@ public sealed class Transition
if (ci.ContactPlaneValid && ci.ContactPlane.Normal.Z < zVal) if (ci.ContactPlaneValid && ci.ContactPlane.Normal.Z < zVal)
{ {
var cliffPlane = ci.ContactPlane; var cliffPlane = ci.ContactPlane;
DumpEdgeSlideBranch("branch2/steep-cliffslide", zVal);
sp.ClearWalkable(); sp.ClearWalkable();
sp.RestoreCheckPos(); sp.RestoreCheckPos();
ci.ContactPlaneValid = false; ci.ContactPlaneValid = false;
@ -865,6 +873,32 @@ public sealed class Transition
if (sp.HasWalkablePolygon) 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.
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);
ci.ContactPlaneValid = false; ci.ContactPlaneValid = false;
ci.ContactPlaneIsWater = false; ci.ContactPlaneIsWater = false;
return sp.PrecipiceSlide(this); return sp.PrecipiceSlide(this);
@ -872,6 +906,7 @@ public sealed class Transition
if (ci.ContactPlaneValid) if (ci.ContactPlaneValid)
{ {
DumpEdgeSlideBranch("branch4/contact-no-walkable", zVal);
sp.ClearWalkable(); sp.ClearWalkable();
sp.RestoreCheckPos(); sp.RestoreCheckPos();
ci.ContactPlaneValid = false; ci.ContactPlaneValid = false;
@ -906,20 +941,53 @@ public sealed class Transition
var sp = SpherePath; var sp = SpherePath;
var ci = CollisionInfo; var ci = CollisionInfo;
if (!ci.LastKnownContactPlaneValid) // L.4-cliffslide-fallback (2026-04-30): use the LAST WALKABLE plane
return TransitionState.OK; // 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.
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";
}
Vector3 contactNormal = Vector3.Cross(contactPlane.Normal, ci.LastKnownContactPlane.Normal); Vector3 contactNormal = Vector3.Cross(contactPlane.Normal, referenceNormal);
contactNormal.Z = 0f; contactNormal.Z = 0f;
Vector3 collideNormal = new(-contactNormal.Y, contactNormal.X, 0f); Vector3 collideNormal = new(-contactNormal.Y, contactNormal.X, 0f);
if (collideNormal.LengthSquared() < PhysicsGlobals.EpsilonSq) if (collideNormal.LengthSquared() < PhysicsGlobals.EpsilonSq)
{
DumpCliffSlide($"degenerate-cross/{refSource}", contactPlane,
new Plane(referenceNormal, 0f), contactNormal, 0f, false);
return TransitionState.OK; return TransitionState.OK;
}
collideNormal = Vector3.Normalize(collideNormal); collideNormal = Vector3.Normalize(collideNormal);
Vector3 offset = sp.GlobalSphere[0].Origin - sp.GlobalCurrCenter[0].Origin; Vector3 offset = sp.GlobalSphere[0].Origin - sp.GlobalCurrCenter[0].Origin;
float angle = Vector3.Dot(collideNormal, offset); float angle = Vector3.Dot(collideNormal, offset);
DumpCliffSlide($"ok/{refSource}", contactPlane,
new Plane(referenceNormal, 0f), collideNormal, angle, true);
if (angle <= 0f) if (angle <= 0f)
{ {
@ -948,6 +1016,73 @@ public sealed class Transition
$"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}")); $"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}"));
} }
/// <summary>
/// L.4-diag: log step-down branch gate decision. Whether we entered or
/// skipped the contact-recovery branch matters for whether CliffSlide
/// has any chance of firing.
/// </summary>
private void DumpStepDownBranchGate(bool contactInvalidOrSteep)
{
if (!DumpEdgeSlideEnabled) return;
var sp = SpherePath;
var ci = CollisionInfo;
var oi = ObjectInfo;
bool wouldEnter = contactInvalidOrSteep && oi.Contact && !sp.StepDown
&& sp.CheckCellId != 0 && oi.StepDown;
if (!wouldEnter) return; // only log when entering, to keep noise low
Console.WriteLine(
System.FormattableString.Invariant(
$"edge-slide: stepdown-branch-enter cur={Fmt(sp.CurPos)} contactValid={ci.ContactPlaneValid} contactN.Z={(ci.ContactPlaneValid ? ci.ContactPlane.Normal.Z : 0f):F3} onWalk={oi.OnWalkable} contact={oi.Contact}"));
}
/// <summary>
/// L.4-diag: log Phase 2 outcome per inner attempt. Tells us whether
/// we're churning in Slid retries or escaping to step-down branch.
/// </summary>
private void DumpPhase2(int attempt, TransitionState envState, TransitionState objState)
{
if (!DumpEdgeSlideEnabled) return;
if (objState == TransitionState.OK) return; // skip clean attempts
Console.WriteLine(
System.FormattableString.Invariant(
$"edge-slide: phase2 attempt={attempt} env={envState} obj={objState}"));
}
/// <summary>
/// L.4-diag: log which branch of EdgeSlideAfterStepDownFailed fired.
/// Tells us whether CliffSlide gets called or whether we hit a
/// stop-at-edge branch.
/// </summary>
private void DumpEdgeSlideBranch(string branch, float zVal)
{
if (!DumpEdgeSlideEnabled) return;
var sp = SpherePath;
var ci = CollisionInfo;
var oi = ObjectInfo;
Console.WriteLine(
System.FormattableString.Invariant(
$"edge-slide: branch={branch} contactValid={ci.ContactPlaneValid} contactN.Z={(ci.ContactPlaneValid ? ci.ContactPlane.Normal.Z : 0f):F3} lastValid={ci.LastKnownContactPlaneValid} lastN.Z={(ci.LastKnownContactPlaneValid ? ci.LastKnownContactPlane.Normal.Z : 0f):F3} walkPolyValid={sp.HasWalkablePolygon} walkPolyN.Z={(sp.HasWalkablePolygon ? sp.WalkablePlane.Normal.Z : 0f):F3} lastWalkPolyN.Z={(sp.HasLastWalkablePolygon ? sp.LastWalkablePlane.Normal.Z : 0f):F3} onWalk={oi.OnWalkable} edgeFlag={oi.EdgeSlide} zVal={zVal:F3}"));
}
/// <summary>
/// L.4-diag: log CliffSlide invocation. Tells us whether the
/// cross-product is degenerate (no slide) or producing a real
/// deflection.
/// </summary>
private void DumpCliffSlide(string outcome, Plane current, Plane lastKnown,
Vector3 collideNormal, float angle, bool willApply)
{
if (!DumpEdgeSlideEnabled) return;
Console.WriteLine(
System.FormattableString.Invariant(
$"edge-slide: cliffslide outcome={outcome} curN={Fmt(current.Normal)} lastN={Fmt(lastKnown.Normal)} collideN={Fmt(collideNormal)} angle={angle:F4} apply={willApply}"));
}
private static string Fmt(Vector3 value) => private static string Fmt(Vector3 value) =>
System.FormattableString.Invariant($"({value.X:F3},{value.Y:F3},{value.Z:F3})"); System.FormattableString.Invariant($"({value.X:F3},{value.Y:F3},{value.Z:F3})");

View file

@ -506,6 +506,85 @@ public class BSPStepUpTests
"indicates Path 5 recursing through DoStepUp without guard."); "indicates Path 5 recursing through DoStepUp without guard.");
} }
/// <summary>
/// L.2c regression: an airborne mover jumping/falling into a vertical wall
/// must keep its vertical displacement. With no live or last-known contact
/// plane, SlideSphere must remove only the component into the wall; inventing
/// a flat UnitZ plane projects the displacement onto the wall/floor crease
/// and leaves the character stuck in falling animation against the wall.
/// </summary>
[Fact]
public void D3_AirborneMover_TallWall_PreservesVerticalMotion()
{
var (root, resolved) = BSPStepUpFixtures.TallWall();
var from = new Vector3(0.1f, 0f, 2.0f);
var to = new Vector3(0.6f, 0f, 1.5f);
var t = BSPStepUpFixtures.MakeAirborneTransition(from, to);
var engine = MakeTestEngine(root, resolved, terrainZ: -50f);
t.FindTransitionalPosition(engine);
Assert.True(t.SpherePath.CurPos.Z < from.Z - 0.1f,
$"Expected airborne wall-slide to preserve downward motion; " +
$"from.Z={from.Z:F3}, CurPos.Z={t.SpherePath.CurPos.Z:F3}");
Assert.True(t.SpherePath.CurPos.X <= 0.5f - BSPStepUpFixtures.SphereRadius + PhysicsGlobals.EPSILON * 20f,
$"Expected wall to block X penetration; got CurPos.X={t.SpherePath.CurPos.X:F3}");
}
/// <summary>
/// L.2c regression: if an airborne wall collision happens in a one-substep
/// frame, the collision normal has to survive into the next frame. Retail
/// does this with transient_state bit 2 + InitSlidingNormal. Without that,
/// every frame replays the same hard stop and the character hangs in falling
/// animation until another correction breaks the loop.
/// </summary>
[Fact]
public void D4_AirborneMover_TallWall_PersistsSlidingNormalAcrossFrames()
{
var (root, resolved) = BSPStepUpFixtures.TallWall();
var engine = MakeTestEngine(root, resolved, terrainZ: -50f);
var body = new PhysicsBody
{
Position = new Vector3(0.25f, 0f, 2.0f),
TransientState = TransientStateFlags.Active,
};
var frame1 = engine.ResolveWithTransition(
currentPos: body.Position,
targetPos: new Vector3(0.36f, 0f, 1.92f),
cellId: 0xA9B40001u,
sphereRadius: BSPStepUpFixtures.SphereRadius,
sphereHeight: 0f,
stepUpHeight: 0.04f,
stepDownHeight: 0.04f,
isOnGround: false,
body: body);
body.Position = frame1.Position;
Assert.True(body.TransientState.HasFlag(TransientStateFlags.Sliding),
"First airborne wall hit should cache SlidingNormal for the next frame.");
Assert.Equal(2.0f, frame1.Position.Z, precision: 3);
var frame2 = engine.ResolveWithTransition(
currentPos: body.Position,
targetPos: body.Position + new Vector3(0.11f, 0f, -0.08f),
cellId: 0xA9B40001u,
sphereRadius: BSPStepUpFixtures.SphereRadius,
sphereHeight: 0f,
stepUpHeight: 0.04f,
stepDownHeight: 0.04f,
isOnGround: false,
body: body);
Assert.True(frame2.Position.Z < frame1.Position.Z - 0.05f,
$"Expected cached wall-slide normal to allow falling on frame 2; " +
$"frame1.Z={frame1.Position.Z:F3}, frame2.Z={frame2.Position.Z:F3}");
Assert.InRange(frame2.Position.X, 0.24f, 0.31f);
}
// ========================================================================= // =========================================================================
// Helpers // Helpers
// ========================================================================= // =========================================================================

View file

@ -261,6 +261,52 @@ public class PhysicsEngineTests
Assert.Equal(50f, result.Position.Z, precision: 2); Assert.Equal(50f, result.Position.Z, precision: 2);
} }
[Fact]
public void ResolveWithTransition_EdgeSlideAtLoadedTerrainBoundary_PreservesTangentMotion()
{
var engine = MakeFlatEngine(terrainZ: 50f);
var body = new PhysicsBody
{
Position = new Vector3(191f, 96f, 50f),
TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
ContactPlaneValid = true,
ContactPlane = new Plane(Vector3.UnitZ, -50f),
ContactPlaneCellId = 0x003Du,
};
var settled = engine.ResolveWithTransition(
currentPos: new Vector3(191f, 96f, 50f),
targetPos: new Vector3(191.25f, 96f, 50f),
cellId: 0x003Du,
sphereRadius: 0.5f,
sphereHeight: 1.2f,
stepUpHeight: 0.4f,
stepDownHeight: 0.4f,
isOnGround: true,
body: body,
moverFlags: ObjectInfoState.EdgeSlide);
Assert.True(body.WalkablePolygonValid);
Assert.NotNull(body.WalkableVertices);
var result = engine.ResolveWithTransition(
currentPos: settled.Position,
targetPos: new Vector3(193f, 98f, 50f),
cellId: 0x003Du,
sphereRadius: 0.5f,
sphereHeight: 1.2f,
stepUpHeight: 0.4f,
stepDownHeight: 0.4f,
isOnGround: true,
body: body,
moverFlags: ObjectInfoState.EdgeSlide);
Assert.True(result.IsOnGround);
Assert.InRange(result.Position.X, 190.75f, 192.0001f);
Assert.True(result.Position.Y > 96.2f);
Assert.Equal(50f, result.Position.Z, precision: 2);
}
[Fact] [Fact]
public void ResolveWithTransition_LandblockBoundary_UpdatesFullOutdoorCellId() public void ResolveWithTransition_LandblockBoundary_UpdatesFullOutdoorCellId()
{ {