diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 6033efe..6d12894 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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 passes the retail-default `EdgeSlide` flag. The first precipice-slide slice now preserves terrain/BSP walkable polygon vertices and runs the retail back-probe -before `SPHEREPATH::precipice_slide`; `ACDREAM_DUMP_EDGE_SLIDE=1` now reports -whether a failed step-down had polygon context. Remaining gaps: real-DAT -building-edge fixtures, fuller `cliff_slide` coverage, and `NegPolyHit` -dispatch. Named retail anchors include `CTransition::edge_slide`, +before `SPHEREPATH::precipice_slide`; edge-slide `Slid` / `Adjusted` results +now feed the `TransitionalInsert` retry loop instead of being reverted by outer +validation, and a synthetic diagonal terrain-boundary test covers tangent +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 `SPHEREPATH::step_up_slide`. diff --git a/docs/plans/2026-04-29-movement-collision-conformance.md b/docs/plans/2026-04-29-movement-collision-conformance.md index 90de943..8bfc2c1 100644 --- a/docs/plans/2026-04-29-movement-collision-conformance.md +++ b/docs/plans/2026-04-29-movement-collision-conformance.md @@ -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 BSP step-down/find-walkable now preserve walkable polygon vertices; failed step-down edge cases perform the retail back-probe before -`SPHEREPATH::precipice_slide`. Remaining L.2c work is real-DAT building-edge -fixtures, fuller `cliff_slide` coverage, and `NegPolyHit` dispatch. +`SPHEREPATH::precipice_slide`; precipice slide results now re-enter the +`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 diff --git a/memory/project_movement_collision_conformance.md b/memory/project_movement_collision_conformance.md index 5db6326..6117dc6 100644 --- a/memory/project_movement_collision_conformance.md +++ b/memory/project_movement_collision_conformance.md @@ -72,3 +72,9 @@ InputDispatcher / PlayerMovementController step-down edge cases run the retail back-probe before precipice slide. `cliff_slide` has a first port, but `NegPolyHit`, `CELLARRAY`, full `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. diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index 64f9f5c..53c0225 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -441,6 +441,19 @@ public sealed class PlayerMovementController moverFlags: AcDream.Core.Physics.ObjectInfoState.IsPlayer | 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. _body.Position = resolveResult.Position; @@ -507,6 +520,47 @@ public sealed class PlayerMovementController ? !(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 (_body.State.HasFlag(PhysicsStateFlags.Inelastic)) @@ -526,6 +580,13 @@ public sealed class PlayerMovementController // velocity reflects (subtle bounce). float k = -(dotVN * (_body.Elasticity + 1f)); _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}"); + } } } } diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs index b86ccc4..cb49f9f 100644 --- a/src/AcDream.Core/Physics/BSPQuery.cs +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -1481,7 +1481,6 @@ public static class BSPQuery var worldVertices = TransformVertices(hitPoly.Vertices, localToWorld, scale, worldOrigin); var worldPlane = BuildWorldPlane(worldNormal, worldVertices); collisions.SetContactPlane(worldPlane, path.CheckCellId, false); - path.SetWalkable(worldPlane, worldVertices, Vector3.UnitZ); return TransitionState.Adjusted; @@ -1572,16 +1571,68 @@ public static class BSPQuery 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 ───────────────────────────────── - // Airborne sphere hits a polygon. Per retail, call SetCollide - // which saves backup position, records StepUpNormal = worldNormal, - // and sets WalkInterp=1. TransitionalInsert's Collide branch will - // then re-test as Placement to confirm we can land on the surface. + // Airborne sphere hits a shallow polygon (walkable surface). + // Call SetCollide so TransitionalInsert's Collide branch + // re-tests as Placement to confirm we can land on it. // // ACE: BSPTree.find_collisions default branch → SpherePath.SetCollide // + return Adjusted. // Named-retail: BSPTREE::find_collisions airborne branch → set_collide. - var worldNormal0 = L2W(hitPoly0!.Plane.Normal); path.SetCollide(worldNormal0); path.WalkableAllowance = PhysicsGlobals.LandingZ; return TransitionState.Adjusted; @@ -1597,8 +1648,25 @@ public static class BSPQuery if (hit1 || hitPoly1 is not null) { - // Head sphere hit: same SetCollide response. 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.WalkableAllowance = PhysicsGlobals.LandingZ; return TransitionState.Adjusted; diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index ce4bb07..7f28d4f 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -607,6 +607,9 @@ public sealed class Transition // ── Phase 2: object (static BSP + cylinder) collision ─────── // Env was OK — now test objects. 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) return TransitionState.Collided; @@ -716,6 +719,8 @@ public sealed class Transition // 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) { @@ -832,6 +837,7 @@ public sealed class Transition 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; @@ -843,6 +849,7 @@ public sealed class Transition // movement carries EdgeSlide, so the local avatar takes the slide path. if (!oi.OnWalkable || !oi.EdgeSlide) { + DumpEdgeSlideBranch("branch1/!onwalkable-or-!edgeslide", zVal); sp.ClearWalkable(); sp.RestoreCheckPos(); ci.ContactPlaneValid = false; @@ -853,6 +860,7 @@ public sealed class Transition if (ci.ContactPlaneValid && ci.ContactPlane.Normal.Z < zVal) { var cliffPlane = ci.ContactPlane; + DumpEdgeSlideBranch("branch2/steep-cliffslide", zVal); sp.ClearWalkable(); sp.RestoreCheckPos(); ci.ContactPlaneValid = false; @@ -865,6 +873,32 @@ public sealed class Transition 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.ContactPlaneIsWater = false; return sp.PrecipiceSlide(this); @@ -872,6 +906,7 @@ public sealed class Transition if (ci.ContactPlaneValid) { + DumpEdgeSlideBranch("branch4/contact-no-walkable", zVal); sp.ClearWalkable(); sp.RestoreCheckPos(); ci.ContactPlaneValid = false; @@ -906,20 +941,53 @@ public sealed class Transition var sp = SpherePath; var ci = CollisionInfo; - if (!ci.LastKnownContactPlaneValid) - return TransitionState.OK; + // 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. + 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; Vector3 collideNormal = new(-contactNormal.Y, contactNormal.X, 0f); if (collideNormal.LengthSquared() < PhysicsGlobals.EpsilonSq) + { + DumpCliffSlide($"degenerate-cross/{refSource}", contactPlane, + new Plane(referenceNormal, 0f), contactNormal, 0f, false); return TransitionState.OK; + } collideNormal = Vector3.Normalize(collideNormal); Vector3 offset = sp.GlobalSphere[0].Origin - sp.GlobalCurrCenter[0].Origin; float angle = Vector3.Dot(collideNormal, offset); + DumpCliffSlide($"ok/{refSource}", contactPlane, + new Plane(referenceNormal, 0f), collideNormal, angle, true); 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}")); } + /// + /// 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. + /// + 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}")); + } + + /// + /// 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. + /// + 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}")); + } + + /// + /// L.4-diag: log which branch of EdgeSlideAfterStepDownFailed fired. + /// Tells us whether CliffSlide gets called or whether we hit a + /// stop-at-edge branch. + /// + 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}")); + } + + /// + /// L.4-diag: log CliffSlide invocation. Tells us whether the + /// cross-product is degenerate (no slide) or producing a real + /// deflection. + /// + 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) => System.FormattableString.Invariant($"({value.X:F3},{value.Y:F3},{value.Z:F3})"); diff --git a/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs b/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs index 258fd58..47bdfb8 100644 --- a/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs +++ b/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs @@ -506,6 +506,85 @@ public class BSPStepUpTests "indicates Path 5 recursing through DoStepUp without guard."); } + /// + /// 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. + /// + [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}"); + } + + /// + /// 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. + /// + [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 // ========================================================================= diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs index 79d6ac8..dc53d93 100644 --- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs +++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs @@ -261,6 +261,52 @@ public class PhysicsEngineTests 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] public void ResolveWithTransition_LandblockBoundary_UpdatesFullOutdoorCellId() {