fix(motion): retail-faithful remote tick — clear body.Velocity, drive via seqVel

Replaces the hybrid that double-counted forward translation:
predicted body.Velocity (set per-tick by apply_current_movement) +
the seqVel-derived offset both pushed the remote body forward at
~11.7 m/s × dt for run, summing to ~23.4 m/s × dt — the user's
"way too fast" + 1-Hz blip.

Per the named-retail decomp investigation 2026-05-03 (research agent
report dispatched against acclient_2013_pseudo_c.txt for
CSequence::update + UpdatePositionInternal + UpdateObjectInternal +
adjust_offset, line citations in the env-var path comments):

  CPhysicsObj::UpdateObjectInternal (0x005156b0)
  → UpdatePositionInternal (0x00512c30)
    → CPartArray::Update (writes anim root motion into the offset frame)
    → PositionManager::adjust_offset (REPLACES the offset with catch-up
      when the body is far from the queue head; otherwise leaves the
      anim root motion alone — Frame::operator=(arg2, &__return)
      semantics, NOT additive)
    → Frame::combine (out = m_position + offset)
    → UpdatePhysicsInternal (out += body.Velocity × dt + 0.5·accel·dt²)

For a remote in steady-state RunForward where the server hasn't pushed
an explicit velocity, m_velocityVector ≈ 0 and ALL per-tick translation
comes from the animation root motion (CSequence::update_internal +
Frame::combine of crossed pos_frames keyframes). Our port doesn't
extract per-keyframe pos_frames from the .anm assets; instead
AnimationSequencer.CurrentVelocity is the synthesized equivalent
(RunAnimSpeed × ForwardSpeed averaged), passed through
PositionManager.ComputeOffset.

Concrete changes in the env-var (ACDREAM_INTERP_MANAGER=1) path:

* Pass seqVel = ae.Sequencer.CurrentVelocity to ComputeOffset (was
  Vector3.Zero — that disabled the animation-root-motion source and
  left only the queue catch-up to drive translation, which lagged
  server pace).
* Clear rm.Body.Velocity to Vector3.Zero for grounded remotes each
  tick. Mirrors retail's m_velocityVector ≈ 0 for remotes; prevents
  UpdatePhysicsInternal from adding a second 11.7 m/s × dt on top of
  the seqVel-driven translation.
* Stop calling apply_current_movement per tick. Retail only calls it
  on motion-state changes (per cdb traces from the L.5 investigation),
  not per physics tick. body.Velocity-based translation is now the
  AIRBORNE-only path (gravity integration during jumps).

Also reverts an unacceptable "scaling hack" (per-tick body.Velocity
scaled by observed serverSpeed/predictedSpeed) the user explicitly
rejected as patching over an unsolved structural problem.

GetMaxSpeed reverted to RunAnimSpeed × rate (matches ACE
MotionInterp.cs:670-678; the earlier "return bare rate" change came
from a misread of an x87-decompiled get_max_speed where Binary Ninja
showed the return type as void).

AnimationSequencer.SetCycle now ALWAYS overwrites CurrentVelocity for
known locomotion cycles (Walk/WalkBackward/Run/SideStepRight/
SideStepLeft) instead of gating on `CurrentVelocity.LengthSquared() <
1e-9f`. The gate was correct for non-locomotion entities with
dat-baked HasVelocity, but for Humanoid where the dat is silent and
the only thing that could set CurrentVelocity before synthesis was a
transition link's HasVelocity flag, the gate would silently leave the
body advancing at the link's velocity instead of the cycle's intended
steady-state.

Adds wire-arrival diagnostics gated on ACDREAM_REMOTE_VEL_DIAG=1
(SETCYCLE, FWD_WIRE) used to trace the bug to ground truth.

User-confirmed improvements vs prior state:
- Steady-state run no longer "way too fast"
- Run-in-circles smoother (rectangle effect gone)
- Jump landing in correct location
- Turn-left visibly turns left

Outstanding (not addressed by this commit, deferred for next
investigation): walk↔run direct transitions don't visibly switch the
animation cycle until the next motion event fires. Both legacy and
new paths exhibit the same behavior, so the bug lives in the
SetCycle queue manipulation pipeline shared by both — not in the
per-tick translation path that this commit revises. Wire trace
confirms ACE delivers the WalkForward → RunForward transition
correctly and SetCycle does fire for it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-03 16:23:57 +02:00
parent 842dfcd092
commit a45c21ee51
3 changed files with 114 additions and 51 deletions

View file

@ -2788,14 +2788,23 @@ 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)
if (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}");
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
{
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}");
}
// Motion command changed — invalidate observed-velocity
// history so the per-tick scaling in TickAnimations
// doesn't reuse a stale ratio derived from the OLD
// motion (e.g. carrying run-pace serverSpeed into the
// first walk frame, which would briefly accelerate
// walk to run pace before settling).
remoteMot.PrevServerPosTime = 0.0;
}
remoteMot.Motion.InterpretedState.ForwardCommand = fullMotion;
// Pass speedMod through verbatim — preserve sign so retail's
@ -2958,7 +2967,18 @@ public sealed class GameWindow : IDisposable
}
}
if (cycleToPlay != 0)
{
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1"
&& (ae.Sequencer.CurrentMotion != cycleToPlay
|| MathF.Abs(ae.Sequencer.CurrentSpeedMod - animSpeed) > 1e-3f))
{
System.Console.WriteLine(
$"[SETCYCLE] guid={update.Guid:X8} "
+ $"old=(motion=0x{ae.Sequencer.CurrentMotion:X8} speed={ae.Sequencer.CurrentSpeedMod:F3}) "
+ $"new=(motion=0x{cycleToPlay:X8} speed={animSpeed:F3})");
}
ae.Sequencer.SetCycle(fullStyle, cycleToPlay, animSpeed);
}
}
// Retail runs the full MotionInterp state machine on every
@ -5914,56 +5934,84 @@ public sealed class GameWindow : IDisposable
// ── 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)
//
// Mirrors retail CPhysicsObj::UpdateObjectInternal
// (acclient @ 0x005156b0) → UpdatePositionInternal (@ 0x00512c30):
// Per retail's CPhysicsObj::UpdateObjectInternal (0x005156b0)
// → UpdatePositionInternal (0x00512c30) → CSequence::update
// chain (decomp investigation 2026-05-03):
//
// 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.
// For a REMOTE entity (not local player), per physics tick
// the world-position advance is the sum of:
// A) animation root motion accumulated by
// update_internal (Frame::combine of crossed
// per-keyframe pos_frames deltas) OR replaced by
// InterpolationManager::adjust_offset's catch-up
// when the body is far from the queue head.
// B) body.Velocity × dt + 0.5 × accel × dt²
// (UpdatePhysicsInternal). For remotes, retail does
// NOT call apply_current_movement per tick — body.
// Velocity stays at whatever the last
// InterpolationManager type-3 ("set velocity") node
// set it to (typically zero unless the server is
// explicitly pushing velocity via VectorUpdate).
//
// So for normal grounded run/walk/strafe with no server-
// pushed velocity, ALL per-tick translation comes from (A).
//
// Acdream port mapping:
// - We don't extract per-keyframe pos_frames from the .anm
// assets. Our AnimationSequencer.CurrentVelocity is the
// synthesized equivalent (RunAnimSpeed × ForwardSpeed)
// which averages to the same effective body translation.
// - Pass it as seqVel to ComputeOffset so the
// animation-root-motion path drives body translation.
// - DO NOT call apply_current_movement per tick — that
// would set body.Velocity to RunAnimSpeed × ForwardSpeed,
// and UpdatePhysicsInternal would then add ANOTHER
// 11.7 m/s × dt on top of the seqVel motion already
// applied by ComputeOffset, producing 2× server pace
// (the user-reported "way too fast" + 1-Hz blip from
// the catch-up walking back the overshoot).
// - body.Velocity stays at 0 for grounded remotes; non-
// zero only when OnLiveVectorUpdated set it (jump
// start) — UpdatePhysicsInternal then integrates
// gravity for the airborne arc.
System.Numerics.Vector3 seqVel = ae.Sequencer?.CurrentVelocity
?? System.Numerics.Vector3.Zero;
System.Numerics.Vector3 seqOmega = ae.Sequencer?.CurrentOmega
?? System.Numerics.Vector3.Zero;
// Step 1: grounded flags so apply_current_movement writes velocity.
// Step 1: transient flags (Contact + OnWalkable for grounded;
// Active always so UpdatePhysicsInternal doesn't early-return).
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);
// For grounded remotes the body should not be carrying
// velocity — retail's m_velocityVector for a remote is
// 0 unless the server explicitly pushed one. Clear any
// stale velocity from a prior airborne arc so
// UpdatePhysicsInternal doesn't double-apply it on top
// of the seqVel-driven ComputeOffset translation below.
rm.Body.Velocity = System.Numerics.Vector3.Zero;
}
else
{
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active;
}
// Step 3+4: queue catch-up correction only (no double-count of seqVel).
// Step 2: per-frame body translation. ComputeOffset returns
// either the queue catch-up (when active) or the animation
// root motion (seqVel × dt rotated to world). REPLACE
// semantics — retail's PositionManager::adjust_offset
// overwrites the offset frame with the catch-up direction,
// not adding to it.
float maxSpeed = rm.Motion.GetMaxSpeed();
System.Numerics.Vector3 offset = rm.Position.ComputeOffset(
dt: (double)dt,
currentBodyPosition: rm.Body.Position,
seqVel: System.Numerics.Vector3.Zero,
seqVel: seqVel,
ori: rm.Body.Orientation,
interp: rm.Interp,
maxSpeed: maxSpeed);

View file

@ -510,42 +510,52 @@ public sealed class AnimationSequencer
// decompiled from _DAT_007c96e0/e4/e8. The velocity is body-local
// (+Y = forward, +X = right); consumers rotate into world space via
// the owning entity's orientation.
if (CurrentVelocity.LengthSquared() < 1e-9f)
// For known locomotion cycles, ALWAYS overwrite CurrentVelocity with
// the synthesized value — even if the transition link set
// CurrentVelocity from its own HasVelocity flag. The link's velocity
// is for the brief transition (e.g. small stride into run-pose); the
// cycle's intended steady-state velocity is what consumers (remote
// body translation in GameWindow.TickAnimations env-var path) need.
// Without this, walking-to-running transitions left CurrentVelocity
// at the link's slow pace, and the user reported "it just blips
// forward walking" until another motion command (turn, etc) forced
// a re-synth. The gate that previously read
// `if (CurrentVelocity.LengthSquared() < 1e-9f)` allowed dat-baked
// velocity to win over synthesis — which is correct for non-
// locomotion (e.g. flying creatures with HasVelocity) but wrong for
// Humanoid run/walk/strafe where the dat is silent and the link
// velocity is the only thing setting it.
{
float yvel = 0f;
float xvel = 0f;
// Low byte of the ORIGINAL (non-adjusted) motion tells us which
// intent the caller signalled. adjust_motion may have remapped
// TurnLeft → TurnRight / SideStepLeft → SideStepRight /
// WalkBackward → WalkForward, encoding the sign into adjustedSpeed.
// The speed sign is preserved in adjustedSpeed so we multiply by
// it rather than re-deriving per-case.
uint low = motion & 0xFFu;
bool isLocomotion = false;
switch (low)
{
case 0x05: // WalkForward
yvel = WalkAnimSpeed * adjustedSpeed;
isLocomotion = true;
break;
case 0x06: // WalkBackward — adjust_motion remapped to WalkForward
// with speedMod *= -0.65f, so adjustedSpeed already
// carries the factor. But the motion arg we see
// here is the original (pre-adjust) 0x06, so we
// still use WalkAnimSpeed — the negative sign of
// adjustedSpeed flips the direction correctly.
// with speedMod *= -0.65f.
yvel = WalkAnimSpeed * adjustedSpeed;
isLocomotion = true;
break;
case 0x07: // RunForward
yvel = RunAnimSpeed * adjustedSpeed;
isLocomotion = true;
break;
case 0x0F: // SideStepRight
xvel = SidestepAnimSpeed * adjustedSpeed;
isLocomotion = true;
break;
case 0x10: // SideStepLeft — remapped to SideStepRight with
// negated speed; same handling as backward walk.
xvel = SidestepAnimSpeed * adjustedSpeed;
isLocomotion = true;
break;
}
if (yvel != 0f || xvel != 0f)
if (isLocomotion)
CurrentVelocity = new Vector3(xvel, yvel, 0f);
}

View file

@ -972,11 +972,16 @@ public sealed class MotionInterpreter
public float GetMaxSpeed()
{
// Resolve current run rate: prefer WeenieObj.InqRunRate, fall back to MyRunRate.
// Mirrors the InqRunRate query at the top of CMotionInterp::get_max_speed.
// Then multiply by RunAnimSpeed (4.0). Matches ACE's MotionInterp.cs:670-678
// which is verified against retail (the ACE MotionInterp file is a
// line-by-line port). Returns the maximum world-space velocity in m/s
// — for run skill 200 with rate ≈ 2.94, this is ≈ 11.76 m/s. Used by
// InterpolationManager.AdjustOffset to compute the catch-up speed
// (= 2 × maxSpeed).
float rate = MyRunRate;
if (WeenieObj is not null && WeenieObj.InqRunRate(out float queried))
rate = queried;
return rate;
return RunAnimSpeed * rate;
}
// ── private helper ────────────────────────────────────────────────────────