diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs
index 83fcd96..f533b4c 100644
--- a/src/AcDream.App/Input/PlayerMovementController.cs
+++ b/src/AcDream.App/Input/PlayerMovementController.cs
@@ -164,6 +164,33 @@ public sealed class PlayerMovementController
_weenie.SetSkills(runSkill, jumpSkill);
}
+ ///
+ /// Wire the player's AnimationSequencer current cycle velocity into
+ /// . When attached,
+ /// get_state_velocity uses MotionData.Velocity * speedMod
+ /// as the primary forward-axis drive, keeping the body's world velocity
+ /// locked to the animation's baked-in root-motion velocity.
+ ///
+ ///
+ /// Without this accessor, the decompiled constant path
+ /// (RunAnimSpeed * ForwardSpeed) 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
+ /// for the full rationale.
+ ///
+ ///
+ ///
+ /// Called once from GameWindow.CreateAnimatedEntity after the
+ /// player's AnimatedEntity.Sequencer is constructed.
+ ///
+ ///
+ public void AttachCycleVelocityAccessor(Func accessor)
+ {
+ if (accessor is null) throw new ArgumentNullException(nameof(accessor));
+ _motion.GetCycleVelocity = accessor;
+ }
+
///
/// Apply a server-echoed run rate (ForwardSpeed from UpdateMotion) to the
/// player's MotionInterpreter. The server broadcasts the real RunRate
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 6ba179a..c5453fd 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -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
diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs
index 5b29c65..81d8201 100644
--- a/src/AcDream.Core/Physics/MotionInterpreter.cs
+++ b/src/AcDream.Core/Physics/MotionInterpreter.cs
@@ -267,6 +267,45 @@ public sealed class MotionInterpreter
/// True when crouching-in-place for a standing long jump (offset +0x70).
public bool StandingLongJump;
+ ///
+ /// Optional accessor for the owning entity's current animation cycle
+ /// velocity (AnimationSequencer.CurrentVelocity, i.e. MotionData.Velocity
+ /// scaled by speedMod). When wired,
+ /// uses it as the primary forward-axis drive instead of the hardcoded
+ /// / constants.
+ ///
+ ///
+ /// Why: the decompiled get_state_velocity (FUN_00528960)
+ /// literally computes RunAnimSpeed * ForwardSpeed. That works in
+ /// retail because retail's Humanoid MotionTable happens to bake
+ /// MotionData.Velocity == RunAnimSpeed (4.0) 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.
+ ///
+ ///
+ ///
+ /// Per docs/research/deepdives/r03-motion-animation.md §1.3,
+ /// the retail animation pipeline treats MotionData.Velocity *
+ /// speedMod as the canonical per-cycle world velocity. The
+ /// constant survives in our port only as
+ /// the max-speed clamp (see below), which matches the decompile's
+ /// if (|velocity| > RunAnimSpeed * rate) guard.
+ ///
+ ///
+ ///
+ /// Call site: PlayerMovementController.AttachCycleVelocityAccessor
+ /// wires this to AnimatedEntity.Sequencer.CurrentVelocity 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).
+ ///
+ ///
+ public Func? GetCycleVelocity { get; set; }
+
// ── constructor ────────────────────────────────────────────────────────────
public MotionInterpreter()
@@ -492,21 +531,50 @@ public sealed class MotionInterpreter
///
/// 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
+ ///
+ /// Decompiled path (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
+ ///
+ ///
+ ///
+ ///
+ /// Option B — MotionData-sourced forward velocity:
+ /// when is wired (i.e. the owning
+ /// entity has an AnimationSequencer), we prefer
+ /// MotionData.Velocity.Y * speedMod over the hardcoded
+ /// / constants.
+ /// This keeps the body's world velocity locked to the animation's
+ /// baked-in root-motion velocity (r03 §1.3), 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.
+ ///
+ ///
+ ///
+ /// The constant survives as the max-speed
+ /// clamp at the bottom, faithfully matching the decompile's
+ /// if (|velocity| > RunAnimSpeed * rate) guard. Sidestep
+ /// continues to use because the
+ /// sequencer only tracks the current forward cycle — strafe is
+ /// implemented as a separate axis in our controller (see
+ /// PlayerMovementController.Update).
+ ///
///
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).
diff --git a/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs b/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs
index bc08af7..e41b679 100644
--- a/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs
@@ -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)
// =========================================================================