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>
892 lines
32 KiB
C#
892 lines
32 KiB
C#
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);
|
||
}
|
||
}
|