acdream/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs
Erik 9c5634af17 feat(physics): MotionInterpreter.GetMaxSpeed for InterpolationManager (L.3.1 Task 2)
Ports retail's CMotionInterp::get_max_speed (0x00527cb0). Returns
motion-table-derived max speed (m/s) for InterpretedState.ForwardCommand:
- RunForward:   RunAnimSpeed (4.0) × (InqRunRate ?? MyRunRate)
- WalkForward:  WalkAnimSpeed (3.12)
- WalkBackward: WalkAnimSpeed × 0.65 (BackwardsFactor from adjust_motion @ 0x00528010)
- otherwise:    0

Decomp note: Binary Ninja emits a spurious void return for x87 FPU-returning
functions; the actual float return is confirmed by both callers
(StickyManager::adjust_offset @ 0x00555430,
InterpolationManager::AdjustOffset @ 0x00555d52) which multiply the result
by 2.0 to produce a catch-up speed in m/s. The per-command switch is
consistent with get_state_velocity (0x00527d50) which uses the same constants.

Used by InterpolationManager.AdjustOffset in Task 5 as 2 × GetMaxSpeed().
Until Task 5 wires it, the method is unused — covered by 4 unit tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 19:16:38 +02:00

876 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)
// =========================================================================
[Fact]
public void Jump_Grounded_SetsJumpExtentAndLeavesWalkable()
{
var body = MakeGrounded();
var interp = MakeInterp(body);
var result = interp.jump(0.5f);
Assert.Equal(WeenieError.None, result);
Assert.Equal(0.5f, interp.JumpExtent, precision: 5);
Assert.False(body.OnWalkable, "Body should no longer be on walkable after jump");
}
[Fact]
public void Jump_Airborne_ReturnsYouCantJumpWhileInTheAir()
{
var body = MakeAirborne();
var interp = MakeInterp(body);
var result = interp.jump(0.5f);
Assert.Equal(WeenieError.YouCantJumpWhileInTheAir, result);
}
[Fact]
public void Jump_WeenieBlocksJump_ClearStandingLongJump()
{
var weenie = new FakeWeenie { CanJumpResult = false };
var body = MakeGrounded();
var interp = MakeInterp(body, weenie);
interp.StandingLongJump = true;
var result = interp.jump(0.5f);
Assert.Equal(WeenieError.CantJumpLoadedDown, result);
Assert.False(interp.StandingLongJump);
}
[Fact]
public void Jump_NullPhysicsObj_ReturnsNoPhysicsObject()
{
var interp = new MotionInterpreter();
var result = interp.jump(0.5f);
Assert.Equal(WeenieError.NoPhysicsObject, result);
}
// =========================================================================
// get_jump_v_z (FUN_005286b0)
// =========================================================================
[Fact]
public void GetJumpVz_ZeroExtent_ReturnsZero()
{
var interp = MakeInterp();
interp.JumpExtent = 0f;
Assert.Equal(0f, interp.get_jump_v_z(), precision: 5);
}
[Fact]
public void GetJumpVz_NoWeenie_ReturnsDefault()
{
var interp = MakeInterp();
interp.JumpExtent = 0.5f;
Assert.Equal(MotionInterpreter.DefaultJumpVz, interp.get_jump_v_z(), precision: 4);
}
[Fact]
public void GetJumpVz_WithWeenie_DelegatesToInqJumpVelocity()
{
var weenie = new FakeWeenie { JumpVz = 8.0f };
var interp = MakeInterp(weenie: weenie);
interp.JumpExtent = 1.0f;
float vz = interp.get_jump_v_z();
// FakeWeenie returns JumpVz * extent = 8.0 * 1.0 = 8.0
Assert.Equal(8.0f, vz, precision: 4);
}
[Fact]
public void GetJumpVz_ExtentClampsAt1()
{
var weenie = new FakeWeenie { JumpVz = 10.0f };
var interp = MakeInterp(weenie: weenie);
interp.JumpExtent = 5.0f; // over-clamped
float vz = interp.get_jump_v_z();
// Should clamp extent to 1.0: FakeWeenie returns 10.0 * 1.0 = 10.0
Assert.Equal(10.0f, vz, precision: 4);
}
[Fact]
public void GetJumpVz_WeenieReturnsFailure_ReturnsZero()
{
var weenie = new FakeWeenie { InqJumpVelocityResult = false };
var interp = MakeInterp(weenie: weenie);
interp.JumpExtent = 0.5f;
Assert.Equal(0f, interp.get_jump_v_z(), precision: 5);
}
// =========================================================================
// get_leave_ground_velocity (FUN_00528cd0)
// =========================================================================
[Fact]
public void GetLeaveGroundVelocity_WalkingForward_HasPositiveYAndZ()
{
var weenie = new FakeWeenie { JumpVz = 10.0f };
var body = MakeGrounded();
var interp = new MotionInterpreter(body, weenie)
{
JumpExtent = 1.0f,
};
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
interp.InterpretedState.ForwardSpeed = 1.0f;
var vel = interp.get_leave_ground_velocity();
Assert.True(vel.Y > 0f, "Y velocity should be positive when walking forward");
Assert.True(vel.Z > 0f, "Z (jump) velocity should be positive");
}
[Fact]
public void GetLeaveGroundVelocity_Standing_ZeroExtent_ReturnsZeroXY()
{
var body = MakeGrounded();
var interp = new MotionInterpreter(body)
{
JumpExtent = 0f, // below epsilon
};
// Default interpreted state = Ready (no walk/run command)
var vel = interp.get_leave_ground_velocity();
Assert.Equal(0f, vel.X, precision: 5);
Assert.Equal(0f, vel.Y, precision: 5);
Assert.Equal(0f, vel.Z, precision: 5);
}
// =========================================================================
// jump_is_allowed (FUN_00528ec0)
// =========================================================================
[Fact]
public void JumpIsAllowed_Grounded_ReturnsNone()
{
var interp = MakeInterp(MakeGrounded());
var result = interp.jump_is_allowed(0.5f, 0);
Assert.Equal(WeenieError.None, result);
}
[Fact]
public void JumpIsAllowed_Airborne_ReturnsYouCantJumpWhileInTheAir()
{
var interp = MakeInterp(MakeAirborne());
var result = interp.jump_is_allowed(0.5f, 0);
Assert.Equal(WeenieError.YouCantJumpWhileInTheAir, result);
}
[Fact]
public void JumpIsAllowed_WeenieBlocks_ReturnsCantJumpLoadedDown()
{
var weenie = new FakeWeenie { CanJumpResult = false };
var interp = MakeInterp(MakeGrounded(), weenie);
var result = interp.jump_is_allowed(0.5f, 0);
Assert.Equal(WeenieError.CantJumpLoadedDown, result);
}
[Fact]
public void JumpIsAllowed_NoGravityFlag_ReturnsYouCantJumpWhileInTheAir()
{
var body = new PhysicsBody
{
State = PhysicsStateFlags.None, // no gravity
TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
};
var interp = MakeInterp(body);
// No gravity → must be airborne-style
var result = interp.jump_is_allowed(0.5f, 0);
Assert.Equal(WeenieError.YouCantJumpWhileInTheAir, result);
}
[Fact]
public void JumpIsAllowed_NullPhysicsObj_ReturnsGeneralFailure()
{
var interp = new MotionInterpreter();
var result = interp.jump_is_allowed(0.5f, 0);
Assert.Equal(WeenieError.GeneralMovementFailure, result);
}
// =========================================================================
// 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);
}
[Fact]
public void ContactAllowsMove_FallenState_RejectsMove()
{
var body = MakeGrounded();
var interp = MakeInterp(body);
interp.InterpretedState.ForwardCommand = MotionCommand.Fallen;
bool allowed = interp.contact_allows_move(MotionCommand.WalkForward);
Assert.False(allowed);
}
[Fact]
public void ContactAllowsMove_DeadState_RejectsMove()
{
var body = MakeGrounded();
var interp = MakeInterp(body);
interp.InterpretedState.ForwardCommand = MotionCommand.Dead;
bool allowed = interp.contact_allows_move(MotionCommand.WalkForward);
Assert.False(allowed);
}
[Theory]
[InlineData(MotionCommand.Crouch)]
[InlineData(MotionCommand.Sitting)]
[InlineData(MotionCommand.Sleeping)]
public void ContactAllowsMove_PostureState_RejectsMove(uint postureCommand)
{
var body = MakeGrounded();
var interp = MakeInterp(body);
interp.InterpretedState.ForwardCommand = postureCommand;
bool allowed = interp.contact_allows_move(MotionCommand.WalkForward);
Assert.False(allowed);
}
[Fact]
public void ContactAllowsMove_CrouchRange_RejectsMove()
{
var body = MakeGrounded();
var interp = MakeInterp(body);
// 0x41000012 is inside (0x41000011, 0x41000015) — crouch state
interp.InterpretedState.ForwardCommand = 0x41000012u;
bool allowed = interp.contact_allows_move(MotionCommand.WalkForward);
Assert.False(allowed);
}
[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_SetsStandingLongJump()
{
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.True(interp.StandingLongJump, "Should set StandingLongJump when grounded and idle");
}
// =========================================================================
// 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
}
[Fact]
public void GetMaxSpeed_WalkForward_ReturnsWalkAnimSpeed()
{
// WalkForward max speed is always WalkAnimSpeed (3.12) — no run-rate scaling.
var interp = MakeInterp();
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
float speed = interp.GetMaxSpeed();
Assert.Equal(MotionInterpreter.WalkAnimSpeed, speed, precision: 4);
}
[Fact]
public void GetMaxSpeed_WalkBackward_ReturnsWalkAnimSpeedTimesBackwardsFactor()
{
// BackwardsFactor = 0.65, from adjust_motion @ 0x00528010 in the named retail decomp.
var interp = MakeInterp();
interp.InterpretedState.ForwardCommand = MotionCommand.WalkBackward;
float speed = interp.GetMaxSpeed();
Assert.Equal(MotionInterpreter.WalkAnimSpeed * 0.65f, speed, precision: 4);
}
[Fact]
public void GetMaxSpeed_Idle_ReturnsZero()
{
// Ready / non-locomotion commands → 0 (no movement speed).
var interp = MakeInterp();
interp.InterpretedState.ForwardCommand = MotionCommand.Ready;
float speed = interp.GetMaxSpeed();
Assert.Equal(0f, speed, precision: 4);
}
}