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:
parent
5bd976e0c6
commit
795d9c8a88
4 changed files with 267 additions and 17 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// =========================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue