using AcDream.Core.Physics; using Xunit; namespace AcDream.Core.Tests.Physics; // ───────────────────────────────────────────────────────────────────────────── // MotionVelocityPipelineTests — Phase D6.2: the full raw→interpreted→velocity // pipeline (apply_raw_movement 0x005287e0 → get_state_velocity 0x00527d50). // // These pin the retail-faithful LOCAL velocity for every direction — the exact // behavior the D6.2 controller integration now routes through, replacing the // hand-mirrored backward/strafe formulas (register TS-22). Golden values are // derived from the pseudocode doc // (docs/research/2026-07-01-d6-motion-interp-pseudocode.md) and the retail // constants on MotionInterpreter. // // Notable retail-faithful change vs the old hand-mirror: strafe is now // 1.25 × (0.5·WalkAnimSpeed/SidestepAnimSpeed) × runRate = ~1.56 × runRate, // clamped via SideStepSpeed ≤ 3.0 (so |v.X| ≤ 3.75). The old code used // 1.25 × runRate with no sidestep-scale and no clamp. // ───────────────────────────────────────────────────────────────────────────── file sealed class FakeRunRateWeenie : IWeenieObject { public float RunRate; public bool InqRunRateResult = true; public bool InqJumpVelocity(float extent, out float vz) { vz = 0f; return false; } public bool InqRunRate(out float rate) { rate = RunRate; return InqRunRateResult; } public bool CanJump(float extent) => true; } public sealed class MotionVelocityPipelineTests { private const float RunRate = 2.75f; // ≈ the +Acdream test char's run rate private const float WalkAnim = MotionInterpreter.WalkAnimSpeed; // 3.11999989 private const float RunAnim = MotionInterpreter.RunAnimSpeed; // 4.0 private const float SideAnim = MotionInterpreter.SidestepAnimSpeed; // 1.25 private const float BackFac = MotionInterpreter.BackwardsFactor; // 0.649999976 private const float MaxSide = MotionInterpreter.MaxSidestepAnimRate;// 3.0 // adjust_motion sidestep scale: 0.5 * (WalkAnim / SideAnim) ≈ 1.24799995 private const float SideScale = MotionInterpreter.SidestepFactor * (WalkAnim / SideAnim); private static MotionInterpreter MakeInterp(float runRate) => new() { WeenieObj = new FakeRunRateWeenie { RunRate = runRate } }; private static RawMotionState Raw( uint forward = MotionCommand.Ready, uint sidestep = 0u, uint turn = 0u, HoldKey hold = HoldKey.None) => new() { CurrentHoldKey = hold, ForwardCommand = forward, ForwardHoldKey = forward != MotionCommand.Ready ? hold : HoldKey.Invalid, ForwardSpeed = 1.0f, SidestepCommand = sidestep, SidestepHoldKey = sidestep != 0u ? hold : HoldKey.Invalid, SidestepSpeed = 1.0f, TurnCommand = turn, TurnHoldKey = turn != 0u ? hold : HoldKey.Invalid, TurnSpeed = 1.0f, }; // ── forward ────────────────────────────────────────────────────────────── [Fact] public void RunForward_VelocityIsRunAnimSpeedTimesRunRate() { var interp = MakeInterp(RunRate); interp.apply_raw_movement(Raw(forward: MotionCommand.WalkForward, hold: HoldKey.Run)); // WalkForward + Run → RunForward @ runRate; v.Y = RunAnim * runRate (== maxSpeed, no clamp). var v = interp.get_state_velocity(); Assert.Equal(RunAnim * RunRate, v.Y, 3); Assert.Equal(0f, v.X, 5); } [Fact] public void WalkForward_NoRun_VelocityIsWalkAnimSpeed() { var interp = MakeInterp(RunRate); interp.apply_raw_movement(Raw(forward: MotionCommand.WalkForward, hold: HoldKey.None)); var v = interp.get_state_velocity(); Assert.Equal(WalkAnim * 1.0f, v.Y, 3); // no run scaling; WalkForward @ 1.0 } // ── backward (the TS-22 fix: no longer zero) ───────────────────────────── [Fact] public void RunBackward_VelocityIsNegativeWalkTimesBackwardFactorTimesRunRate() { var interp = MakeInterp(RunRate); interp.apply_raw_movement(Raw(forward: MotionCommand.WalkBackward, hold: HoldKey.Run)); // WalkBackward → WalkForward, speed *= -0.65; Run → *= runRate (unconditional, // promotion sign-gated so it stays WalkForward). v.Y = WalkAnim * (-0.65 * runRate). var v = interp.get_state_velocity(); Assert.Equal(-(WalkAnim * BackFac * RunRate), v.Y, 3); Assert.True(v.Y < 0f, "backward velocity must be negative"); // Matches the OLD hand-mirror (WalkAnim * 0.65 * runMul) — backward is unchanged. } // ── strafe (retail-faithful magnitude + ±3.0 clamp) ────────────────────── [Fact] public void RunStrafeRight_ClampsSideStepSpeedToThree_VelocityIs3p75() { var interp = MakeInterp(RunRate); // 1.248*2.75 = 3.432 > 3.0 → clamps interp.apply_raw_movement(Raw(sidestep: MotionCommand.SideStepRight, hold: HoldKey.Run)); var v = interp.get_state_velocity(); Assert.Equal(SideAnim * MaxSide, v.X, 3); // 1.25 * 3.0 = 3.75 Assert.Equal(3.75f, v.X, 3); Assert.Equal(0f, v.Y, 5); } [Fact] public void RunStrafeRight_BelowClamp_VelocityIsScaledSidestep() { const float lowRun = 2.0f; // 1.248*2.0 = 2.496 < 3.0 → no clamp var interp = MakeInterp(lowRun); interp.apply_raw_movement(Raw(sidestep: MotionCommand.SideStepRight, hold: HoldKey.Run)); var v = interp.get_state_velocity(); // v.X = SideAnim * (SideScale * lowRun) = 1.56 * lowRun ≈ 3.12 Assert.Equal(SideAnim * SideScale * lowRun, v.X, 3); } [Fact] public void RunStrafeLeft_NegatedAndClamped_VelocityIsNeg3p75() { var interp = MakeInterp(RunRate); interp.apply_raw_movement(Raw(sidestep: MotionCommand.SideStepLeft, hold: HoldKey.Run)); // SideStepLeft → SideStepRight, negated → -1.248; *runRate = -3.432; clamp → -3.0. var v = interp.get_state_velocity(); Assert.Equal(-(SideAnim * MaxSide), v.X, 3); // -3.75 Assert.True(v.X < 0f, "strafe-left velocity must be negative"); } // ── turn (drives the local Yaw omega: base π/2 × TurnSpeed) ─────────────── [Fact] public void RunTurnRight_InterpretedTurnSpeedIsPositiveRunTurnFactor() { var interp = MakeInterp(RunRate); interp.apply_raw_movement(Raw(turn: MotionCommand.TurnRight, hold: HoldKey.Run)); Assert.Equal(MotionCommand.TurnRight, interp.InterpretedState.TurnCommand); Assert.Equal(MotionInterpreter.RunTurnFactor, interp.InterpretedState.TurnSpeed, 5); // +1.5 } [Fact] public void RunTurnLeft_RemapsToTurnRightWithNegativeRunTurnFactor() { var interp = MakeInterp(RunRate); interp.apply_raw_movement(Raw(turn: MotionCommand.TurnLeft, hold: HoldKey.Run)); // TurnLeft → TurnRight, speed *= -1; Run → *= 1.5 → -1.5. Assert.Equal(MotionCommand.TurnRight, interp.InterpretedState.TurnCommand); Assert.Equal(-MotionInterpreter.RunTurnFactor, interp.InterpretedState.TurnSpeed, 5); // -1.5 } [Fact] public void WalkTurn_NoRun_TurnSpeedIsUnity() { var interp = MakeInterp(RunRate); interp.apply_raw_movement(Raw(turn: MotionCommand.TurnRight, hold: HoldKey.None)); Assert.Equal(MotionCommand.TurnRight, interp.InterpretedState.TurnCommand); Assert.Equal(1.0f, interp.InterpretedState.TurnSpeed, 5); // no run factor } // ── idle ───────────────────────────────────────────────────────────────── [Fact] public void Idle_ReadyState_ZeroVelocity() { var interp = MakeInterp(RunRate); interp.apply_raw_movement(Raw()); // Ready, no sidestep/turn var v = interp.get_state_velocity(); Assert.Equal(0f, v.X, 5); Assert.Equal(0f, v.Y, 5); } // ── attack / non-locomotion forward command (#170) ─────────────────────── [Theory] [InlineData(0x10000062u)] // AttackHigh1 [InlineData(0x10000063u)] // AttackMed1 [InlineData(0x10000064u)] // AttackLow1 [InlineData(0x10000186u)] // AttackHigh4 (shifted late block) public void AttackForwardCommand_ZeroVelocity(uint attackCommand) { // #170: an ATTACK forward command (action-class 0x1000006x/0x100001xx) // is neither WalkForward (0x45000005) nor RunForward (0x44000007), so // retail's get_state_velocity (0x00527d50) hits its `else → 0` branch — // the creature plants its feet instead of coasting at the last run // velocity. Cross-checked against holtburger grounded_local_velocity // (`_ => Vector3::zero()`) and the retail decomp. This is the invariant // the #170 glide fix relies on: GameWindow's remote dead-reckon now // refreshes the body velocity from get_state_velocity each tick, so a // creature that switches from a run-chase (ForwardCommand=RunForward) // to an attack (ForwardCommand=0x1000006x) resolves to zero velocity // and stops gliding. var interp = MakeInterp(RunRate); interp.InterpretedState.ForwardCommand = attackCommand; interp.InterpretedState.ForwardSpeed = 0.97f; var v = interp.get_state_velocity(); Assert.Equal(0f, v.X, 5); Assert.Equal(0f, v.Y, 5); Assert.Equal(0f, v.Z, 5); } }