acdream/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs
Erik c0afcacbb2 fix(physics): movement-parity fixes - adjusted catch-up cap, autorun retail semantics, AP-30 retired
Ports CMotionInterp::get_adjusted_max_speed (0x00527D00, byte-decoded:
bare rate unless RunForward; forward_speed x 4.0 when running;
current_speed_factor proven a ctor-constant 1.0 at 0x00528C34) and swaps
all five interpolation catch-up call sites to it - retail's
fUseAdjustedSpeed_ static (.data 0x0081F418 = 1) makes this the live
branch, so standing/walking remotes now catch up at ~2x runRate instead
of 4x too fast (the #41/#165 presentation family). Autorun now hard-
forces Run for its duration and cancels on every fresh forward press
(CommandInterpreter::HandleNewForwardMovement 0x006b3d60 is literally
SetAutoRun(0,1)); the old test pin codified the divergence. AP-30
retired: retail Frame::is_equal genuinely uses the 0.0002 epsilon - the
row recorded a non-divergence. Three catch-up test pins re-baselined to
retail semantics with citations. Full Release suite 9,983/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:04:17 +02:00

762 lines
30 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
// ─────────────────────────────────────────────────────────────────────────────
// MotionInterpreterTests — covers the 10 ported methods.
//
// Source addresses tested:
// FUN_00529a90 PerformMovement
// FUN_00529930 DoMotion
// FUN_00528a50 StopCompletely
// FUN_00528960 get_state_velocity
// FUN_00529390 jump
// FUN_005286b0 get_jump_v_z
// FUN_00528cd0 get_leave_ground_velocity
// FUN_00528ec0 jump_is_allowed
// FUN_00528dd0 contact_allows_move
// FUN_00529210 apply_current_movement
// ─────────────────────────────────────────────────────────────────────────────
/// <summary>
/// Fake WeenieObject for test isolation — all methods return configurable values.
/// </summary>
file sealed class FakeWeenie : IWeenieObject
{
public float RunRate = 1.0f;
public float JumpVz = 10.0f;
public bool CanJumpResult = true;
public bool InqRunRateResult = true;
public bool InqJumpVelocityResult = true;
public bool InqJumpVelocity(float extent, out float vz)
{
vz = JumpVz * extent;
return InqJumpVelocityResult;
}
public bool InqRunRate(out float rate)
{
rate = RunRate;
return InqRunRateResult;
}
public bool CanJump(float extent) => CanJumpResult;
}
public sealed class MotionInterpreterTests
{
// ── helpers ───────────────────────────────────────────────────────────────
private static PhysicsBody MakeGrounded()
{
var body = new PhysicsBody
{
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
};
body.TransientState = TransientStateFlags.Contact
| TransientStateFlags.OnWalkable
| TransientStateFlags.Active;
return body;
}
private static PhysicsBody MakeAirborne()
{
var body = new PhysicsBody
{
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
};
body.TransientState = TransientStateFlags.Active;
return body;
}
private static MotionInterpreter MakeInterp(PhysicsBody? body = null, IWeenieObject? weenie = null)
{
body ??= MakeGrounded();
return new MotionInterpreter(body, weenie);
}
// =========================================================================
// PerformMovement (FUN_00529a90) — dispatcher
// =========================================================================
[Theory]
[InlineData(MovementType.RawCommand)]
[InlineData(MovementType.InterpretedCommand)]
[InlineData(MovementType.StopRawCommand)]
[InlineData(MovementType.StopInterpretedCommand)]
[InlineData(MovementType.StopCompletely)]
public void PerformMovement_ValidTypes_ReturnNone(MovementType type)
{
var interp = MakeInterp();
var mvs = new MovementStruct
{
Type = type,
Motion = MotionCommand.WalkForward,
Speed = 1.0f,
ModifyInterpretedState = true,
ModifyRawState = false,
};
var result = interp.PerformMovement(mvs);
Assert.Equal(WeenieError.None, result);
}
[Fact]
public void PerformMovement_InvalidType_ReturnsGeneralFailure()
{
var interp = MakeInterp();
var mvs = new MovementStruct { Type = (MovementType)99, Motion = MotionCommand.WalkForward };
var result = interp.PerformMovement(mvs);
Assert.Equal(WeenieError.GeneralMovementFailure, result);
}
[Fact]
public void PerformMovement_StopCompletely_ResetsToReady()
{
var interp = MakeInterp();
interp.RawState.ForwardCommand = MotionCommand.WalkForward;
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
var mvs = new MovementStruct { Type = MovementType.StopCompletely };
interp.PerformMovement(mvs);
Assert.Equal(MotionCommand.Ready, interp.RawState.ForwardCommand);
Assert.Equal(MotionCommand.Ready, interp.InterpretedState.ForwardCommand);
}
[Fact]
public void PerformMovement_NullPhysicsObj_ReturnsNoPhysicsObject()
{
var interp = new MotionInterpreter(); // no PhysicsObj
var mvs = new MovementStruct { Type = MovementType.RawCommand, Motion = MotionCommand.WalkForward };
var result = interp.PerformMovement(mvs);
Assert.Equal(WeenieError.NoPhysicsObject, result);
}
// =========================================================================
// DoMotion (FUN_00529930) — raw motion command sets RawState
// =========================================================================
[Fact]
public void DoMotion_WalkForward_SetsRawForwardCommand()
{
var interp = MakeInterp();
interp.DoMotion(MotionCommand.WalkForward, 0.8f);
Assert.Equal(MotionCommand.WalkForward, interp.RawState.ForwardCommand);
Assert.Equal(0.8f, interp.RawState.ForwardSpeed, precision: 5);
}
[Fact]
public void DoMotion_NullPhysicsObj_ReturnsNoPhysicsObject()
{
var interp = new MotionInterpreter();
var result = interp.DoMotion(MotionCommand.WalkForward);
Assert.Equal(WeenieError.NoPhysicsObject, result);
}
// =========================================================================
// StopCompletely (FUN_00528a50)
// =========================================================================
[Fact]
public void StopCompletely_ResetsRawAndInterpretedToReady()
{
var interp = MakeInterp();
interp.RawState.ForwardCommand = MotionCommand.WalkForward;
interp.RawState.SidestepCommand = MotionCommand.SideStepRight;
interp.RawState.TurnCommand = MotionCommand.TurnRight;
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
interp.InterpretedState.TurnCommand = MotionCommand.TurnLeft;
var result = interp.StopCompletely();
Assert.Equal(WeenieError.None, result);
Assert.Equal(MotionCommand.Ready, interp.RawState.ForwardCommand);
Assert.Equal(1.0f, interp.RawState.ForwardSpeed, precision: 5);
Assert.Equal(0u, interp.RawState.SidestepCommand);
Assert.Equal(0u, interp.RawState.TurnCommand);
Assert.Equal(MotionCommand.Ready, interp.InterpretedState.ForwardCommand);
Assert.Equal(1.0f, interp.InterpretedState.ForwardSpeed, precision: 5);
Assert.Equal(0u, interp.InterpretedState.SideStepCommand);
Assert.Equal(0u, interp.InterpretedState.TurnCommand);
}
[Fact]
public void StopCompletely_ZerosPhysicsBodyVelocity()
{
var body = MakeGrounded();
body.set_velocity(new Vector3(5f, 3f, 0f));
var interp = MakeInterp(body);
interp.StopCompletely();
Assert.Equal(Vector3.Zero, body.Velocity);
}
[Fact]
public void StopCompletely_NullPhysicsObj_ReturnsNoPhysicsObject()
{
var interp = new MotionInterpreter();
var result = interp.StopCompletely();
Assert.Equal(WeenieError.NoPhysicsObject, result);
}
// =========================================================================
// get_state_velocity (FUN_00528960)
// =========================================================================
[Fact]
public void GetStateVelocity_Idle_ReturnsZero()
{
var interp = MakeInterp();
// Default interpreted state = Ready, no sidestep, no turn
var vel = interp.get_state_velocity();
Assert.Equal(Vector3.Zero, vel);
}
[Fact]
public void GetStateVelocity_WalkForward_ReturnsWalkSpeed()
{
var interp = MakeInterp();
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
interp.InterpretedState.ForwardSpeed = 1.0f;
var vel = interp.get_state_velocity();
// Y = WalkAnimSpeed * 1.0 = 3.12
Assert.Equal(0f, vel.X, precision: 5);
Assert.Equal(MotionInterpreter.WalkAnimSpeed, vel.Y, precision: 4);
Assert.Equal(0f, vel.Z, precision: 5);
}
[Fact]
public void GetStateVelocity_RunForward_ReturnsRunSpeed()
{
var interp = MakeInterp();
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
interp.InterpretedState.ForwardSpeed = 1.0f;
var vel = interp.get_state_velocity();
// Y = RunAnimSpeed * 1.0 = 4.0
Assert.Equal(MotionInterpreter.RunAnimSpeed, vel.Y, precision: 4);
}
[Fact]
public void GetStateVelocity_SidestepRight_ReturnsPositiveX()
{
var interp = MakeInterp();
interp.InterpretedState.SideStepCommand = MotionCommand.SideStepRight;
interp.InterpretedState.SideStepSpeed = 1.0f;
var vel = interp.get_state_velocity();
Assert.Equal(MotionInterpreter.SidestepAnimSpeed, vel.X, precision: 4);
Assert.Equal(0f, vel.Y, precision: 5);
}
[Fact]
public void GetStateVelocity_ClampsToMaxSpeed_WhenExceedRunRate()
{
// Speed=10 for walk would produce velocity.Y = 3.12*10 = 31.2, well over
// RunAnimSpeed * runRate = 4.0 * 1.0 = 4.0; must be clamped.
var interp = MakeInterp();
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
interp.InterpretedState.ForwardSpeed = 10.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_UsesWeenieRunRate()
{
var weenie = new FakeWeenie { RunRate = 2.0f };
var interp = MakeInterp(weenie: weenie);
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
interp.InterpretedState.ForwardSpeed = 1.0f;
var vel = interp.get_state_velocity();
// maxSpeed = 4.0 * 2.0 = 8.0; velocity.Y = RunAnimSpeed * 1.0 = 4.0 ≤ 8.0
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) / get_jump_v_z (FUN_005286b0) /
// get_leave_ground_velocity (FUN_00528cd0) / jump_is_allowed (FUN_00528ec0)
//
// R3-W3 (closes J5, J6, J7-interp-side, J16-epsilons): the full jump
// family (jump/GetJumpVZ/GetLeaveGroundVelocity/jump_is_allowed) plus
// jump_charge_is_allowed/ChargeJump now live in
// MotionInterpreterJumpFamilyTests.cs, ported against the verbatim
// decomp (r3-motioninterp-decomp.md §3a-3h) and W0-pins.md (A2/A5/A6/A10)
// instead of this file's pre-W3 approximation-era pins (wrong 0.001
// epsilon, jump_is_allowed(extent, int) shape, no IsFullyConstrained /
// pending-head-peek / stamina chain). See that file for the full gate
// tables and epsilon-boundary pins.
// =========================================================================
// =========================================================================
// contact_allows_move (FUN_00528dd0)
// =========================================================================
[Fact]
public void ContactAllowsMove_Grounded_Ready_AllowsMove()
{
var body = MakeGrounded();
var interp = MakeInterp(body);
// InterpretedState.ForwardCommand defaults to Ready
bool allowed = interp.contact_allows_move(MotionCommand.WalkForward);
Assert.True(allowed);
}
[Fact]
public void ContactAllowsMove_Airborne_RejectsMove()
{
var body = MakeAirborne();
var interp = MakeInterp(body);
bool allowed = interp.contact_allows_move(MotionCommand.WalkForward);
Assert.False(allowed);
}
[Fact]
public void ContactAllowsMove_TurnCommand_AlwaysAllowed_EvenAirborne()
{
var body = MakeAirborne();
var interp = MakeInterp(body);
// Turn commands bypass the ground-contact check.
bool allowRight = interp.contact_allows_move(MotionCommand.TurnRight);
bool allowLeft = interp.contact_allows_move(MotionCommand.TurnLeft);
Assert.True(allowRight);
Assert.True(allowLeft);
}
// L.2g S2 (2026-07-02): the posture-rejection tests that used to live
// here pinned a MISATTRIBUTED port — the Fallen/Dead/crouch-range checks
// belong to jump_charge_is_allowed (0x00527a50) / motion_allows_jump
// (0x005279e0), NOT to contact_allows_move. The real contact_allows_move
// (0x00528240, pseudo-C 305471) never reads the forward-command posture:
// a grounded creature in ANY posture may receive motion
// (GetObjectSequence handles the posture→locomotion transition via
// links). Verified against the live retail-observer trace
// (RetailObserverTraceConformanceTests, 183/183 dispatch conformant).
[Theory]
[InlineData(MotionCommand.Fallen)]
[InlineData(MotionCommand.Dead)]
[InlineData(MotionCommand.Crouch)]
[InlineData(MotionCommand.Sitting)]
[InlineData(MotionCommand.Sleeping)]
[InlineData(0x41000012u)] // inside the crouch range (0x41000011, 0x41000015)
public void ContactAllowsMove_GroundedPosture_StillAllowsMove(uint postureCommand)
{
var body = MakeGrounded();
var interp = MakeInterp(body);
interp.InterpretedState.ForwardCommand = postureCommand;
bool allowed = interp.contact_allows_move(MotionCommand.WalkForward);
Assert.True(allowed);
}
[Fact]
public void ContactAllowsMove_AirborneCreature_AcceptsFallingAndTurns_BlocksWalk()
{
// Verbatim 0x00528240: Falling (0x40000015) / Dead-class (0x40000011)
// and TurnLeft/TurnRight are ALWAYS allowed; a gravity-bound creature
// without Contact+OnWalkable is blocked for everything else
// (including the style command, which falls through to the gate).
var body = new PhysicsBody { State = PhysicsStateFlags.Gravity };
var interp = MakeInterp(body);
Assert.True(interp.contact_allows_move(MotionCommand.Falling));
Assert.True(interp.contact_allows_move(0x40000011u));
Assert.True(interp.contact_allows_move(MotionCommand.TurnRight));
Assert.True(interp.contact_allows_move(MotionCommand.TurnLeft));
Assert.False(interp.contact_allows_move(MotionCommand.WalkForward));
Assert.False(interp.contact_allows_move(0x8000003Du));
}
[Fact]
public void ContactAllowsMove_NoGravity_AlwaysAllows()
{
// E.g. flying or swimming object
var body = new PhysicsBody
{
State = PhysicsStateFlags.None,
TransientState = TransientStateFlags.None,
};
var interp = MakeInterp(body);
bool allowed = interp.contact_allows_move(MotionCommand.WalkForward);
Assert.True(allowed);
}
[Fact]
public void ContactAllowsMove_GroundedAndIdle_DoesNotSetStandingLongJump()
{
// R3-W3 (closes J6): the S2a-flagged misattribution is DELETED.
// Retail arms StandingLongJump ONLY in charge_jump (0x005281c0) —
// contact_allows_move (0x00528240) never reads or writes it. See
// MotionInterpreterJumpFamilyTests.ContactAllowsMove_GroundedAndIdle_DoesNotArmStandingLongJump
// for the full regression pin (both arm and no-clear directions)
// and ChargeJump_GroundedIdle_ArmsStandingLongJump for where the
// arming now actually happens.
var body = MakeGrounded();
var interp = MakeInterp(body);
// All interpreted commands at default (Ready, no sidestep, no turn)
interp.StandingLongJump = false;
interp.contact_allows_move(MotionCommand.WalkForward);
Assert.False(interp.StandingLongJump, "contact_allows_move must never touch StandingLongJump (J6)");
}
// =========================================================================
// apply_current_movement (FUN_00529210)
// =========================================================================
[Fact]
public void ApplyCurrentMovement_WalkForward_PushesNonZeroVelocity()
{
var body = MakeGrounded();
var interp = MakeInterp(body);
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
interp.InterpretedState.ForwardSpeed = 1.0f;
interp.apply_current_movement(cancelMoveTo: false, allowJump: false);
// The body's local-space velocity should have been set to some non-zero forward component.
// We can't easily test world-space (depends on orientation = Identity), but with
// Identity orientation the local Y becomes world Y.
Assert.True(body.Velocity.Length() > 0f, "Velocity should be non-zero when walking");
}
[Fact]
public void ApplyCurrentMovement_Idle_PushesZeroVelocity()
{
var body = MakeGrounded();
var interp = MakeInterp(body);
// Default state = Ready
interp.apply_current_movement(cancelMoveTo: false, allowJump: false);
Assert.Equal(Vector3.Zero, body.Velocity);
}
[Fact]
public void ApplyCurrentMovement_Run_UpdatesMyRunRate()
{
var body = MakeGrounded();
var interp = MakeInterp(body);
interp.MyRunRate = 1.0f;
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
interp.InterpretedState.ForwardSpeed = 2.0f; // e.g. speed-buff
interp.apply_current_movement(cancelMoveTo: false, allowJump: false);
Assert.Equal(2.0f, interp.MyRunRate, precision: 5);
}
[Fact]
public void ApplyCurrentMovement_RunForward_SetsMyRunRate()
{
// Verifies that wiring a server-echoed ForwardSpeed (from UpdateMotion
// 0xF74C InterpretedMotionState flag 0x10) into the MotionInterpreter
// produces the correct MyRunRate and get_state_velocity result.
var body = new PhysicsBody();
var mi = new MotionInterpreter(body);
mi.InterpretedState.ForwardCommand = MotionCommand.RunForward;
mi.InterpretedState.ForwardSpeed = 2.375f;
mi.apply_current_movement(cancelMoveTo: false, allowJump: true);
Assert.Equal(2.375f, mi.MyRunRate, precision: 3);
var vel = mi.get_state_velocity();
Assert.Equal(4.0f * 2.375f, vel.Y, precision: 2);
}
// =========================================================================
// GetMaxSpeed (CMotionInterp::get_max_speed @ 0x00527cb0)
// L.3.1 Task 2 — InterpolationManager catch-up speed source
// =========================================================================
[Fact]
public void GetMaxSpeed_RunForward_ReturnsRunAnimSpeedTimesRunRate()
{
// Retail: get_max_speed returns run rate from InqRunRate; callers
// multiply by 2 to get catch-up speed. For RunForward the per-m/s
// speed is RunAnimSpeed × rate = 4.0 × 1.5 = 6.0.
var weenie = new FakeWeenie { RunRate = 1.5f };
var interp = MakeInterp(weenie: weenie);
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
float speed = interp.GetMaxSpeed();
Assert.Equal(MotionInterpreter.RunAnimSpeed * 1.5f, speed, precision: 4); // 6.0
}
[Theory]
[InlineData(MotionCommand.WalkForward)]
[InlineData(MotionCommand.WalkBackward)]
[InlineData(MotionCommand.Ready)]
[InlineData(MotionCommand.RunForward)]
public void GetMaxSpeed_IgnoresForwardCommand_AlwaysReturnsRunRate(uint command)
{
// GetMaxSpeed is the InterpolationManager.AdjustOffset catch-up speed — it
// returns RunAnimSpeed × run-rate REGARDLESS of the current ForwardCommand
// (retail 0x00527cb0 never reads interpreted_state; UN-2 byte verification
// 2026-06-12, tools/verify_un2_fmul.py). These previously asserted a REMOVED
// command-branching design (WalkForward → WalkAnimSpeed, WalkBackward →
// ×0.65, Idle → 0); they PIN the no-branch contract across commands.
var weenie = new FakeWeenie { RunRate = 1.75f };
var interp = MakeInterp(weenie: weenie);
interp.InterpretedState.ForwardCommand = command;
float speed = interp.GetMaxSpeed();
Assert.Equal(MotionInterpreter.RunAnimSpeed * 1.75f, speed, precision: 4);
}
// =========================================================================
// GetAdjustedMaxSpeed (CMotionInterp::get_adjusted_max_speed @ 0x00527D00)
// Movement parity audit 2026-07-30 — the accessor InterpolationManager's
// catch-up cap actually uses (fUseAdjustedSpeed_ = 0x1, .data 0x0081F418).
// Byte-decoded: NOT RunForward → bare rate; RunForward → forward_speed ×
// RunAnimSpeed (current_speed_factor is a ctor-constant 1.0, 0x00528C34).
// =========================================================================
[Theory]
[InlineData(MotionCommand.WalkForward)]
[InlineData(MotionCommand.WalkBackward)]
[InlineData(MotionCommand.Ready)]
public void GetAdjustedMaxSpeed_NotRunForward_ReturnsBareRunRate(uint command)
{
// The 4x-too-fast walking-remote catch-up fix: a non-RunForward mover's
// adjusted max speed is the BARE rate — no RunAnimSpeed multiply
// (0x00527d2a jnz falls straight to ret with rate in st0).
var weenie = new FakeWeenie { RunRate = 2.94f };
var interp = MakeInterp(weenie: weenie);
interp.InterpretedState.ForwardCommand = command;
Assert.Equal(2.94f, interp.GetAdjustedMaxSpeed(), precision: 4);
}
[Fact]
public void GetAdjustedMaxSpeed_RunForward_ReturnsForwardSpeedTimesRunAnimSpeed()
{
// RunForward DISCARDS the queried rate (fstp st0) and returns
// forward_speed / current_speed_factor(=1.0) * 4.0.
var weenie = new FakeWeenie { RunRate = 99f }; // must be ignored
var interp = MakeInterp(weenie: weenie);
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
interp.InterpretedState.ForwardSpeed = 2.5f;
Assert.Equal(
2.5f * MotionInterpreter.RunAnimSpeed,
interp.GetAdjustedMaxSpeed(),
precision: 4); // 10.0 — NOT 99×4
}
[Fact]
public void GetAdjustedMaxSpeed_NoWeenie_FallsBackToLiteralOne()
{
// 0x00527d0b: no weenie → fld [0x007928b0] = 1.0 (NOT my_run_rate).
var interp = MakeInterp(weenie: null);
interp.MyRunRate = 3.5f;
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
Assert.Equal(1.0f, interp.GetAdjustedMaxSpeed(), precision: 4);
}
[Fact]
public void GetAdjustedMaxSpeed_InqRunRateFails_FallsBackToMyRunRate()
{
// 0x00527d1d-0x00527d23: InqRunRate false → fld [this+0x7c] my_run_rate.
var weenie = new FakeWeenie { RunRate = 5f, InqRunRateResult = false };
var interp = MakeInterp(weenie: weenie);
interp.MyRunRate = 2.4f;
interp.InterpretedState.ForwardCommand = MotionCommand.Ready;
Assert.Equal(2.4f, interp.GetAdjustedMaxSpeed(), precision: 4);
}
[Fact]
public void GetMaxSpeed_NoWeenie_ReturnsLiteralOneTimesRunAnimSpeed()
{
// Retail 0x00527cb0 weenie_obj == null path: fld 1.0 (.rdata 0x007928B0),
// fmul 4.0 (.rdata 0x007C8918) — the LITERAL 1.0, NOT my_run_rate (UN-2
// byte verification 2026-06-12). MyRunRate is set to a different value to
// prove it is not consulted on this path.
var interp = MakeInterp();
interp.MyRunRate = 1.75f;
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
float speed = interp.GetMaxSpeed();
Assert.Equal(MotionInterpreter.RunAnimSpeed * 1.0f, speed, precision: 4);
}
[Fact]
public void GetMaxSpeed_InqRunRateFails_FallsBackToMyRunRate()
{
// Retail 0x00527cb0 InqRunRate-failure path: fld [esi+0x7c] (my_run_rate),
// fmul 4.0. The InqRunRate out-value is discarded on failure.
var weenie = new FakeWeenie { RunRate = 9.9f, InqRunRateResult = false };
var interp = MakeInterp(weenie: weenie);
interp.MyRunRate = 1.75f;
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
float speed = interp.GetMaxSpeed();
Assert.Equal(MotionInterpreter.RunAnimSpeed * 1.75f, speed, precision: 4);
}
}