From f199a6a0754570d1cd4954ca3e79b6b67eb090dc Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 3 May 2026 09:38:49 +0200 Subject: [PATCH] fix(motion): airborne hard-snap + velocity-extrapolation (L.3.1) Round 2 fix for two visual bugs that survived commit 5154a3e: Bug 1 (chop at 1 Hz UP cadence): Round 1 zeroed body.Velocity each tick on grounded remotes, leaving AdjustOffset as the sole motion source. AdjustOffset catches up in ~150 ms then sits idle until the next UP at 1 Hz, producing visible "updates every 1 second" stepping. Root cause: retail achieves smoothness via animation root motion + AdjustOffset *corrections*; we only ported corrections (root motion is Phase L.3.2 / PositionManager). Workaround for L.3.1: seed body.Velocity from update.Velocity on every grounded UP so UpdatePhysicsInternal integrates position += vel*dt between UPs, with the queue providing corrective patches via AdjustOffset. Bug 2 (endless jump): Round 1 tried to detect landing via "UP arrives during airborne with no velocity" but ACE keeps sending non-zero velocity through the arc, so the detector never fired. Fix: stop maintaining a local "predicted arc". Server is authoritative for airborne position too -- hard-snap from each UP during airborne; body.Velocity (set by OnLiveVectorUpdated) integrates between UPs for smoothing. Landing detected via reported-Z-near-body-Z + falling/ settled velocity heuristic (more reliable than the velocity-zero test). Per-frame tick: removed the !rm.Airborne velocity clamp from Round 1. OnLivePositionUpdated now owns velocity policy; per-tick just integrates whatever is set. Both deviations from retail decomp are documented in source comments and slated for L.3.2 (PositionManager) cleanup. Co-Authored-By: Claude Opus 4.7 --- src/AcDream.App/Rendering/GameWindow.cs | 88 ++++++++++++++++--------- 1 file changed, 57 insertions(+), 31 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 2beecb9..8cb2351 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -3284,13 +3284,30 @@ public sealed class GameWindow : IDisposable // contact: clear Airborne, zero body.Velocity, restore contact flags. // This is the signal ACE uses (VectorUpdate only fires on jump start; // no corresponding "landed" packet — the next plain UpdatePosition is it). + // ── AIRBORNE ──────────────────────────────────────────────────────────── + // Server is authoritative for the arc. Hard-snap position from every UP + // while airborne; body.Velocity (set by OnLiveVectorUpdated at jump start, + // or unchanged) continues to integrate via UpdatePhysicsInternal/gravity + // between UPs. Don't enqueue — the queue is for grounded motion only. + // + // Landing heuristic (L.3.1): ACE doesn't send an explicit "landed" packet. + // Instead we detect landing by two conditions simultaneously: + // 1. The server-reported Z is within 0.5m of the body's current Z + // (server has snapped to ground level — close to where we are). + // 2. Body's vertical velocity is falling or settled (vz <= 0.5 m/s). + // Both together mean the arc is complete. We do NOT use "velocity == 0" + // because ACE sends non-zero velocity through the entire arc (Bug 2 root + // cause in Round 1). if (rmState.Airborne) { - bool velocityIsNegligible = update.Velocity is null - || update.Velocity.Value.LengthSquared() < 0.04f; - if (velocityIsNegligible) + bool reportedNearBodyZ = + MathF.Abs(worldPos.Z - rmState.Body.Position.Z) < 0.5f; + bool velocityFallingOrSettled = + rmState.Body.Velocity.Z <= 0.5f; + + if (reportedNearBodyZ && velocityFallingOrSettled) { - // Landed: snap to server position, re-ground the body. + // LANDED: snap to ground, re-ground the body. rmState.Airborne = false; rmState.Body.Velocity = System.Numerics.Vector3.Zero; rmState.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; @@ -3298,11 +3315,18 @@ public sealed class GameWindow : IDisposable | AcDream.Core.Physics.TransientStateFlags.OnWalkable; rmState.Body.Position = worldPos; rmState.Interp.Clear(); + return; } - // Still airborne: don't enqueue — let gravity arc continue. + + // Still airborne: hard-snap so server is authoritative for the arc. + // body.Velocity preserved from VectorUpdate; UpdatePhysicsInternal + // integrates gravity between UPs. + rmState.Body.Position = worldPos; return; } + // ── GROUNDED ──────────────────────────────────────────────────────────── + // Routing mirrors CPhysicsObj::MoveOrTeleport (acclient @ 0x00516330). const float MaxPhysicsDistance = 96f; System.Numerics.Vector3 localPlayerPos = _playerController?.Position ?? System.Numerics.Vector3.Zero; @@ -3313,27 +3337,37 @@ public sealed class GameWindow : IDisposable // Default-true: HasContact not on wire yet (CreateObject.ServerPosition gap). // bool hasContact = true; (implicit — only the teleport and distance branches below) - if (teleportFlag) + if (teleportFlag || dist > MaxPhysicsDistance) { - // SetPosition equivalent: hard-snap position, clear interp queue. - // Orientation already applied unconditionally above. - rmState.Body.Position = worldPos; - rmState.Interp.Clear(); - } - else if (dist > MaxPhysicsDistance) - { - // SetPositionSimple equivalent: slide-snap (clear queue, then hard-snap). + // SetPosition / SetPositionSimple equivalent: hard-snap, clear queue. // Orientation already applied unconditionally above. + // Zero velocity so UpdatePhysicsInternal doesn't extrapolate from + // a prior walk-direction after a teleport or distant slide-snap. rmState.Interp.Clear(); rmState.Body.Position = worldPos; + rmState.Body.Velocity = System.Numerics.Vector3.Zero; } else { - // InterpolationManager.Enqueue equivalent: queue for adjust_offset to walk to. - // NOTE: do NOT touch rmState.Body.Position here — adjust_offset (Task 5) owns it. + // InterpolationManager.Enqueue equivalent: queue for AdjustOffset to walk to. + // NOTE: do NOT touch rmState.Body.Position here — AdjustOffset owns it. // Orientation already applied unconditionally above. + // + // L.3.1 WORKAROUND — velocity-extrapolation between UPs: + // Retail achieves smooth 60 fps motion via animation root motion feeding + // PositionManager (Phase L.3.2 / PositionManager port). Until that lands, + // AdjustOffset alone catches up in ~150 ms after each 1-Hz UP then sits + // idle the remaining 850 ms — visible as "updates every 1 second" stepping. + // Workaround: seed body.Velocity from the UP's velocity field so + // UpdatePhysicsInternal integrates position += vel*dt between UPs; + // AdjustOffset provides corrective patches when drift accumulates. + // When update.Velocity is null the entity is stationary on this UP → + // zero velocity → only queue-walking applies. This deviates from the + // retail decomp finding that walking remotes have m_velocityVector == 0, + // but is the best approximation available without root motion. float headingFromQuat = ExtractYawFromQuaternion(rot); rmState.Interp.Enqueue(worldPos, headingFromQuat, isMovingTo: false); + rmState.Body.Velocity = update.Velocity ?? System.Numerics.Vector3.Zero; } // Skip the legacy hard-snap path below. @@ -5840,21 +5874,13 @@ public sealed class GameWindow : IDisposable rm.Body.Position += delta; } - // Bug 2a fix (L.3.1 visual verification): grounded remotes must keep - // body.Velocity == 0 so it doesn't fight the queue. In the legacy path - // apply_current_movement achieved this by recomputing velocity from - // InterpretedState each tick; the new path skips apply_current_movement, - // so we explicitly clamp. Airborne remotes keep their VectorUpdate-set - // velocity for gravity arc integration (UpdatePhysicsInternal below). - if (!rm.Airborne) - { - rm.Body.Velocity = System.Numerics.Vector3.Zero; - } - - // Gravity integration: retail's UpdatePhysicsInternal still - // fires every frame regardless of the interpolation path. - // For grounded remotes body.Velocity == 0 so this is a no-op; - // for airborne remotes it applies gravity to the arc. + // Velocity policy is owned by OnLivePositionUpdated (grounded) and + // OnLiveVectorUpdated (airborne jump start). Do NOT clamp body.Velocity + // here — doing so stomped the velocity-extrapolation workaround seeded + // on grounded UPs (Bug 1 regression from Round 1). UpdatePhysicsInternal + // integrates whatever velocity is set: zero for stationary remotes, + // update.Velocity for moving remotes (L.3.1 workaround), or the launch + // arc velocity for airborne remotes. Gravity is applied by the same call. rm.Body.UpdatePhysicsInternal(dt); ae.Entity.Position = rm.Body.Position;