diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index 60b38412..60c23d23 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -827,6 +827,7 @@ public sealed class PlayerMovementController // declaration for the full retail trace evidence. var preIntegratePos = _body.Position; bool physicsTickRan = false; + float lastTickDt = 0f; // the dt actually integrated this frame (for cached_velocity) Vector3 oldTickEndPos = _currPhysicsPos; _physicsAccum += dt; @@ -842,6 +843,7 @@ public sealed class PlayerMovementController // Integrate accumulated dt, clamped to MaxQuantum so a long // pause doesn't produce one giant integration step. float tickDt = MathF.Min(_physicsAccum, PhysicsBody.MaxQuantum); + lastTickDt = tickDt; // R5-V3 (#171): retail UpdatePositionInternal (0x00512c30) — // PositionManager::adjust_offset (@0x00512d0e) composes the @@ -876,6 +878,14 @@ public sealed class PlayerMovementController // (motion commands, animation, return) runs normally. var postIntegratePos = _body.Position; + // retail UpdateObjectInternal (pc:283657): the transition + handle_all_collisions are + // reached ONLY when the integrated candidate actually MOVED off m_position. This gate + // is load-bearing for the #182 bleed: after fsf>1 zeros a blocked jump's velocity, the + // next frame integrates zero motion (velMag2==0 → no position step, just v += gravity), + // so the candidate hasn't moved yet — handle_all_collisions MUST be skipped that frame + // or it re-zeros the gravity velocity and the body re-wedges instead of falling off. + bool candidateMoved = postIntegratePos != preIntegratePos; + // ── 5. Collision resolution via CTransition sphere-sweep ───────────── // The Transition system subdivides the movement from pre→post into // sphere-radius steps, testing terrain collision at each step. @@ -928,7 +938,23 @@ public sealed class PlayerMovementController $"isOnGround={resolveResult.IsOnGround}"); } - // Apply resolved position. + // ── Apply the resolve: retail UpdateObjectInternal (0x005156b0) → + // SetPositionInternal (0x00515330) → handle_all_collisions (0x00514780). + // #182 verbatim rebuild: this REPLACES the ad-hoc airborne-only bounce with the + // frames_stationary_fall-driven velocity model — the airborne-stuck bleed + // (fsf>1 → velocity zeroed) that lets a blocked jump fall/glide off a monster crowd. + + // cached_velocity = realized displacement / dt (retail's SEPARATE reporting/DR value, + // pc:005158cb-5158ff; not fed back into the integrator velocity). + _body.CachedVelocity = (candidateMoved && physicsTickRan && lastTickDt > 0f) + ? (resolveResult.Position - preIntegratePos) / lastTickDt + : Vector3.Zero; + + // Capture prev contact/walkable BEFORE committing the new state — retail + // SetPositionInternal reads transient_state at entry for handle_all_collisions' args. + bool prevContact = _body.InContact; + bool prevOnWalkable = _body.OnWalkable; + _body.Position = resolveResult.Position; if (physicsTickRan) { @@ -939,169 +965,63 @@ public sealed class PlayerMovementController // UseTime (@0x005159b3) runs AFTER the quantum's integration // (whose head hosts adjust_offset) and only on frames where a // physics quantum executed (retail's update_object MinQuantum - // gate skips the whole UpdateObjectInternal). Diff-review find: - // a head-of-frame placement tore an expiring stick down BEFORE - // the crossing quantum's final steer/turn — retail applies that - // last adjust_offset, THEN the tail watchdog tears down. + // gate skips the whole UpdateObjectInternal). PositionManager?.UseTime(); } - // L.3a (2026-04-30): retail wall-bounce / velocity reflection. - // - // Retail's CPhysicsObj::handle_all_collisions runs after every - // SetPositionInternal. It reads the wall normal that the - // transition's slide computed and reflects the body's velocity: - // - // v_new = v - (1 + elasticity) * dot(v, n) * n - // - // This is what gives retail its "bouncy" feel — fast head-on - // jumps push the player back from the wall, glancing angles - // produce a small deflection. acdream's transition resolver - // SLID position correctly but never updated velocity, so the - // player kept driving into walls until the controller's input - // changed direction. Felt sticky / fragile. - // - // Suppression rule (apply_bounce): grounded movement on a wall - // SHOULDN'T bounce — sliding along a corridor is expected. Only - // airborne wall hits reflect. Mirrors retail's `var_10_1` guard - // and ACE PhysicsObj.cs:2656-2660 `apply_bounce`. - // - // Inelastic flag (spell projectiles, missiles) zeros velocity - // entirely instead of reflecting. The player never has it set. - // - // Sources: - // acclient_2013_pseudo_c.txt:282699-282715 (handle_all_collisions) - // acclient.h:2834 (INELASTIC_PS = 0x20000) - // ACE PhysicsObj.cs:2656-2721 (line-for-line port) - // PhysicsGlobals.DefaultElasticity = 0.05f, MaxElasticity = 0.1f - if (resolveResult.CollisionNormalValid) - { - bool prevOnWalkable = _body.OnWalkable; - bool nowOnWalkable = resolveResult.IsOnGround; - - // apply_bounce: bounce ONLY when the body stays airborne both - // before and after this step. That is: jumping into a wall - // mid-flight, hitting a ceiling, etc. Specifically NOT: - // - // - prev grounded + now grounded → wall-slide along corridor - // (bounce would feel sticky on every wall touch). - // - prev airborne + now grounded → terrain landing - // (terrain normal is mostly +Z; reflecting downward velocity - // would push the body upward and prevent the landing snap - // from firing — player perpetually micro-bouncing on the - // floor instead of resting). - // - prev grounded + now airborne → walked off cliff - // (gravity should take over, not lateral bounce). - // - // Sledding mode reverts to retail's broader rule (bounce - // unless both grounded), since sledding intentionally bounces - // off ramps. - // - // This is more conservative than retail's strict - // `!(prev && now && !sledding)` rule — retail bounces on - // landing too, but at elasticity 0.05 the visual effect is - // imperceptible there. acdream's per-frame architecture - // amplifies the artifact (the post-reflection upward Z - // defeats the controller's `Velocity.Z <= 0` landing-snap - // gate), so we suppress it on landing to avoid the - // micro-bounce death spiral. - bool applyBounce = _body.State.HasFlag(PhysicsStateFlags.Sledding) - ? !(prevOnWalkable && nowOnWalkable) - : (!prevOnWalkable && !nowOnWalkable); - - // L.4-diag (2026-04-30): per-frame bounce trace for steep-roof bug. - bool diagSteep = AcDream.Core.Physics.PhysicsDiagnostics.DumpSteepRoofEnabled; - 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)) - { - // Full stop on impact. Spell projectiles / missiles. - _body.Velocity = Vector3.Zero; - } - else - { - var v = _body.Velocity; - var n = resolveResult.CollisionNormal; - float dotVN = Vector3.Dot(v, n); - if (dotVN < 0f) - { - // Reflect the into-wall component back out. - // Player elasticity is 0.05 → 105% of perpendicular - // 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}"); - } - } - } - } - } - + // SetPositionInternal contact determination (pc:283468-510). acdream's resolver + // reports IsOnGround even during an UPWARD jump (it always step-downs), so the + // contact-plane intent stays gated by Velocity.Z<=0 (documented adaptation AD-25): a + // jump stays airborne until it descends. Determined BEFORE handle_all_collisions so the + // landing state is committed before any reflect — this ordering plus the ungated + // small-velocity-zero (Slice 1a) is what retires AD-25's micro-bounce death spiral + // (the old code reflected FIRST, so the reflected +Z defeated the landing gate). bool justLanded = false; - if (resolveResult.IsOnGround) + if (resolveResult.IsOnGround && _body.Velocity.Z <= 0f) { - if (_body.Velocity.Z <= 0f) - { - // Grounded — snap to resolved position and land. - bool wasAirborne = !_body.OnWalkable; - _body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable; - _body.calc_acceleration(); + bool wasAirborne = !_body.OnWalkable; + _body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable; + _body.calc_acceleration(); - if (_body.Velocity.Z < 0f) - _body.Velocity = new Vector3(_body.Velocity.X, _body.Velocity.Y, 0f); + // Stop the fall on landing (retail settles the into-ground component via + // calc_friction next frame; the hand-zero avoids a one-frame floor dip and makes + // handle_all_collisions' landing reflect a no-op — dot(v,n)=0). + if (_body.Velocity.Z < 0f) + _body.Velocity = new Vector3(_body.Velocity.X, _body.Velocity.Y, 0f); - if (wasAirborne) - { - // R4-V5 → R5-V5: retail order — minterp first, then - // moveto (MovementManager::HitGround 0x00524300, decomp - // §2d — now the literal facade relay); re-arms a moveto - // suspended by the airborne UseTime contact gate. - // LeaveGround has NO moveto side (COMDAT no-op, §2e) — - // do not add one. - Movement.HitGround(); - justLanded = true; - } - } - else + if (wasAirborne) { - // Moving upward (jump) — stay airborne even though terrain is below. - _body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable); - _body.calc_acceleration(); + // R4-V5 → R5-V5: retail order — minterp then moveto + // (MovementManager::HitGround 0x00524300). Re-arms a moveto suspended by the + // airborne UseTime contact gate. LeaveGround has NO moveto side (§2e). + Movement.HitGround(); + justLanded = true; } } else { - // No ground found — airborne. + // Airborne: jumping up (IsOnGround but v.z>0) OR no ground found. _body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable); _body.calc_acceleration(); } + // handle_all_collisions (0x00514780): reflect the into-surface velocity (fsf≤1) or + // ZERO it entirely (fsf>1 — THE airborne-stuck fix). The Stationary* bit round-trip is + // owned by the Core resolve writeback. Restores retail's should_reflect rule; on a + // landing the Velocity.Z hand-zero above makes the reflect a no-op (no micro-bounce). + // Gated on candidateMoved (retail SetPositionInternal is only reached when the candidate + // moved) so a no-move frame doesn't re-zero the gravity velocity rebuilding after a bleed. + if (candidateMoved) + { + PhysicsObjUpdate.HandleAllCollisions( + _body, + resolveResult.CollisionNormalValid, resolveResult.CollisionNormal, + prevContact, prevOnWalkable, nowOnWalkable: _body.OnWalkable); + } - // R3-W4 (J7/J8): the grounded→airborne EDGE fires retail's - // LeaveGround (0x00528b00) — jump launches (jump() cleared - // OnWalkable earlier this frame) AND walk-off-a-ledge both route - // here, replacing the old manual jump-block call (and giving - // ledge departures the momentum fallback + link strip they never - // had). Edge = grounded last frame, airborne now; the landing - // branch above already fires HitGround on the opposite edge. + // R3-W4 (J7/J8): the grounded→airborne EDGE fires retail's LeaveGround (0x00528b00) — + // jump launches and walk-off-a-ledge both route here. The landing branch above fires + // HitGround on the opposite edge. if (!_body.OnWalkable && !_wasAirborneLastFrame) _motion.LeaveGround(); diff --git a/tests/AcDream.Core.Tests/Physics/Issue182CrowdJumpTests.cs b/tests/AcDream.Core.Tests/Physics/Issue182CrowdJumpTests.cs new file mode 100644 index 00000000..278b5e33 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Issue182CrowdJumpTests.cs @@ -0,0 +1,110 @@ +using System; +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.Core.Tests.Physics; + +/// +/// End-to-end (Core) proof of the #182 airborne-stuck fix: a jump velocity blocked by a +/// creature BLEEDS to zero within a few frames so gravity can resume — the player falls / +/// glides off instead of hanging in the falling animation. Chains the same steps the +/// PlayerMovementController per-frame loop runs, minus the App layer: +/// integrate (UpdatePhysicsInternal + gravity) → ResolveWithTransition → apply → +/// handle_all_collisions (PhysicsObjUpdate). +/// Before the rebuild the velocity persisted (only an airborne-only reflect that barely +/// touched +Z against a near-horizontal normal); now fs>1 zeros it. +/// +public class Issue182CrowdJumpTests +{ + private readonly ITestOutputHelper _out; + public Issue182CrowdJumpTests(ITestOutputHelper output) => _out = output; + + private const uint Lb = 0xA9B40000u; + private const uint Cell = Lb | 0x0001u; + private const float R = 0.48f, H = 1.835f, StepUp = 0.60f, StepDown = 0.04f; + private const float Dt = 1f / 30f; // one physics quantum + + private static PhysicsEngine BuildEngine() + { + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + engine.AddLandblock(Lb, new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), Array.Empty(), 0f, 0f); + return engine; + } + + private static void RegisterCreatureAt(PhysicsEngine e, uint id, Vector3 c) + => e.ShadowObjects.Register(id, 0u, c, Quaternion.Identity, R, + 0f, 0f, Lb, ShadowCollisionType.Sphere, 0f, 1f, 0u, + EntityCollisionFlags.IsCreature, isStatic: false); + + [Fact] + public void BlockedJump_VelocityBleedsToZero_ThenGravityResumes() + { + var engine = BuildEngine(); + RegisterCreatureAt(engine, 0xC0B0u, new Vector3(12f, 10f, 4.5f)); // creature overhead + + var body = new PhysicsBody + { + Position = new Vector3(12f, 10f, 3.0f), + Orientation = Quaternion.Identity, + State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions, + TransientState = TransientStateFlags.None, // airborne + Velocity = new Vector3(0f, 0f, 12f), // a jump: moving straight up + }; + uint cell = Cell; + + bool bled = false; + for (int frame = 0; frame < 20; frame++) + { + // 1) integrate velocity + gravity into a candidate (mirrors the controller's §4). + var pre = body.Position; + body.calc_acceleration(); + body.UpdatePhysicsInternal(Dt); + var post = body.Position; + bool candidateMoved = post != pre; // retail candidate != m_position gate + + // 2) resolve the candidate against the crowd. + var r = engine.ResolveWithTransition(pre, post, cell, R, H, StepUp, StepDown, + isOnGround: false, body: body, + moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide); + + // 3) apply + contact determination (mirrors the controller's SetPositionInternal). + bool prevOnWalkable = body.OnWalkable; + body.Position = r.Position; + cell = r.CellId; + if (r.IsOnGround && body.Velocity.Z <= 0f) + { + body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable; + body.calc_acceleration(); + if (body.Velocity.Z < 0f) body.Velocity = new Vector3(body.Velocity.X, body.Velocity.Y, 0f); + } + else + { + body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable); + body.calc_acceleration(); + } + + // 4) handle_all_collisions: reflect (fsf<=1) or zero (fsf>1) — but ONLY when the + // candidate moved (retail skips SetPositionInternal otherwise), so gravity can + // rebuild the velocity after the bleed instead of it being re-zeroed each frame. + if (candidateMoved) + PhysicsObjUpdate.HandleAllCollisions(body, + r.CollisionNormalValid, r.CollisionNormal, + prevContact: false, prevOnWalkable, nowOnWalkable: body.OnWalkable); + + _out.WriteLine($"frame{frame,2}: z={body.Position.Z:F3} vz={body.Velocity.Z:F3} " + + $"fsf={body.FramesStationaryFall}"); + + // The upward jump velocity must collapse to ~0 (or reverse to falling) within a few + // frames of being blocked — the bleed. Before the fix it persisted near +12. + if (frame >= 1 && frame <= 6 && body.Velocity.Z <= 0.5f) + bled = true; + } + + Assert.True(bled, "the blocked jump velocity must bleed to ~0 within a few frames (fsf>1 → v=0)"); + // After bleeding, gravity has taken over — the body is no longer being shoved upward. + Assert.True(body.Velocity.Z <= 0.5f, $"velocity should not persist upward; got vz={body.Velocity.Z:F3}"); + } +}