diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index d87df04..6d0c297 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -350,6 +350,33 @@ public sealed class GameWindow : IDisposable
public AcDream.Core.Physics.PositionManager Position { get; } =
new AcDream.Core.Physics.PositionManager();
+ ///
+ /// Most recent server-broadcast Z coordinate from any UpdatePosition
+ /// (including mid-arc airborne UPs). Used by the
+ /// ACDREAM_INTERP_MANAGER=1 per-tick path as a landing-fallback
+ /// floor: if gravity drags the body's Z below this value while
+ /// is still set, force-land locally because
+ /// the server has effectively told us where the ground is even if
+ /// it never sent an IsGrounded=true UP. Initialized to NaN so the
+ /// fallback is a no-op until the first UP arrives.
+ ///
+ public float LastServerZ = float.NaN;
+
+ ///
+ /// Diagnostic-only (gated on ACDREAM_REMOTE_VEL_DIAG=1): the
+ /// previous UpdatePosition's world position + timestamp. The per-tick
+ /// path computes (serverPos - prevServerPos) / dt and compares
+ /// it to the sequencer's CurrentVelocity. The ratio tells us
+ /// whether the local-prediction speed (animation root motion) is
+ /// outrunning the server's actual broadcast pace, which would cause
+ /// the InterpolationManager queue to walk back the body each UP and
+ /// produce visible 1-Hz blips. Read in TickAnimations and throttled
+ /// to one log line per remote per ~2 seconds.
+ ///
+ public System.Numerics.Vector3 PrevServerPos;
+ public double PrevServerPosTime;
+ public double LastVelDiagLogTime;
+
public RemoteMotion()
{
Body = new AcDream.Core.Physics.PhysicsBody
@@ -3266,6 +3293,25 @@ public sealed class GameWindow : IDisposable
// position only; heading would otherwise lag the queue.
rmState.Body.Orientation = rot;
+ // Track the most recent server-broadcast Z on EVERY UP — including
+ // mid-arc airborne ones. Read by the per-tick landing-fallback in
+ // TickAnimations: if gravity drags the body below this floor while
+ // still airborne, we force-land locally even when the server never
+ // sent an IsGrounded=true UP for the actual landing frame.
+ rmState.LastServerZ = worldPos.Z;
+
+ // Diagnostic-only (ACDREAM_REMOTE_VEL_DIAG=1): roll the previous
+ // server-pos snapshot forward so the per-tick comparison has a
+ // delta to work with. Cheap (struct copy + double write); not
+ // gated here because the read side gates the actual print.
+ {
+ double nowSecDiag = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
+ rmState.PrevServerPos = rmState.LastServerPos;
+ rmState.PrevServerPosTime = rmState.LastServerPosTime;
+ rmState.LastServerPos = worldPos;
+ rmState.LastServerPosTime = nowSecDiag;
+ }
+
// ── AIRBORNE NO-OP ────────────────────────────────────────────
// Mirrors retail CPhysicsObj::MoveOrTeleport (acclient @ 0x00516330):
// when has_contact==0, return false (don't touch body, don't queue).
@@ -5839,6 +5885,65 @@ public sealed class GameWindow : IDisposable
// Step 4: physics integration (Euler: pos += vel*dt + 0.5*accel*dt²).
rm.Body.UpdatePhysicsInternal(dt);
+ // Step 5: landing fallback. The retail-faithful path leaves
+ // the landing transition to OnLivePositionUpdated when ACE
+ // sends IsGrounded=true. In practice ACE doesn't always
+ // broadcast that flag promptly — the body keeps falling
+ // under gravity and visibly disappears into the ground until
+ // the next non-stop UP arrives (e.g. when the player turns).
+ // The remote's most recent server-reported Z is an
+ // authoritative ground floor: if our predicted body has
+ // sunk below it by more than half a meter, snap up to it
+ // and clear airborne, mirroring the OnLivePositionUpdated
+ // landing-transition branch. Threshold matches retail's
+ // MIN_DISTANCE_TO_REACH_POSITION-style tolerance.
+ if (rm.Airborne
+ && !float.IsNaN(rm.LastServerZ)
+ && rm.Body.Position.Z < rm.LastServerZ - 0.5f)
+ {
+ rm.Airborne = false;
+ rm.Body.Velocity = System.Numerics.Vector3.Zero;
+ rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity;
+ rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
+ | AcDream.Core.Physics.TransientStateFlags.OnWalkable;
+ rm.Interp.Clear();
+ rm.Body.Position = new System.Numerics.Vector3(
+ rm.Body.Position.X, rm.Body.Position.Y, rm.LastServerZ);
+ }
+
+ // Step 6: speed-overshoot diagnostic (ACDREAM_REMOTE_VEL_DIAG=1).
+ // Compare the sequencer's body-local CurrentVelocity (root motion
+ // we're applying per tick) against the server's effective
+ // broadcast pace ((LastServerPos - PrevServerPos) / Δt). If
+ // |seqVel| significantly exceeds |serverVel|, the body
+ // overshoots between UPs and the InterpolationManager has to
+ // walk it backward each waypoint — visible as 1-Hz blips.
+ // The ratio prints once per remote per ~2 seconds so a moving
+ // remote shows up without flooding the console.
+ if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1"
+ && rm.PrevServerPosTime > 0.0
+ && rm.LastServerPosTime > rm.PrevServerPosTime)
+ {
+ double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
+ if (nowSec - rm.LastVelDiagLogTime > 2.0)
+ {
+ double dtServer = rm.LastServerPosTime - rm.PrevServerPosTime;
+ var serverDelta = rm.LastServerPos - rm.PrevServerPos;
+ float serverSpeed = (float)(serverDelta.Length() / dtServer);
+ float seqSpeed = seqVel.Length();
+ // Only log when the entity is actually moving — skip
+ // idle remotes where both speeds are ~0.
+ if (serverSpeed > 0.1f || seqSpeed > 0.1f)
+ {
+ System.Console.WriteLine(
+ $"[VEL_DIAG] guid={serverGuid:X8} seqSpeed={seqSpeed:F3} m/s "
+ + $"serverSpeed={serverSpeed:F3} m/s "
+ + $"ratio={(serverSpeed > 1e-3f ? seqSpeed / serverSpeed : 0f):F3}");
+ rm.LastVelDiagLogTime = nowSec;
+ }
+ }
+ }
+
ae.Entity.Position = rm.Body.Position;
ae.Entity.Rotation = rm.Body.Orientation;
}
diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs
index a6d57ba..d270f35 100644
--- a/src/AcDream.Core/Physics/AnimationSequencer.cs
+++ b/src/AcDream.Core/Physics/AnimationSequencer.cs
@@ -567,10 +567,15 @@ public sealed class AnimationSequencer
case 0x0D: // TurnRight — clockwise from above = -Z in right-handed.
zomega = -(MathF.PI / 2f) * adjustedSpeed;
break;
- case 0x0E: // TurnLeft — counter-clockwise = +Z. adjust_motion
- // may have remapped 0x0E → 0x0D with negated speed;
- // in that case the negation preserves correct sign.
- zomega = (MathF.PI / 2f) * adjustedSpeed;
+ case 0x0E: // TurnLeft — counter-clockwise = +Z.
+ // adjust_motion above ALREADY remapped 0x0E → 0x0D
+ // with adjustedSpeed = -speedMod, so the same
+ // formula as 0x0D applied to the negated speed
+ // produces the correct +Z (CCW) result. Using a
+ // different sign here would double-negate and
+ // animate a left turn as a right turn — that was
+ // the bug observed before this fix (commit follows).
+ zomega = -(MathF.PI / 2f) * adjustedSpeed;
break;
}
if (zomega != 0f)