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