acdream/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs
Erik 8664959152 feat(R3-W1): retail state completion — action FIFOs, MovementParameters, MotionNode, WeenieError renumber (closes J2, J16-codes)
InterpretedMotionState + the unified RawMotionState (LegacyRawMotionState
folded away) gain retail's action FIFO + ApplyMotion/RemoveMotion, ported
verbatim from the raw named decomp (0x0051e790/0x0051eb60 region, pc
293252-293703) including two genuine retail quirks pinned by tests and
independently re-verified against the raw text before commit:
- RawMotionState::ApplyMotion's RunForward (0x44000007) dead branch — a
  cycle-class apply of literal RunForward writes NOTHING (retail encodes
  run as WalkForward + HoldKey_Run on the raw state; same family as the
  D6 apply_run_to_command early-return).
- InterpretedMotionState::RemoveMotion's exact-match-only TurnRight/
  SideStepRight handling (left variants fall through to the style/
  forward branches).

MovementParameters verbatim (A4 pin): 18 named flags per the absolute
mask table, ctor = 0x1EE0F expansion + distance_to_object 0.6 /
fail_distance FLT_MAX / speed 1 / walk_run_threshhold 15 / hold_key
Invalid — with the two ACE-divergence traps ported RETAIL-side
(can_charge FALSE where ACE defaults true; threshold 15.0 not 1.0).
MotionNode {ContextId, Motion, JumpErrorCode} (acclient.h:53293) — the
pending_motions node W2 consumes.

WeenieError renumbered to retail's numeric semantics (A10 table):
NotGrounded=0x24, GeneralMovementFailure 0x24→0x47, new 0x3f/0x40/0x41
combat-stance rejects + 0x42 chat-emote + 0x45 action-depth; the
airborne jump/action returns corrected 0x48→0x24 per the A10 sweep
(incl. jump_is_allowed's null-physics-obj 0x24-not-8 divergence from
ACE). Codes are local-only — wire untouched.

Outbound packer proof: RawMotionStatePacker unmodified; all 6
golden-byte RawMotionStatePackTests pass unmodified. TS-24 register row
updated (capability landed; outbound wiring still open).

Implemented by a dedicated agent against the W0-pinned spec; quirks,
scope, and suite independently verified. Full suite green: 3,531
(374+425+713+2015+4 pre-existing skips).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:12:33 +02:00

892 lines
32 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_ReturnsNotGrounded()
{
// R3-W1 renumber (A10): airborne is 0x24 (NotGrounded), not 0x48
// (0x48 is now reserved for the motion_allows_jump blocklist).
var body = MakeAirborne();
var interp = MakeInterp(body);
var result = interp.jump(0.5f);
Assert.Equal(WeenieError.NotGrounded, 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_ReturnsNotGrounded()
{
var interp = MakeInterp(MakeAirborne());
var result = interp.jump_is_allowed(0.5f, 0);
Assert.Equal(WeenieError.NotGrounded, 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_ReturnsNotGrounded()
{
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.NotGrounded, result);
}
[Fact]
public void JumpIsAllowed_NullPhysicsObj_ReturnsNotGrounded()
{
// A10 note: jump_is_allowed returns 0x24 (NotGrounded), NOT 8, when
// physics_obj == null — the "8 = no physics obj" convention that
// holds everywhere else in CMotionInterp does not hold here.
var interp = new MotionInterpreter();
var result = interp.jump_is_allowed(0.5f, 0);
Assert.Equal(WeenieError.NotGrounded, 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);
}
// 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_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
}
[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);
}
[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);
}
}