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
|
|
@ -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