From 5154a3eae1ca1bac571cf239c0d1a834d9e85f78 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 3 May 2026 08:08:23 +0200 Subject: [PATCH] fix(motion): heading + jump bugs in InterpolationManager path (L.3.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visual verification (Task 7) revealed two bugs in the new env-var gated path: 1. Heading locked at login direction. Cause: AdjustOffset returns position delta only; the dist≤96 enqueue branch never updated body.Orientation. Fix: apply orientation unconditionally on every UpdatePosition (snap-on-receipt). Position lerps via queue. 2. Endless jumping. Cause: (a) body.Velocity persisted forever after arc landed because apply_current_movement no longer ran; (b) UpdatePositions during the arc were enqueued, fighting the gravity sim. Fix: skip enqueue when rm.Airborne (mirrors retail MoveOrTeleport has_contact=false → no-op); zero non-airborne body.Velocity each tick (mirrors legacy apply_current_movement); detect landed when receiving UpdatePosition while airborne with no/zero velocity. Co-Authored-By: Claude Opus 4.7 --- src/AcDream.App/Rendering/GameWindow.cs | 60 +++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 0866e84..2beecb9 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -3261,6 +3261,48 @@ public sealed class GameWindow : IDisposable // - has_contact && distance ≤ 96 → InterpolationManager.Enqueue (queue) // - has_contact && distance > 96 → SetPositionSimple (slide-snap) + // Bug 1 fix (L.3.1 visual verification): apply orientation unconditionally + // on every UpdatePosition, regardless of the routing branch below. + // InterpolationManager.AdjustOffset returns a position delta only — it + // never updates Orientation. Without this, the dist≤96 enqueue branch + // never touched Body.Orientation, so remote heading was locked at whatever + // it was at login. Position lerps via the queue; heading snaps on receipt, + // which is both perceptually correct and mirrors retail's set_frame behavior + // (FUN_00514b90 @ chunk_00510000.c:5637 — direct assignment). + rmState.Body.Orientation = rot; + + // Bug 2b fix (L.3.1 visual verification): if the remote is currently + // airborne (body.Velocity set by VectorUpdate, gravity integrating), skip + // enqueueing position waypoints. The queue and the gravity sim would + // double-step position. Mirrors retail MoveOrTeleport returning false when + // has_contact == false (acclient @ 0x00516330). The landing UpdatePosition + // (received after arc completes with no/zero velocity) will arrive with + // rmState.Airborne == false and proceed normally. + // + // Bug 2c fix: detect "just landed" — if Airborne was true but this + // UpdatePosition carries no non-trivial velocity, treat it as ground + // 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). + if (rmState.Airborne) + { + bool velocityIsNegligible = update.Velocity is null + || update.Velocity.Value.LengthSquared() < 0.04f; + if (velocityIsNegligible) + { + // Landed: snap to server position, re-ground the body. + rmState.Airborne = false; + rmState.Body.Velocity = System.Numerics.Vector3.Zero; + rmState.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; + rmState.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact + | AcDream.Core.Physics.TransientStateFlags.OnWalkable; + rmState.Body.Position = worldPos; + rmState.Interp.Clear(); + } + // Still airborne: don't enqueue — let gravity arc continue. + return; + } + const float MaxPhysicsDistance = 96f; System.Numerics.Vector3 localPlayerPos = _playerController?.Position ?? System.Numerics.Vector3.Zero; @@ -3273,22 +3315,23 @@ public sealed class GameWindow : IDisposable if (teleportFlag) { - // SetPosition equivalent: hard-snap position + orientation, clear interp queue. + // SetPosition equivalent: hard-snap position, clear interp queue. + // Orientation already applied unconditionally above. rmState.Body.Position = worldPos; - rmState.Body.Orientation = rot; rmState.Interp.Clear(); } else if (dist > MaxPhysicsDistance) { // SetPositionSimple equivalent: slide-snap (clear queue, then hard-snap). + // Orientation already applied unconditionally above. rmState.Interp.Clear(); rmState.Body.Position = worldPos; - rmState.Body.Orientation = rot; } 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. + // Orientation already applied unconditionally above. float headingFromQuat = ExtractYawFromQuaternion(rot); rmState.Interp.Enqueue(worldPos, headingFromQuat, isMovingTo: false); } @@ -5797,6 +5840,17 @@ 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;