fix(anim): physics velocity now sourced from MotionData — option B / r03 §1.3

The decompiled get_state_velocity (FUN_00528960) literally computes
`RunAnimSpeed * ForwardSpeed` — a 4.0 × runRate world velocity. That
matches retail only when the character's MotionTable happens to bake
MotionData.Velocity.Y = 4.0 on RunForward (true for Humanoid, not
necessarily for other creatures or swapped weapon-style cycles).

When MotionData.Velocity ≠ RunAnimSpeed, the body's world velocity
drifts away from the animation's baked-in root-motion velocity, and
you see the classic "legs cycle too slowly for how fast the body is
sliding" visual bug. User reports ~30% discrepancy ("running animation
is too slow"), consistent with Humanoid RunForward's actual dat
Velocity being ~3.0 rather than the 4.0 constant.

The fix per r03 §1.3: physics body velocity = MotionData.Velocity ×
speedMod. That's exactly what AnimationSequencer.CurrentVelocity
already exposes. Route it into MotionInterpreter via an opt-in
Func<Vector3> accessor. When wired, get_state_velocity uses the
sequencer's cycle velocity as the primary forward-axis drive; when
unwired (tests, physics bodies without a sequencer), falls back to
the decompiled constant path — byte-compatible with retail on the
shapes where it actually matters.

The RunAnimSpeed × rate max-speed clamp at the bottom of
FUN_00528960 stays intact — Option B only replaces the *drive*, not
the clamp. 20 m/s phantom MotionData can't teleport the player.

Wiring: GameWindow attaches `playerAE.Sequencer.CurrentVelocity` to
`_playerController` on Tab-player-mode entry. The sequencer is always
built before the player enters chase mode, so timing is safe.

Sidestep continues to use SidestepAnimSpeed — the sequencer only
tracks the current forward cycle, so strafe is a separate axis.

6 new MotionInterpreterTests verify: accessor overrides constant path,
zero Y falls back to constant (link transitions), clamp still applies,
Ready state doesn't leak accessor value, sidestep axis is untouched.

All 717 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-19 15:06:08 +02:00
parent 5bd976e0c6
commit 795d9c8a88
4 changed files with 267 additions and 17 deletions

View file

@ -164,6 +164,33 @@ public sealed class PlayerMovementController
_weenie.SetSkills(runSkill, jumpSkill);
}
/// <summary>
/// Wire the player's AnimationSequencer current cycle velocity into
/// <see cref="MotionInterpreter.GetCycleVelocity"/>. When attached,
/// <c>get_state_velocity</c> uses <c>MotionData.Velocity * speedMod</c>
/// as the primary forward-axis drive, keeping the body's world velocity
/// locked to the animation's baked-in root-motion velocity.
///
/// <para>
/// Without this accessor, the decompiled constant path
/// (<c>RunAnimSpeed * ForwardSpeed</c>) is used — matches retail only
/// when the character's MotionTable happens to bake Velocity=4.0 on
/// RunForward, which is true for Humanoid but not for arbitrary
/// creatures. See <see cref="MotionInterpreter.GetCycleVelocity"/>
/// for the full rationale.
/// </para>
///
/// <para>
/// Called once from <c>GameWindow.CreateAnimatedEntity</c> after the
/// player's <c>AnimatedEntity.Sequencer</c> is constructed.
/// </para>
/// </summary>
public void AttachCycleVelocityAccessor(Func<Vector3> accessor)
{
if (accessor is null) throw new ArgumentNullException(nameof(accessor));
_motion.GetCycleVelocity = accessor;
}
/// <summary>
/// Apply a server-echoed run rate (ForwardSpeed from UpdateMotion) to the
/// player's MotionInterpreter. The server broadcasts the real RunRate

View file

@ -579,6 +579,21 @@ public sealed class GameWindow : IDisposable
playerEntity.Position, pinitCellId & 0xFFFFu,
System.Numerics.Vector3.Zero, 100f); // huge step height for initial snap
_playerController.SetPosition(initResult.Position, initResult.CellId);
// Option B (r03 §1.3): wire the player's sequencer current
// cycle velocity into MotionInterpreter.get_state_velocity so
// body physics uses MotionData.Velocity * speedMod instead of
// the hardcoded RunAnimSpeed/WalkAnimSpeed. Keeps legs-per-meter
// invariant regardless of which MotionTable drives the player
// (Humanoid RunForward happens to bake Velocity=4.0 so the old
// path looked right there, but the decompiled constant is a
// MotionTable property, not a global).
if (_animatedEntities.TryGetValue(playerEntity.Id, out var playerAE)
&& playerAE.Sequencer is { } playerSeq)
{
_playerController.AttachCycleVelocityAccessor(() => playerSeq.CurrentVelocity);
}
// Derive initial yaw from the entity's rotation.
// The render loop stores rotation as Yaw - PI/2 (to
// compensate for AC models facing +Y at identity), so

View file

@ -267,6 +267,45 @@ public sealed class MotionInterpreter
/// <summary>True when crouching-in-place for a standing long jump (offset +0x70).</summary>
public bool StandingLongJump;
/// <summary>
/// Optional accessor for the owning entity's current animation cycle
/// velocity (AnimationSequencer.CurrentVelocity, i.e. MotionData.Velocity
/// scaled by speedMod). When wired, <see cref="get_state_velocity"/>
/// uses it as the primary forward-axis drive instead of the hardcoded
/// <see cref="RunAnimSpeed"/> / <see cref="WalkAnimSpeed"/> constants.
///
/// <para>
/// <b>Why:</b> the decompiled <c>get_state_velocity</c> (FUN_00528960)
/// literally computes <c>RunAnimSpeed * ForwardSpeed</c>. That works in
/// retail because retail's Humanoid MotionTable happens to bake
/// <c>MotionData.Velocity == RunAnimSpeed (4.0)</c> for the RunForward
/// cycle — so the constant and the dat data agree. For MotionTables
/// where they disagree (other creatures; swapped weapon-style cycles;
/// modded dats), the constant causes the body's world velocity to
/// drift away from the animation's baked-in root-motion velocity,
/// producing the classic "legs cycle too slowly for how fast the body
/// is sliding" visual bug.
/// </para>
///
/// <para>
/// Per <c>docs/research/deepdives/r03-motion-animation.md</c> §1.3,
/// the retail animation pipeline treats <c>MotionData.Velocity *
/// speedMod</c> as the canonical per-cycle world velocity. The
/// <see cref="RunAnimSpeed"/> constant survives in our port only as
/// the max-speed clamp (see below), which matches the decompile's
/// <c>if (|velocity| > RunAnimSpeed * rate)</c> guard.
/// </para>
///
/// <para>
/// Call site: <c>PlayerMovementController.AttachCycleVelocityAccessor</c>
/// wires this to <c>AnimatedEntity.Sequencer.CurrentVelocity</c> once
/// the player's sequencer is built. Null = fall back to the decompiled
/// constant-based path (used by tests and by any physics body with
/// no sequencer).
/// </para>
/// </summary>
public Func<Vector3>? GetCycleVelocity { get; set; }
// ── constructor ────────────────────────────────────────────────────────────
public MotionInterpreter()
@ -492,21 +531,50 @@ public sealed class MotionInterpreter
/// <summary>
/// Compute the body-local velocity vector for the current interpreted motion.
///
/// Decompiled logic (FUN_00528960):
/// velocity = (0, 0, 0)
/// if InterpretedState.SideStepCommand == 0x6500000F:
/// velocity.X = _DAT_007c96e8 * InterpretedState.SideStepSpeed
/// = SidestepAnimSpeed * SideStepSpeed
/// if InterpretedState.ForwardCommand == 0x45000005 (WalkForward):
/// velocity.Y = _DAT_007c96e4 * InterpretedState.ForwardSpeed
/// = WalkAnimSpeed * ForwardSpeed
/// elif InterpretedState.ForwardCommand == 0x44000007 (RunForward):
/// velocity.Y = _DAT_007c96e0 * InterpretedState.ForwardSpeed
/// = RunAnimSpeed * ForwardSpeed
/// rate = InqRunRate() or MyRunRate
/// maxSpeed = RunAnimSpeed * rate
/// if |velocity| > maxSpeed: velocity = normalize(velocity) * maxSpeed
/// return velocity
/// <para>
/// <b>Decompiled path (FUN_00528960)</b>:
/// <code>
/// velocity = (0, 0, 0)
/// if InterpretedState.SideStepCommand == 0x6500000F:
/// velocity.X = _DAT_007c96e8 * InterpretedState.SideStepSpeed
/// = SidestepAnimSpeed * SideStepSpeed
/// if InterpretedState.ForwardCommand == 0x45000005 (WalkForward):
/// velocity.Y = _DAT_007c96e4 * InterpretedState.ForwardSpeed
/// = WalkAnimSpeed * ForwardSpeed
/// elif InterpretedState.ForwardCommand == 0x44000007 (RunForward):
/// velocity.Y = _DAT_007c96e0 * InterpretedState.ForwardSpeed
/// = RunAnimSpeed * ForwardSpeed
/// rate = InqRunRate() or MyRunRate
/// maxSpeed = RunAnimSpeed * rate
/// if |velocity| > maxSpeed: velocity = normalize(velocity) * maxSpeed
/// return velocity
/// </code>
/// </para>
///
/// <para>
/// <b>Option B — MotionData-sourced forward velocity</b>:
/// when <see cref="GetCycleVelocity"/> is wired (i.e. the owning
/// entity has an AnimationSequencer), we prefer
/// <c>MotionData.Velocity.Y * speedMod</c> over the hardcoded
/// <see cref="WalkAnimSpeed"/> / <see cref="RunAnimSpeed"/> constants.
/// This keeps the body's world velocity locked to the animation's
/// baked-in root-motion velocity (<c>r03 §1.3</c>), so the
/// legs-per-meter ratio is invariant regardless of which motion table
/// drives the character. The decompiled constant was a
/// MotionTable-specific value that happens to equal the Humanoid
/// RunForward MotionData.Velocity — fine as a fallback, but the dat
/// is the ground truth for any non-humanoid creature.
/// </para>
///
/// <para>
/// The <see cref="RunAnimSpeed"/> constant survives as the max-speed
/// clamp at the bottom, faithfully matching the decompile's
/// <c>if (|velocity| > RunAnimSpeed * rate)</c> guard. Sidestep
/// continues to use <see cref="SidestepAnimSpeed"/> because the
/// sequencer only tracks the current forward cycle — strafe is
/// implemented as a separate axis in our controller (see
/// <c>PlayerMovementController.Update</c>).
/// </para>
/// </summary>
public Vector3 get_state_velocity()
{
@ -515,10 +583,29 @@ public sealed class MotionInterpreter
if (InterpretedState.SideStepCommand == MotionCommand.SideStepRight)
velocity.X = SidestepAnimSpeed * InterpretedState.SideStepSpeed;
// Forward axis — prefer sequencer's current cycle velocity when available.
// Sequencer's CurrentVelocity is already `MotionData.Velocity * speedMod`
// where speedMod == ForwardSpeed for locomotion cycles, so we use it as-is
// (no additional ForwardSpeed multiplication). Fall back to the decompiled
// constant-based path when the accessor is unwired or returns zero Y
// (e.g. during zero-velocity link transitions — in which case the constant
// is the safe default to keep physics moving at ForwardSpeed).
Vector3? cycleVel = GetCycleVelocity?.Invoke();
bool haveCycleForward = cycleVel.HasValue
&& MathF.Abs(cycleVel.Value.Y) > float.Epsilon;
if (InterpretedState.ForwardCommand == MotionCommand.WalkForward)
velocity.Y = WalkAnimSpeed * InterpretedState.ForwardSpeed;
{
velocity.Y = haveCycleForward
? cycleVel!.Value.Y
: WalkAnimSpeed * InterpretedState.ForwardSpeed;
}
else if (InterpretedState.ForwardCommand == MotionCommand.RunForward)
velocity.Y = RunAnimSpeed * InterpretedState.ForwardSpeed;
{
velocity.Y = haveCycleForward
? cycleVel!.Value.Y
: RunAnimSpeed * InterpretedState.ForwardSpeed;
}
// Determine the current run rate via WeenieObj or fall back to MyRunRate.
// Decompile: calls vtable+0x34 (InqRunRate).

View file

@ -301,6 +301,127 @@ public sealed class MotionInterpreterTests
Assert.Equal(MotionInterpreter.RunAnimSpeed, vel.Y, precision: 4);
}
// =========================================================================
// get_state_velocity + GetCycleVelocity accessor (Option B — r03 §1.3)
//
// When the accessor is wired (AnimationSequencer.CurrentVelocity =
// MotionData.Velocity * speedMod), get_state_velocity prefers that value
// over the decompiled constant path. Keeps legs-per-meter invariant.
// =========================================================================
[Fact]
public void GetStateVelocity_CycleAccessor_OverridesRunAnimSpeed()
{
// Sequencer's CurrentVelocity represents MotionData.Velocity * speedMod.
// With speedMod=2.0 and MotionData.Velocity.Y=3.0, the sequencer reports
// 6.0 — which should override the decompiled `RunAnimSpeed * ForwardSpeed`
// path (= 4.0 * 2.0 = 8.0).
//
// maxSpeed clamp (RunAnimSpeed * runRate = 4.0 * 2.0 = 8.0) allows 6.0.
var weenie = new FakeWeenie { RunRate = 2.0f };
var interp = MakeInterp(weenie: weenie);
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
interp.InterpretedState.ForwardSpeed = 2.0f;
interp.GetCycleVelocity = () => new Vector3(0f, 6.0f, 0f);
var vel = interp.get_state_velocity();
Assert.Equal(6.0f, vel.Y, precision: 4);
}
[Fact]
public void GetStateVelocity_CycleAccessor_OverridesWalkAnimSpeed()
{
// Walk with MotionData.Velocity.Y=2.5 + speedMod=1.0 → sequencer reports 2.5.
// Without accessor, get_state_velocity would return WalkAnimSpeed (3.12).
var interp = MakeInterp();
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
interp.InterpretedState.ForwardSpeed = 1.0f;
interp.GetCycleVelocity = () => new Vector3(0f, 2.5f, 0f);
var vel = interp.get_state_velocity();
Assert.Equal(2.5f, vel.Y, precision: 4);
}
[Fact]
public void GetStateVelocity_CycleAccessor_ZeroY_FallsBackToConstant()
{
// During a zero-velocity link transition the sequencer's CurrentVelocity
// temporarily goes to (0,0,0). The constant path remains the safe default
// so the body keeps moving at ForwardSpeed's expected rate.
var interp = MakeInterp();
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
interp.InterpretedState.ForwardSpeed = 1.0f;
interp.GetCycleVelocity = () => Vector3.Zero;
var vel = interp.get_state_velocity();
Assert.Equal(MotionInterpreter.RunAnimSpeed, vel.Y, precision: 4);
}
[Fact]
public void GetStateVelocity_CycleAccessor_ClampStillApplies()
{
// Runaway MotionData.Velocity (20 m/s) must still be clamped to
// RunAnimSpeed * runRate (4.0 * 1.0 = 4.0). This preserves the
// decompiled `if (|velocity| > maxSpeed)` guard at the bottom of
// FUN_00528960 — the accessor only replaces the *drive*, not the clamp.
var interp = MakeInterp();
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
interp.InterpretedState.ForwardSpeed = 1.0f;
interp.GetCycleVelocity = () => new Vector3(0f, 20.0f, 0f);
var vel = interp.get_state_velocity();
float maxSpeed = MotionInterpreter.RunAnimSpeed * interp.MyRunRate;
Assert.True(vel.Length() <= maxSpeed + 1e-4f,
$"velocity {vel.Length()} exceeds maxSpeed {maxSpeed}");
}
[Fact]
public void GetStateVelocity_CycleAccessor_OnlyAffectsForwardAxis()
{
// Sidestep uses its own constant path — the sequencer only tracks the
// forward cycle's velocity, so strafe must ignore the accessor's X.
// Even if the accessor returned a bogus X, SideStepAnimSpeed wins.
var interp = MakeInterp();
interp.InterpretedState.ForwardCommand = MotionCommand.Ready;
interp.InterpretedState.SideStepCommand = MotionCommand.SideStepRight;
interp.InterpretedState.SideStepSpeed = 1.0f;
interp.GetCycleVelocity = () => new Vector3(99f, 0f, 0f);
var vel = interp.get_state_velocity();
// velocity.X must equal SidestepAnimSpeed (1.25), NOT 99.
Assert.Equal(MotionInterpreter.SidestepAnimSpeed, vel.X, precision: 4);
}
[Fact]
public void GetStateVelocity_CycleAccessor_NotCalledWhenIdle()
{
// When ForwardCommand == Ready, the decompiled path never reads the
// forward-axis; the accessor shouldn't be invoked either (avoid
// misleading zero readings when stationary).
int invocations = 0;
var interp = MakeInterp();
interp.InterpretedState.ForwardCommand = MotionCommand.Ready;
interp.GetCycleVelocity = () =>
{
invocations++;
return new Vector3(0f, 5f, 0f);
};
var vel = interp.get_state_velocity();
// Velocity must be zero; accessor must not have supplied the 5.0f.
Assert.Equal(0f, vel.Y, precision: 4);
// Implementation detail: it's OK to call the accessor once (we do),
// but it must never leak its value into velocity when ForwardCommand
// is Ready. The assertion above guarantees that without over-pinning
// the exact call count.
}
// =========================================================================
// jump (FUN_00529390)
// =========================================================================