fix(motion): heading + jump bugs in InterpolationManager path (L.3.1)

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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-03 08:08:23 +02:00
parent e08accf7c2
commit 5154a3eae1

View file

@ -3261,6 +3261,48 @@ public sealed class GameWindow : IDisposable
// - has_contact && distance ≤ 96 → InterpolationManager.Enqueue (queue) // - has_contact && distance ≤ 96 → InterpolationManager.Enqueue (queue)
// - has_contact && distance > 96 → SetPositionSimple (slide-snap) // - 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; const float MaxPhysicsDistance = 96f;
System.Numerics.Vector3 localPlayerPos = System.Numerics.Vector3 localPlayerPos =
_playerController?.Position ?? System.Numerics.Vector3.Zero; _playerController?.Position ?? System.Numerics.Vector3.Zero;
@ -3273,22 +3315,23 @@ public sealed class GameWindow : IDisposable
if (teleportFlag) 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.Position = worldPos;
rmState.Body.Orientation = rot;
rmState.Interp.Clear(); rmState.Interp.Clear();
} }
else if (dist > MaxPhysicsDistance) else if (dist > MaxPhysicsDistance)
{ {
// SetPositionSimple equivalent: slide-snap (clear queue, then hard-snap). // SetPositionSimple equivalent: slide-snap (clear queue, then hard-snap).
// Orientation already applied unconditionally above.
rmState.Interp.Clear(); rmState.Interp.Clear();
rmState.Body.Position = worldPos; rmState.Body.Position = worldPos;
rmState.Body.Orientation = rot;
} }
else else
{ {
// InterpolationManager.Enqueue equivalent: queue for adjust_offset to walk to. // InterpolationManager.Enqueue equivalent: queue for adjust_offset to walk to.
// NOTE: do NOT touch rmState.Body.Position here — adjust_offset (Task 5) owns it. // NOTE: do NOT touch rmState.Body.Position here — adjust_offset (Task 5) owns it.
// Orientation already applied unconditionally above.
float headingFromQuat = ExtractYawFromQuaternion(rot); float headingFromQuat = ExtractYawFromQuaternion(rot);
rmState.Interp.Enqueue(worldPos, headingFromQuat, isMovingTo: false); rmState.Interp.Enqueue(worldPos, headingFromQuat, isMovingTo: false);
} }
@ -5797,6 +5840,17 @@ public sealed class GameWindow : IDisposable
rm.Body.Position += delta; 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 // Gravity integration: retail's UpdatePhysicsInternal still
// fires every frame regardless of the interpolation path. // fires every frame regardless of the interpolation path.
// For grounded remotes body.Velocity == 0 so this is a no-op; // For grounded remotes body.Velocity == 0 so this is a no-op;