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) // =========================================================================