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

@ -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);
}
}