fix(motion): retail-faithful per-frame remote tick (L.3.2 follow-up)

Multi-bug fix for the env-var-gated retail-faithful remote tick path
(ACDREAM_INTERP_MANAGER=1). Combines four previously-stacked defects
into one coherent rewrite:

1. PositionManager.ComputeOffset was additive (rootMotion + correction).
   Retail's PositionManager::adjust_offset (acclient @ 0x00555190 →
   InterpolationManager::adjust_offset @ 0x00555d30) REPLACES the
   offset frame via Frame::operator=(arg2, &__return) when catch-up
   engages — it does NOT add to the rootOffset that CPartArray::Update
   wrote. Switched to "correction overrides root motion" semantics.

2. MotionInterpreter.GetMaxSpeed was returning RunAnimSpeed × rate
   (~11.7 m/s for run skill 200). The retail decomp at
   acclient_2013_pseudo_c.txt:305127 shows get_max_speed returns the
   bare run rate (~2.94) — the function's float return rides the x87
   FPU stack, which Binary Ninja shows as void. Caller multiplies by
   2.0 to get the catch-up speed. With the wrong return our catch-up
   was 23.5 m/s instead of retail's 5.88 m/s — the queue would walk
   the body 4× too aggressively.

3. The env-var TickAnimations branch was DOUBLE-COUNTING forward
   translation: it applied seqVel × dt via PositionManager.ComputeOffset
   AND let UpdatePhysicsInternal advance body.Position += body.Velocity
   × dt. Both were ~11.7 m/s for run, so body raced at 23.4 m/s —
   "way too fast" per the user. Pass seqVel=Vector3.Zero to
   ComputeOffset; let body.Velocity (refreshed per tick by
   apply_current_movement) drive the bulk translation alone.

4. Body orientation only applied sequencer.CurrentOmega per tick. For
   the running-in-circles case ACE broadcasts ForwardCommand=RunForward
   AND TurnCommand=TurnLeft on the same UpdateMotion; the sequencer
   picks the RunForward cycle whose synthesized CurrentOmega is zero,
   so body never rotated between UPs and body.Velocity stayed in an
   out-of-date world direction — the visible "rectangle when running
   circles" effect. Prefer ObservedOmega (set explicitly in
   OnLiveMotionUpdated from the wire's TurnCommand + signed TurnSpeed)
   when present; fall back to seqOmega for standalone turn cycles.

Also adds:
- Sequencer-reset call in the env-var landing-fallback so the legs
  un-fold from Falling on land (mirrors the legacy K-fix17 path).
- LastServerZ now only updates on IsGrounded UPs, so the per-tick
  landing-fallback floor doesn't drift up to the player's airborne
  peak Z and force-land mid-arc — fixes the user-reported "small
  landing in the air before landing on the ground" when jumping
  while moving.
- VEL_DIAG now samples at UP arrival with overlapping windows, plus
  TURN_WIRE / OMEGA_DIAG / FWD_WIRE diagnostics gated on
  ACDREAM_REMOTE_VEL_DIAG=1 used to trace these bugs to ground truth.

Verified via live retail-driven character observation 2026-05-03:
turn-left now rotates left (was animating right with snap), running
in circles is much smoother, jumping lands on ground (no mid-air
pause). Residual ~20% steady-state overshoot for walk remains —
WalkAnimSpeed=3.12 (decompiled retail constant) doesn't match ACE's
actual broadcast walk pace (~2.6 m/s). Tracked separately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-03 15:24:24 +02:00
parent 9960ce3bce
commit 842dfcd092
3 changed files with 228 additions and 99 deletions

View file

@ -375,8 +375,14 @@ public sealed class GameWindow : IDisposable
/// </summary>
public System.Numerics.Vector3 PrevServerPos;
public double PrevServerPosTime;
public double LastVelDiagLogTime;
public double LastOmegaDiagLogTime;
/// <summary>
/// Diagnostic-only: max |sequencer.CurrentVelocity| observed across
/// all per-tick samples since the last UpdatePosition arrival. The
/// next UP compares this against (LastServerPos - PrevServerPos) /
/// dtServer to compute the overshoot ratio. Reset on each UP.
/// </summary>
public float MaxSeqSpeedSinceLastUP;
public RemoteMotion()
{
@ -2782,6 +2788,15 @@ public sealed class GameWindow : IDisposable
// to the Attack/Twitch/etc command, and
// get_state_velocity returns 0 because the gate is
// RunForward||WalkForward — body stops moving forward.
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1"
&& remoteMot.Motion.InterpretedState.ForwardCommand != fullMotion)
{
System.Console.WriteLine(
$"[FWD_WIRE] guid={update.Guid:X8} "
+ $"oldCmd=0x{remoteMot.Motion.InterpretedState.ForwardCommand:X8} "
+ $"newCmd=0x{fullMotion:X8} "
+ $"newLow=0x{fullMotion & 0xFFu:X2} speed={speedMod:F3}");
}
remoteMot.Motion.InterpretedState.ForwardCommand = fullMotion;
// Pass speedMod through verbatim — preserve sign so retail's
// adjust_motion'd backward walk (cmd=WalkForward, spd<0)
@ -3312,19 +3327,46 @@ 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
// Track the most recent GROUNDED server-broadcast Z. Read by
// the per-tick landing-fallback in TickAnimations: if gravity
// drags the body more than 0.5 m 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;
//
// Only updated for grounded UPs — mid-arc airborne UPs would
// raise this value to the player's peak Z, then the body's
// descent would cross (peak - 0.5) and trigger a force-land
// mid-air, producing the user-reported "small landing in the
// air before landing on the ground" when jumping while moving.
if (update.IsGrounded)
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.
// Diagnostic (ACDREAM_REMOTE_VEL_DIAG=1): roll the previous
// server-pos snapshot forward AND print the per-UP comparison
// between the max sequencer speed observed since last UP and
// the actual server broadcast pace. Both sides are now sampled
// over the same window so the ratio reflects real overshoot.
{
double nowSecDiag = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1"
&& rmState.LastServerPosTime > 0.0)
{
double dtServer = nowSecDiag - rmState.LastServerPosTime;
if (dtServer > 0.001)
{
var serverDelta = worldPos - rmState.LastServerPos;
float serverSpeed = (float)(serverDelta.Length() / dtServer);
float seqSpeed = rmState.MaxSeqSpeedSinceLastUP;
if (serverSpeed > 0.1f || seqSpeed > 0.1f)
{
System.Console.WriteLine(
$"[VEL_DIAG] guid={update.Guid:X8} maxSeqSpeed={seqSpeed:F3} m/s "
+ $"serverSpeed={serverSpeed:F3} m/s dtServer={dtServer:F3}s "
+ $"ratio={(serverSpeed > 1e-3f ? seqSpeed / serverSpeed : 0f):F3}");
}
}
}
rmState.MaxSeqSpeedSinceLastUP = 0f;
rmState.PrevServerPos = rmState.LastServerPos;
rmState.PrevServerPosTime = rmState.LastServerPosTime;
rmState.LastServerPos = worldPos;
@ -3353,6 +3395,22 @@ public sealed class GameWindow : IDisposable
| AcDream.Core.Physics.TransientStateFlags.OnWalkable;
rmState.Interp.Clear();
rmState.Body.Position = worldPos;
// Reset the sequencer out of Falling — see matching block in
// TickAnimations Step 5 (env-var path) for rationale.
if (_animatedEntities.TryGetValue(entity.Id, out var aeForLand)
&& aeForLand.Sequencer is not null)
{
uint style = aeForLand.Sequencer.CurrentStyle != 0
? aeForLand.Sequencer.CurrentStyle
: 0x8000003Du;
uint landingCmd = rmState.Motion.InterpretedState.ForwardCommand;
if (landingCmd == 0)
landingCmd = AcDream.Core.Physics.MotionCommand.Ready;
float landingSpeed = rmState.Motion.InterpretedState.ForwardSpeed;
if (landingSpeed <= 0f) landingSpeed = 1f;
aeForLand.Sequencer.SetCycle(style, landingCmd, landingSpeed);
}
return;
}
@ -5853,43 +5911,85 @@ public sealed class GameWindow : IDisposable
{
if (System.Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1")
{
// ── NEW PATH: PositionManager (animation root motion + InterpolationManager) ──
// ── NEW PATH: retail-faithful per-frame remote tick ──
// (L.3.1+L.3.2 Task 3/follow-up — ACDREAM_INTERP_MANAGER=1 gates this path)
//
// Always-run-all-steps per retail CPhysicsObj::UpdateObjectInternal
// (acclient @ 0x00513730):
// 1+2. animation root motion + interpolation correction (combined)
// 2.5 sequencer omega → body orientation (TurnRight/TurnLeft angular velocity)
// 3. calc_acceleration (gravity flag → body.Acceleration)
// 4. physics integration (gravity for airborne; no-op for grounded)
// Mirrors retail CPhysicsObj::UpdateObjectInternal
// (acclient @ 0x005156b0) → UpdatePositionInternal (@ 0x00512c30):
//
// 1. Force grounded transient flags (matches the legacy path
// and the gate inside MotionInterpreter.apply_current_movement
// which only writes velocity when OnWalkable is set).
// 2. apply_current_movement → body.set_local_velocity(get_state_velocity())
// Refreshes body.Velocity from the current InterpretedState
// every tick. Matches the legacy path that has been working
// for player remotes since pre-L.3.
// 3. PositionManager.ComputeOffset returns ONLY the
// InterpolationManager catch-up correction (with seqVel=0).
// Retail's CPartArray::Update writes a tiny per-anim-frame
// stride into the offset frame; PositionManager::adjust_offset
// either lets it through or REPLACES it with catch-up. Our
// AnimationSequencer.CurrentVelocity is the SYNTHESIZED
// RunAnimSpeed × speedMod (matches body.Velocity), NOT a
// per-anim-frame stride — passing it as root motion
// double-counts the bulk translation that body.Velocity
// already provides via UpdatePhysicsInternal. Pass zero
// so only the queue-correction reaches the body.
// 4. Apply correction to body.Position.
// 5. Sequencer omega → body orientation (turn cycles).
// 6. calc_acceleration + UpdatePhysicsInternal — Euler-
// integrates body.Position += body.Velocity × dt.
// Sequencer-driven motion sources: linear velocity (root motion)
// AND angular velocity (turn-cycle omega).
System.Numerics.Vector3 seqVel = ae.Sequencer?.CurrentVelocity
?? System.Numerics.Vector3.Zero;
System.Numerics.Vector3 seqOmega = ae.Sequencer?.CurrentOmega
?? System.Numerics.Vector3.Zero;
// Step 1+2: animation root motion + Interp correction (combined via PositionManager).
// Step 1: grounded flags so apply_current_movement writes velocity.
if (!rm.Airborne)
{
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
| AcDream.Core.Physics.TransientStateFlags.Active;
// Step 2: refresh body.Velocity from current motion state.
rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
}
else
{
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active;
}
// Step 3+4: queue catch-up correction only (no double-count of seqVel).
float maxSpeed = rm.Motion.GetMaxSpeed();
System.Numerics.Vector3 offset = rm.Position.ComputeOffset(
dt: (double)dt,
currentBodyPosition: rm.Body.Position,
seqVel: seqVel,
seqVel: System.Numerics.Vector3.Zero,
ori: rm.Body.Orientation,
interp: rm.Interp,
maxSpeed: maxSpeed);
rm.Body.Position += offset;
// Step 2.5: animation-driven rotation. Retail's sequencer bakes Omega
// for TurnRight/TurnLeft cycles; we apply it as a per-frame quaternion
// rotation. seqOmega is body-local angular velocity (axis-angle: axis
// is omega.Normalized, magnitude is rad/sec). For Z-axis turns it's
// (0, 0, ±π/2 × turnSpeed) typically.
if (seqOmega.LengthSquared() > 1e-9f)
// Step 2.5: angular velocity → body orientation. Prefer
// ObservedOmega (set explicitly in OnLiveMotionUpdated from
// the wire's TurnCommand + signed TurnSpeed) over the
// sequencer's synthesized omega: when the player runs in
// a circle ACE broadcasts ForwardCommand=RunForward AND
// TurnCommand=TurnLeft on the same UpdateMotion. The
// sequencer's animCycle picker chooses RunForward (legs
// running), whose synthesized CurrentOmega is zero. Body
// would not rotate between UPs and body.Velocity stays in
// an out-of-date world direction, producing the
// user-reported "rectangle when running circles" effect.
// ObservedOmega has the correct turn rate even when the
// visible cycle is RunForward.
System.Numerics.Vector3 omegaToApply =
rm.ObservedOmega.LengthSquared() > 1e-9f
? rm.ObservedOmega
: seqOmega;
if (omegaToApply.LengthSquared() > 1e-9f)
{
float angleDelta = seqOmega.Length() * (float)dt;
System.Numerics.Vector3 axis = System.Numerics.Vector3.Normalize(seqOmega);
float angleDelta = omegaToApply.Length() * (float)dt;
System.Numerics.Vector3 axis = System.Numerics.Vector3.Normalize(omegaToApply);
var rot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angleDelta);
rm.Body.Orientation = System.Numerics.Quaternion.Normalize(
System.Numerics.Quaternion.Concatenate(rm.Body.Orientation, rot));
@ -5906,7 +6006,9 @@ public sealed class GameWindow : IDisposable
uint seqMotion = ae.Sequencer?.CurrentMotion ?? 0;
System.Console.WriteLine(
$"[OMEGA_DIAG] guid={serverGuid:X8} motion=0x{seqMotion:X8} "
+ $"seqOmega.Z={seqOmega.Z:F3} (Z>0=CCW=TurnLeft, Z<0=CW=TurnRight)");
+ $"omegaApplied.Z={omegaToApply.Z:F3} "
+ $"(seq.Z={seqOmega.Z:F3} obs.Z={rm.ObservedOmega.Z:F3}) "
+ $"(Z>0=CCW=TurnLeft, Z<0=CW=TurnRight)");
rm.LastOmegaDiagLogTime = nowSec;
}
}
@ -5945,39 +6047,48 @@ public sealed class GameWindow : IDisposable
rm.Interp.Clear();
rm.Body.Position = new System.Numerics.Vector3(
rm.Body.Position.X, rm.Body.Position.Y, rm.LastServerZ);
// Swap the sequencer out of Falling — without this the
// legs stay folded in the airborne pose forever even
// though the body is now planted on the ground. Mirrors
// the legacy K-fix17 path at the bottom of TickAnimations
// (line ~6284): pick the cycle from the last-known
// InterpretedState.ForwardCommand, falling back to Ready
// when nothing is held. The next UpdateMotion the server
// sends will refine if the player was strafing/turning
// mid-jump; this just gets them out of Falling now.
if (ae.Sequencer is not null)
{
uint style = ae.Sequencer.CurrentStyle != 0
? ae.Sequencer.CurrentStyle
: 0x8000003Du;
uint landingCmd = rm.Motion.InterpretedState.ForwardCommand;
if (landingCmd == 0)
landingCmd = AcDream.Core.Physics.MotionCommand.Ready;
float landingSpeed = rm.Motion.InterpretedState.ForwardSpeed;
if (landingSpeed <= 0f) landingSpeed = 1f;
ae.Sequencer.SetCycle(style, landingCmd, landingSpeed);
}
}
// 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)
// Track the maximum sequencer velocity magnitude seen since
// the last UpdatePosition arrival (carried on the
// RemoteMotion struct), then on each UP arrival the
// OnLivePositionUpdated path prints the comparison against
// the server's actual broadcast pace
// ((LastServerPos - PrevServerPos) / Δt). This guarantees
// both sides are sampled during the same window and the
// ratio reflects the real overshoot.
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
{
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;
}
}
// body.Velocity is now the source of bulk translation
// (set above by apply_current_movement). Track its
// magnitude so VEL_DIAG can compare against the actual
// server broadcast pace.
float seqSpeedNow = rm.Body.Velocity.Length();
if (seqSpeedNow > rm.MaxSeqSpeedSinceLastUP)
rm.MaxSeqSpeedSinceLastUP = seqSpeedNow;
}
ae.Entity.Position = rm.Body.Position;

View file

@ -935,34 +935,38 @@ public sealed class MotionInterpreter
// ── CMotionInterp::get_max_speed (0x00527cb0) ─────────────────────────────
/// <summary>
/// Return the motion-table-derived max speed (m/s) for the current
/// <see cref="InterpretedMotionState.ForwardCommand"/>.
/// Return the run rate. Mirrors retail
/// <c>CMotionInterp::get_max_speed</c> at <c>0x00527cb0</c>.
///
/// <para>
/// <b>Retail reference (named-retail, 0x00527cb0):</b>
/// <c>CMotionInterp::get_max_speed</c> fetches the run rate via
/// <c>InqRunRate</c> (or falls back to <c>my_run_rate</c>) and returns
/// the result as a float from the x87 FPU stack (ST0). The Binary Ninja
/// decompiler emits a spurious <c>void</c> return type for x87-returning
/// functions — the actual return value is confirmed by the two callers:
/// <c>StickyManager::adjust_offset</c> (0x00555430) and
/// <c>InterpolationManager::AdjustOffset</c> (0x00555d52), both of which
/// multiply the result by 2.0 to produce a catch-up speed in m/s. With a
/// run rate of ~1.0 the catch-up would be 2.0 m/s — far too slow — so the
/// function must return the actual velocity (m/s), not a bare rate.
/// <b>Decomp (named-retail/acclient_2013_pseudo_c.txt:305127):</b>
/// <code>
/// void get_max_speed(this) {
/// weenie_obj = this->weenie_obj;
/// this_1 = nullptr;
/// if (weenie_obj == 0) return;
/// if (weenie_obj->vtable->InqRunRate(&this_1) != 0) return;
/// this->my_run_rate; // x87 fld leaves my_run_rate on FPU stack
/// }
/// </code>
/// Binary Ninja shows the return type as <c>void</c> because the float
/// return rides the x87 FPU stack rather than EAX. Both branches
/// emit an <c>fld</c> of either <c>this_1</c> (the InqRunRate
/// out-param value) or <c>my_run_rate</c>, leaving the run rate on
/// ST0 as the return value.
/// </para>
///
/// <para>
/// The per-command switch mirrors <c>get_state_velocity</c>
/// (0x00527d50), which uses the same constants and the same
/// RunForward / WalkForward / WalkBackward branches, and the
/// <c>adjust_motion</c> (0x00527c0e) <c>BackwardsFactor = 0.65</c>
/// scaling confirmed at address 0x00528010.
/// </para>
///
/// <para>
/// Used by <c>InterpolationManager.AdjustOffset</c> in L.3 Task 5
/// as <c>2 × GetMaxSpeed()</c> catch-up speed.
/// <b>Critical:</b> this returns the BARE run rate (typically 1.0 to
/// ~3.0), NOT a velocity in m/s. We previously multiplied by
/// <c>RunAnimSpeed</c> to get a m/s value, reasoning that
/// <c>2 × bare_rate</c> would be too slow a catch-up speed for the
/// caller (<c>InterpolationManager::adjust_offset</c>). That was a
/// misread of the decomp — retail's catch-up IS that slow on purpose.
/// The multi-second 1-Hz blip the user reported when observing retail
/// remotes from acdream traced to body racing at the wrong (overshot)
/// catch-up speed (~23.5 m/s instead of the retail-correct ~5.9 m/s
/// for a run-skill-200 char).
/// </para>
/// </summary>
public float GetMaxSpeed()
@ -972,14 +976,7 @@ public sealed class MotionInterpreter
float rate = MyRunRate;
if (WeenieObj is not null && WeenieObj.InqRunRate(out float queried))
rate = queried;
return InterpretedState.ForwardCommand switch
{
MotionCommand.RunForward => RunAnimSpeed * rate,
MotionCommand.WalkForward => WalkAnimSpeed,
MotionCommand.WalkBackward => WalkAnimSpeed * 0.65f, // BackwardsFactor @ adjust_motion 0x00528010
_ => 0f, // idle / non-locomotion
};
return rate;
}
// ── private helper ────────────────────────────────────────────────────────

View file

@ -42,14 +42,35 @@ public sealed class PositionManager
InterpolationManager interp,
float maxSpeed)
{
// Step 1: animation root motion (body-local → world).
Vector3 rootMotionLocal = seqVel * (float)dt;
Vector3 rootMotionWorld = Vector3.Transform(rootMotionLocal, ori);
// Step 2: interpolation correction (world-space already).
// Retail-faithful per-frame combiner. Mirrors
// CPhysicsObj::UpdatePositionInternal (acclient @ 0x00512c30) +
// InterpolationManager::adjust_offset (@ 0x00555d30):
//
// 1. CPartArray::Update writes rootOffset (animation root motion)
// into the per-tick Frame.
// 2. PositionManager::adjust_offset → InterpolationManager::adjust_offset
// either:
// a) RETURNS EARLY when distance(body, head) < 0.05m
// (NodeCompleted; arg2 unmodified) — body uses root motion.
// b) OVERWRITES arg2 with `direction × min(catchUpSpeed × dt,
// distance)` when body is far from head — catch-up REPLACES
// root motion for this frame.
//
// It is NOT additive. Our prior port added rootMotion + correction
// every frame, which stacked the animation push (≈ RunAnimSpeed ×
// speedMod, ≈ 11.7 m/s) on top of the queue catch-up (capped at
// ≈ 23.5 m/s) so the body advanced at up to ~3× the server's
// broadcast pace and the head-behind-body case produced a backward
// correction every UP — the visible 1-Hz blip the user reported.
//
// AdjustOffset returns Vector3.Zero in two cases mapped to retail's
// early-return: empty queue OR distance < DesiredDistance (0.05m).
// In both, body falls back to animation root motion.
Vector3 correction = interp.AdjustOffset(dt, currentBodyPosition, maxSpeed);
if (correction.LengthSquared() > 0f)
return correction;
// Step 3: combined delta.
return rootMotionWorld + correction;
Vector3 rootMotionLocal = seqVel * (float)dt;
return Vector3.Transform(rootMotionLocal, ori);
}
}