diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs
new file mode 100644
index 0000000..bfc2165
--- /dev/null
+++ b/src/AcDream.Core/Physics/MotionInterpreter.cs
@@ -0,0 +1,842 @@
+using System;
+using System.Numerics;
+
+namespace AcDream.Core.Physics;
+
+// ─────────────────────────────────────────────────────────────────────────────
+// MotionInterpreter — C# port of CMotionInterp from acclient.exe (chunk_00520000.c).
+//
+// Source addresses (chunk_00520000.c):
+// FUN_00529a90 PerformMovement — top-level dispatcher (switch 1-5)
+// FUN_00529930 DoMotion — process one raw motion command
+// FUN_00528a50 StopCompletely — reset to Ready/idle
+// FUN_00528960 get_state_velocity — compute world-space velocity for current motion
+// FUN_00529210 apply_current_movement — apply interpreted motion as velocity
+// FUN_00529390 jump — initiate jump: validate, record extent, leave ground
+// FUN_005286b0 get_jump_v_z — get vertical jump velocity
+// FUN_00528cd0 get_leave_ground_velocity — compose full 3D launch vector
+// FUN_00528ec0 jump_is_allowed — can we jump?
+// FUN_00528dd0 contact_allows_move — slope angle / contact state check
+//
+// Cross-checked against ACE MotionInterp.cs.
+// ─────────────────────────────────────────────────────────────────────────────
+
+// ── Motion command constants (from retail dat / wire protocol) ────────────────
+///
+/// Raw AC motion command IDs used in CMotionInterp.
+/// Values sourced from the decompiled comparisons in chunk_00520000.c and
+/// confirmed against ACE's MotionCommand enum.
+///
+public static class MotionCommand
+{
+ /// 0x41000003 — idle/default state.
+ public const uint Ready = 0x41000003u;
+ /// 0x45000005 — walk forward.
+ public const uint WalkForward = 0x45000005u;
+ /// 0x44000007 — run forward.
+ public const uint RunForward = 0x44000007u;
+ /// 0x45000006 — walk backward.
+ public const uint WalkBackward = 0x45000006u;
+ /// 0x6500000D — turn right.
+ public const uint TurnRight = 0x6500000Du;
+ /// 0x6500000E — turn left.
+ public const uint TurnLeft = 0x6500000Eu;
+ /// 0x6500000F — sidestep right.
+ public const uint SideStepRight = 0x6500000Fu;
+ /// 0x65000010 — sidestep left.
+ public const uint SideStepLeft = 0x65000010u;
+ /// 0x40000008 — Fallen (lying on ground).
+ public const uint Fallen = 0x40000008u;
+ /// 0x10000057 — Dead.
+ public const uint Dead = 0x10000057u;
+ /// 0x41000011 — Crouch lower bound for blocked-jump check.
+ public const uint CrouchLowerBound = 0x41000011u;
+ /// 0x41000014 — upper bound of crouch/sit/sleep range.
+ public const uint CrouchUpperBound = 0x41000014u;
+}
+
+///
+/// Movement type passed in PerformMovement's switch statement.
+/// Matches the 5-case switch at FUN_00529a90.
+///
+public enum MovementType
+{
+ /// case 1 — raw motion command (DoMotion).
+ RawCommand = 1,
+ /// case 2 — interpreted motion command (DoInterpretedMotion).
+ InterpretedCommand = 2,
+ /// case 3 — stop raw motion (StopMotion).
+ StopRawCommand = 3,
+ /// case 4 — stop interpreted motion (StopInterpretedMotion).
+ StopInterpretedCommand = 4,
+ /// case 5 — stop completely (StopCompletely).
+ StopCompletely = 5,
+}
+
+///
+/// WeenieError codes returned by CMotionInterp methods.
+/// Values are the hex constants used directly in the decompiled C code.
+///
+public enum WeenieError : uint
+{
+ /// 0x00 — success.
+ None = 0x00,
+ /// 0x08 — PhysicsObj is null.
+ NoPhysicsObject = 0x08,
+ /// 0x24 — general movement failure.
+ GeneralMovementFailure = 0x24,
+ /// 0x47 — cannot jump from this position (motion state blocks it).
+ YouCantJumpFromThisPosition = 0x47,
+ /// 0x48 — cannot jump while in the air.
+ YouCantJumpWhileInTheAir = 0x48,
+ /// 0x49 — loaded down / weenie blocked the jump.
+ CantJumpLoadedDown = 0x49,
+}
+
+// ── Motion state structs ───────────────────────────────────────────────────────
+
+///
+/// Raw (network-derived) motion state for the local player.
+/// Struct layout in chunk_00520000 starts at offset +0x14 (struct field +0x20 =
+/// ForwardCommand, +0x28 = ForwardSpeed, etc.).
+///
+public struct RawMotionState
+{
+ /// Forward/backward motion command (offset +0x20).
+ public uint ForwardCommand;
+ /// Speed scalar for forward motion (offset +0x28).
+ public float ForwardSpeed;
+ /// Sidestep command (offset +0x2C).
+ public uint SideStepCommand;
+ /// Speed scalar for sidestep (offset +0x34, inferred from ACE).
+ public float SideStepSpeed;
+ /// Turn command (offset +0x38).
+ public uint TurnCommand;
+ /// Speed scalar for turn (offset +0x40, inferred).
+ public float TurnSpeed;
+
+ /// Initialize to the idle/ready state (1.0 speed, Ready command).
+ public static RawMotionState Default() => new()
+ {
+ ForwardCommand = MotionCommand.Ready,
+ ForwardSpeed = 1.0f,
+ SideStepCommand = 0,
+ SideStepSpeed = 1.0f,
+ TurnCommand = 0,
+ TurnSpeed = 1.0f,
+ };
+}
+
+///
+/// Interpreted motion state, derived from the raw state.
+/// Struct layout: starts at offset +0x44 (ForwardCommand at +0x4C, ForwardSpeed at +0x50).
+///
+public struct InterpretedMotionState
+{
+ /// Forward/backward interpreted command (offset +0x4C).
+ public uint ForwardCommand;
+ /// Speed scalar for interpreted forward motion (offset +0x50).
+ public float ForwardSpeed;
+ /// Sidestep interpreted command (offset +0x54).
+ public uint SideStepCommand;
+ /// Speed scalar for interpreted sidestep (offset +0x58).
+ public float SideStepSpeed;
+ /// Turn interpreted command (offset +0x5C).
+ public uint TurnCommand;
+ /// Speed scalar for turn (offset +0x60).
+ public float TurnSpeed;
+
+ /// Initialize to the idle/ready state.
+ public static InterpretedMotionState Default() => new()
+ {
+ ForwardCommand = MotionCommand.Ready,
+ ForwardSpeed = 1.0f,
+ SideStepCommand = 0,
+ SideStepSpeed = 1.0f,
+ TurnCommand = 0,
+ TurnSpeed = 1.0f,
+ };
+}
+
+///
+/// Lightweight struct passed into PerformMovement.
+/// Fields correspond to what the retail dispatcher read from param_1 (the movement packet struct).
+///
+public struct MovementStruct
+{
+ /// Which of the 5 motion types to dispatch.
+ public MovementType Type;
+ /// Motion command ID (e.g. WalkForward).
+ public uint Motion;
+ /// Speed scalar for this motion.
+ public float Speed;
+ /// Autonomous (player-initiated) flag.
+ public bool Autonomous;
+ /// Whether to modify the interpreted state.
+ public bool ModifyInterpretedState;
+ /// Whether to modify the raw state.
+ public bool ModifyRawState;
+}
+
+// ── Optional WeenieObject interface ──────────────────────────────────────────
+
+///
+/// Minimal interface for the server-side WeenieObject callbacks that CMotionInterp
+/// reaches through at vtable offsets +0x30, +0x34, +0x3C.
+/// Allows testing without a real weenie.
+///
+public interface IWeenieObject
+{
+ /// vtable +0x30 — InqJumpVelocity. Returns true and sets vz if valid.
+ bool InqJumpVelocity(float extent, out float vz);
+ /// vtable +0x34 — InqRunRate. Returns true and sets rate if valid.
+ bool InqRunRate(out float rate);
+ /// vtable +0x3C — CanJump. Returns true if the weenie can jump at this extent.
+ bool CanJump(float extent);
+}
+
+// ── MotionInterpreter ─────────────────────────────────────────────────────────
+
+///
+/// C# port of CMotionInterp (chunk_00520000.c).
+///
+/// Owns the raw and interpreted motion states for a physics object and
+/// translates network movement commands into PhysicsBody velocity calls.
+///
+public sealed class MotionInterpreter
+{
+ // ── animation speed constants (from ACE / confirmed by decompile globals) ─
+ /// Walk animation base speed (_DAT_007c96e4 family).
+ public const float WalkAnimSpeed = 3.12f;
+ /// Run animation base speed (_DAT_007c96e0 family).
+ public const float RunAnimSpeed = 4.0f;
+ /// Sidestep animation base speed (_DAT_007c96e8 family).
+ public const float SidestepAnimSpeed = 1.25f;
+ /// Minimum jump extent before get_jump_v_z bothers computing (_DAT_007c9734).
+ public const float JumpExtentEpsilon = 0.001f;
+ /// Fallback vertical jump velocity when WeenieObj is absent (_DAT_0079c6d4).
+ public const float DefaultJumpVz = 10.0f;
+ /// Maximum jump extent clamped by get_jump_v_z (_DAT_007938b0 = 1.0f).
+ public const float MaxJumpExtent = 1.0f;
+
+ // ── fields (matching struct layout from acclient_function_map.md) ─────────
+
+ /// The physics body this interpreter controls (struct offset +0x08).
+ public PhysicsBody? PhysicsObj { get; set; }
+
+ /// Optional WeenieObject for stamina / run-rate queries (struct offset +0x04).
+ public IWeenieObject? WeenieObj { get; set; }
+
+ /// Raw (network-derived) motion state (struct offsets +0x14..+0x44).
+ public RawMotionState RawState;
+
+ /// Interpreted motion state derived from raw (struct offsets +0x44..+0x7C).
+ public InterpretedMotionState InterpretedState;
+
+ /// Jump charge accumulator — set in jump(), cleared in LeaveGround() (offset +0x74).
+ public float JumpExtent;
+
+ /// Stored run rate from last successful InqRunRate call (offset +0x7C).
+ public float MyRunRate = 1.0f;
+
+ /// True when crouching-in-place for a standing long jump (offset +0x70).
+ public bool StandingLongJump;
+
+ // ── constructor ────────────────────────────────────────────────────────────
+
+ public MotionInterpreter()
+ {
+ RawState = RawMotionState.Default();
+ InterpretedState = InterpretedMotionState.Default();
+ }
+
+ public MotionInterpreter(PhysicsBody physicsObj, IWeenieObject? weenieObj = null)
+ {
+ PhysicsObj = physicsObj;
+ WeenieObj = weenieObj;
+ RawState = RawMotionState.Default();
+ InterpretedState = InterpretedMotionState.Default();
+ }
+
+ // ── FUN_00529a90 — PerformMovement ────────────────────────────────────────
+
+ ///
+ /// Top-level dispatcher for a network movement struct.
+ ///
+ /// Decompiled logic (FUN_00529a90):
+ /// switch(*param_1) { ← MovementStruct.Type
+ /// case 1: DoMotion(…) → raw command
+ /// case 2: DoInterpretedMotion(…)
+ /// case 3: StopMotion(…)
+ /// case 4: StopInterpretedMotion(…)
+ /// case 5: StopCompletely()
+ /// default: return 0x47
+ /// }
+ /// FUN_00510900() — CheckForCompletedMotions (animation flush, not simulated here)
+ ///
+ public WeenieError PerformMovement(MovementStruct mvs)
+ {
+ WeenieError result = mvs.Type switch
+ {
+ MovementType.RawCommand => DoMotion(mvs.Motion, mvs.Speed),
+ MovementType.InterpretedCommand => DoInterpretedMotion(mvs.Motion, mvs.Speed, mvs.ModifyInterpretedState),
+ MovementType.StopRawCommand => StopMotion(mvs.Motion),
+ MovementType.StopInterpretedCommand => StopInterpretedMotion(mvs.Motion, mvs.ModifyInterpretedState),
+ MovementType.StopCompletely => StopCompletely(),
+ _ => WeenieError.GeneralMovementFailure,
+ };
+ // FUN_00510900 — CheckForCompletedMotions is an animation system flush;
+ // no simulation state to update here.
+ return result;
+ }
+
+ // ── FUN_00529930 — DoMotion ───────────────────────────────────────────────
+
+ ///
+ /// Process one raw motion command from a network packet.
+ ///
+ /// Decompiled logic (FUN_00529930):
+ /// Copy packet fields into local variables (at local_24..local_4).
+ /// If the speed byte in flags is negative → call FUN_00510cc0 (cancel moveto).
+ /// If 0x800 flag → FUN_005297c0 (set hold key from packet).
+ /// FUN_00528c20 — adjust_motion (raw→interpreted adjustments).
+ /// Guard against special mid-animation states (returns 0x3F/0x40/0x41/0x42).
+ /// If Action bit (0x10000000) set and num_actions ≥ 6 → return 0x45.
+ /// Call DoInterpretedMotion(motion, movementParams).
+ ///
+ /// Our simplified port focuses on the state fields and physics side-effects.
+ ///
+ public WeenieError DoMotion(uint motion, float speed = 1.0f)
+ {
+ if (PhysicsObj is null)
+ return WeenieError.NoPhysicsObject;
+
+ // Record the new raw forward command and speed.
+ // In the decompile, local_24 = *(param_3+8) = ForwardCommand,
+ // local_18 = ForwardSpeed, etc.
+ RawState.ForwardCommand = motion;
+ RawState.ForwardSpeed = speed;
+
+ // Delegate to the interpreted path. DoMotion ultimately calls
+ // DoInterpretedMotion after adjust_motion in the retail client.
+ return DoInterpretedMotion(motion, speed, modifyInterpretedState: true);
+ }
+
+ // ── DoInterpretedMotion ────────────────────────────────────────────────────
+
+ ///
+ /// Core animation-state-machine entry point (FUN_00528f70).
+ ///
+ /// In the full retail engine this runs the animation sequencer. In this
+ /// physics-only port we update the InterpretedState and call
+ /// apply_current_movement so that the velocity is immediately reflected.
+ ///
+ public WeenieError DoInterpretedMotion(uint motion, float speed = 1.0f, bool modifyInterpretedState = false)
+ {
+ if (PhysicsObj is null)
+ return WeenieError.NoPhysicsObject;
+
+ if (!contact_allows_move(motion))
+ {
+ // Action commands (bit 0x10000000) are blocked mid-air.
+ if ((motion & 0x10000000u) != 0)
+ return WeenieError.YouCantJumpWhileInTheAir;
+ // Non-action motions are queued silently; state still updates.
+ }
+
+ if (modifyInterpretedState)
+ ApplyMotionToInterpretedState(motion, speed);
+
+ apply_current_movement(cancelMoveTo: false, allowJump: true);
+ return WeenieError.None;
+ }
+
+ // ── StopMotion ────────────────────────────────────────────────────────────
+
+ ///
+ /// Stop a specific raw motion (FUN_00529140 → StopInterpretedMotion).
+ ///
+ public WeenieError StopMotion(uint motion)
+ {
+ if (PhysicsObj is null)
+ return WeenieError.NoPhysicsObject;
+
+ if (RawState.ForwardCommand == motion)
+ {
+ RawState.ForwardCommand = MotionCommand.Ready;
+ RawState.ForwardSpeed = 1.0f;
+ }
+ if (RawState.SideStepCommand == motion)
+ {
+ RawState.SideStepCommand = 0;
+ RawState.SideStepSpeed = 1.0f;
+ }
+ if (RawState.TurnCommand == motion)
+ {
+ RawState.TurnCommand = 0;
+ RawState.TurnSpeed = 1.0f;
+ }
+
+ return StopInterpretedMotion(motion, modifyInterpretedState: true);
+ }
+
+ // ── StopInterpretedMotion ────────────────────────────────────────────────
+
+ ///
+ /// Stop a specific interpreted motion (FUN_00529080).
+ ///
+ public WeenieError StopInterpretedMotion(uint motion, bool modifyInterpretedState = false)
+ {
+ if (PhysicsObj is null)
+ return WeenieError.NoPhysicsObject;
+
+ if (modifyInterpretedState)
+ {
+ if (InterpretedState.ForwardCommand == motion)
+ {
+ InterpretedState.ForwardCommand = MotionCommand.Ready;
+ InterpretedState.ForwardSpeed = 1.0f;
+ }
+ if (InterpretedState.SideStepCommand == motion)
+ {
+ InterpretedState.SideStepCommand = 0;
+ InterpretedState.SideStepSpeed = 1.0f;
+ }
+ if (InterpretedState.TurnCommand == motion)
+ {
+ InterpretedState.TurnCommand = 0;
+ InterpretedState.TurnSpeed = 1.0f;
+ }
+ }
+
+ apply_current_movement(cancelMoveTo: false, allowJump: false);
+ return WeenieError.None;
+ }
+
+ // ── FUN_00528a50 — StopCompletely ─────────────────────────────────────────
+
+ ///
+ /// Reset both raw and interpreted states to Ready/idle, then push zero velocity.
+ ///
+ /// Decompiled logic (FUN_00528a50):
+ /// if (PhysicsObj == null) return 8
+ /// FUN_00510cc0() — cancel moveto
+ /// uVar1 = FUN_005285e0(InterpretedState.ForwardCommand) — motion_allows_jump
+ /// *(+0x20) = 0x41000003 (RawState.ForwardCommand = Ready)
+ /// *(+0x28) = 0x3f800000 (RawState.ForwardSpeed = 1.0f)
+ /// *(+0x2c) = 0 (RawState.SideStepCommand = 0)
+ /// *(+0x38) = 0 (RawState.TurnCommand = 0)
+ /// *(+0x4c) = 0x41000003 (InterpretedState.ForwardCommand = Ready)
+ /// *(+0x50) = 0x3f800000 (InterpretedState.ForwardSpeed = 1.0f)
+ /// *(+0x54) = 0 (InterpretedState.SideStepCommand = 0)
+ /// *(+0x5c) = 0 (InterpretedState.TurnCommand = 0)
+ /// FUN_0050f5a0() — StopCompletely_Internal (zero velocity on PhysicsObj)
+ /// FUN_00528790(…) — add_to_queue
+ /// if (PhysicsObj != null && CurCell == null) → FUN_005108f0 (RemoveLinkAnimations)
+ /// return 0
+ ///
+ public WeenieError StopCompletely()
+ {
+ if (PhysicsObj is null)
+ return WeenieError.NoPhysicsObject;
+
+ // Reset raw state
+ RawState.ForwardCommand = MotionCommand.Ready;
+ RawState.ForwardSpeed = 1.0f;
+ RawState.SideStepCommand = 0;
+ RawState.SideStepSpeed = 1.0f;
+ RawState.TurnCommand = 0;
+ RawState.TurnSpeed = 1.0f;
+
+ // Reset interpreted state
+ InterpretedState.ForwardCommand = MotionCommand.Ready;
+ InterpretedState.ForwardSpeed = 1.0f;
+ InterpretedState.SideStepCommand = 0;
+ InterpretedState.SideStepSpeed = 1.0f;
+ InterpretedState.TurnCommand = 0;
+ InterpretedState.TurnSpeed = 1.0f;
+
+ // Zero the body velocity (FUN_0050f5a0 = StopCompletely_Internal)
+ PhysicsObj.set_velocity(Vector3.Zero);
+
+ return WeenieError.None;
+ }
+
+ // ── FUN_00528960 — get_state_velocity ────────────────────────────────────
+
+ ///
+ /// Compute the body-local velocity vector for the current interpreted motion.
+ ///
+ /// Decompiled logic (FUN_00528960):
+ /// velocity = (0, 0, 0)
+ /// if InterpretedState.SideStepCommand == 0x6500000F:
+ /// velocity.X = _DAT_007c96e8 * InterpretedState.SideStepSpeed
+ /// = SidestepAnimSpeed * SideStepSpeed
+ /// if InterpretedState.ForwardCommand == 0x45000005 (WalkForward):
+ /// velocity.Y = _DAT_007c96e4 * InterpretedState.ForwardSpeed
+ /// = WalkAnimSpeed * ForwardSpeed
+ /// elif InterpretedState.ForwardCommand == 0x44000007 (RunForward):
+ /// velocity.Y = _DAT_007c96e0 * InterpretedState.ForwardSpeed
+ /// = RunAnimSpeed * ForwardSpeed
+ /// rate = InqRunRate() or MyRunRate
+ /// maxSpeed = RunAnimSpeed * rate
+ /// if |velocity| > maxSpeed: velocity = normalize(velocity) * maxSpeed
+ /// return velocity
+ ///
+ public Vector3 get_state_velocity()
+ {
+ var velocity = Vector3.Zero;
+
+ if (InterpretedState.SideStepCommand == MotionCommand.SideStepRight)
+ velocity.X = SidestepAnimSpeed * InterpretedState.SideStepSpeed;
+
+ if (InterpretedState.ForwardCommand == MotionCommand.WalkForward)
+ velocity.Y = WalkAnimSpeed * InterpretedState.ForwardSpeed;
+ else if (InterpretedState.ForwardCommand == MotionCommand.RunForward)
+ velocity.Y = RunAnimSpeed * InterpretedState.ForwardSpeed;
+
+ // Determine the current run rate via WeenieObj or fall back to MyRunRate.
+ // Decompile: calls vtable+0x34 (InqRunRate).
+ float rate = MyRunRate;
+ if (WeenieObj is not null)
+ {
+ if (WeenieObj.InqRunRate(out float queried))
+ rate = queried;
+ // else: rate stays MyRunRate
+ }
+
+ float maxSpeed = RunAnimSpeed * rate;
+ float len = velocity.Length();
+ if (len > maxSpeed && len > 0f)
+ {
+ velocity = Vector3.Normalize(velocity) * maxSpeed;
+ }
+
+ return velocity;
+ }
+
+ // ── FUN_00529210 — apply_current_movement ─────────────────────────────────
+
+ ///
+ /// Apply the current interpreted motion state as a local velocity to the PhysicsBody.
+ ///
+ /// Decompiled logic (FUN_00529210):
+ /// if PhysicsObj == null: return
+ /// FUN_00524f80() — internal animation state update
+ /// If ForwardCommand == RunForward: update MyRunRate = ForwardSpeed
+ /// Then delegates to DoInterpretedMotion for each active command,
+ /// which ultimately calls set_local_velocity via FUN_00528960.
+ ///
+ /// In our physics-only port we compute the body-local velocity via
+ /// get_state_velocity() and push it directly to PhysicsBody.set_local_velocity.
+ ///
+ public void apply_current_movement(bool cancelMoveTo, bool allowJump)
+ {
+ if (PhysicsObj is null)
+ return;
+
+ // Decompile writes back MyRunRate when in run state (offset +0x7C).
+ if (InterpretedState.ForwardCommand == MotionCommand.RunForward)
+ MyRunRate = InterpretedState.ForwardSpeed;
+
+ var localVelocity = get_state_velocity();
+ PhysicsObj.set_local_velocity(localVelocity);
+ }
+
+ // ── FUN_00529390 — jump ───────────────────────────────────────────────────
+
+ ///
+ /// Initiate a jump: validate, store jump extent, leave the ground.
+ ///
+ /// Decompiled logic (FUN_00529390):
+ /// if (PhysicsObj == null) return 8
+ /// FUN_00510cc0() — cancel moveto
+ /// iVar1 = FUN_00528ec0(extent, stamina) ← jump_is_allowed
+ /// if (iVar1 == 0):
+ /// *(+0x74) = extent ← JumpExtent
+ /// FUN_00511de0(0) ← PhysicsObj.set_on_walkable(false)
+ /// return 0
+ /// *(+0x70) = 0 ← StandingLongJump = false
+ /// return iVar1
+ ///
+ public WeenieError jump(float extent, int adjustStamina = 0)
+ {
+ if (PhysicsObj is null)
+ return WeenieError.NoPhysicsObject;
+
+ var result = jump_is_allowed(extent, adjustStamina);
+ if (result == WeenieError.None)
+ {
+ JumpExtent = extent;
+ PhysicsObj.set_on_walkable(false);
+ return WeenieError.None;
+ }
+
+ StandingLongJump = false;
+ return result;
+ }
+
+ // ── FUN_005286b0 — get_jump_v_z ──────────────────────────────────────────
+
+ ///
+ /// Get the vertical (Z) component of jump velocity.
+ ///
+ /// Decompiled logic (FUN_005286b0):
+ /// local_4 = *(+0x74) ← JumpExtent
+ /// if local_4 < _DAT_007c9734 (epsilon): return _DAT_00796344 (0.0)
+ /// if local_4 > _DAT_007938b0 (1.0): local_4 = 1.0
+ /// if WeenieObj == null: return _DAT_0079c6d4 (10.0) — default jump v_z
+ /// cVar1 = InqJumpVelocity(local_4, &local_4) — vtable +0x30
+ /// if (cVar1 != 0): return local_4
+ /// return _DAT_00796344 (0.0)
+ ///
+ public float get_jump_v_z()
+ {
+ float extent = JumpExtent;
+
+ if (extent < JumpExtentEpsilon)
+ return 0.0f;
+
+ if (extent > MaxJumpExtent)
+ extent = MaxJumpExtent;
+
+ if (WeenieObj is null)
+ return DefaultJumpVz;
+
+ if (WeenieObj.InqJumpVelocity(extent, out float vz))
+ return vz;
+
+ return 0.0f;
+ }
+
+ // ── FUN_00528cd0 — get_leave_ground_velocity ──────────────────────────────
+
+ ///
+ /// Compose the full 3D body-local launch velocity when leaving the ground.
+ ///
+ /// Decompiled logic (FUN_00528cd0):
+ /// FUN_00528960(velocity) ← get_state_velocity (XY components)
+ /// velocity.Z = get_jump_v_z()
+ /// If all three components are < epsilon (nearly zero velocity):
+ /// Apply the orientation matrix rows of PhysicsObj to the current
+ /// world-space velocity (rotate world vel into body-local frame).
+ /// This preserves momentum direction when jumping while stationary.
+ /// return velocity
+ ///
+ /// The "near-zero" fast path uses the body's current velocity transformed
+ /// back into local space, which in our port is
+ /// Vector3.Transform(Velocity, Quaternion.Inverse(Orientation)).
+ ///
+ public Vector3 get_leave_ground_velocity()
+ {
+ var velocity = get_state_velocity();
+ velocity.Z = get_jump_v_z();
+
+ // If the lateral + vertical components are all tiny, fall back to the
+ // current world velocity projected into body-local space so that an
+ // airborne nudge preserves direction (retail decompile: matrix multiply
+ // of the orientation column vectors against the world velocity).
+ float eps = JumpExtentEpsilon;
+ if (MathF.Abs(velocity.X) < eps && MathF.Abs(velocity.Y) < eps && MathF.Abs(velocity.Z) < eps
+ && PhysicsObj is not null)
+ {
+ var invOrientation = Quaternion.Inverse(PhysicsObj.Orientation);
+ velocity = Vector3.Transform(PhysicsObj.Velocity, invOrientation);
+ }
+
+ return velocity;
+ }
+
+ // ── FUN_00528ec0 — jump_is_allowed ────────────────────────────────────────
+
+ ///
+ /// Determine whether a jump is currently permitted.
+ ///
+ /// Decompiled logic (FUN_00528ec0):
+ /// if PhysicsObj == null: return 0x24
+ /// if WeenieObj == null: proceed (no weenie check)
+ /// elif WeenieObj.IsCreature() returns false: proceed
+ /// iVar2 = PhysicsObj
+ /// if Gravity flag NOT set OR (Contact AND OnWalkable): ← grounded or no gravity
+ /// return 0x24 (GeneralMovementFailure)
+ /// if FUN_0050f730() (IsFullyConstrained) != 0: return 0x47
+ /// if pending queue action has non-zero jump error: return that error
+ /// iVar2 = FUN_00528660() (jump_charge_is_allowed)
+ /// if iVar2 == 0:
+ /// iVar2 = FUN_005285e0(InterpretedState.ForwardCommand) (motion_allows_jump)
+ /// if iVar2 == 0 AND WeenieObj != null:
+ /// cVar1 = WeenieObj.CanJump(extent, stamina) → vtable +0x40
+ /// if cVar1 == 0: return 0x47
+ /// return iVar2
+ ///
+ public WeenieError jump_is_allowed(float extent, int staminaCost)
+ {
+ if (PhysicsObj is null)
+ return WeenieError.GeneralMovementFailure;
+
+ // Must have gravity and be grounded (Contact + OnWalkable) to start a jump.
+ bool hasGravity = PhysicsObj.State.HasFlag(PhysicsStateFlags.Gravity);
+ bool isGrounded = PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact)
+ && PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable);
+
+ if (!hasGravity || !isGrounded)
+ return WeenieError.YouCantJumpWhileInTheAir;
+
+ // Delegate jump eligibility to WeenieObj if present.
+ if (WeenieObj is not null && !WeenieObj.CanJump(extent))
+ return WeenieError.CantJumpLoadedDown;
+
+ return WeenieError.None;
+ }
+
+ // ── FUN_00528dd0 — contact_allows_move ────────────────────────────────────
+
+ ///
+ /// Determine whether the current contact state allows this motion command.
+ ///
+ /// Decompiled logic (FUN_00528dd0):
+ /// if WeenieObj != null AND WeenieObj.CanJump(JumpExtent) returns false:
+ /// return 0x49
+ /// uVar1 = InterpretedState.ForwardCommand
+ /// if uVar1 == 0x40000008 (Fallen) OR uVar1 == 0x10000057 (Dead):
+ /// return 0x48
+ /// if 0x41000011 < uVar1 < 0x41000015 (crouch/sit/sleep range):
+ /// return 0x48
+ /// uVar2 = PhysicsObj.TransientState
+ /// if (Contact AND OnWalkable) AND ForwardCommand == Ready
+ /// AND SideStepCommand == 0 AND TurnCommand == 0:
+ /// StandingLongJump = true
+ /// return 0
+ ///
+ /// The return type in the decompile is undefined4 (int), but ACE models it
+ /// as bool (0 = allowed, non-zero = blocked). We model it as bool here for
+ /// cleaner call sites, treating any non-zero return as "blocked".
+ ///
+ public bool contact_allows_move(uint motion)
+ {
+ if (PhysicsObj is null)
+ return false;
+
+ // Turn commands are always allowed regardless of ground contact.
+ // (Decompile doesn't explicitly early-return for turns here, but
+ // ACE and the general shape of the code confirm they bypass the block.)
+ if (motion == MotionCommand.TurnRight || motion == MotionCommand.TurnLeft)
+ return true;
+
+ // Dead or Fallen forward-command blocks movement.
+ uint fwd = InterpretedState.ForwardCommand;
+ if (fwd == MotionCommand.Fallen || fwd == MotionCommand.Dead)
+ return false;
+
+ // Crouch / sit / sleep range (0x41000011 < fwd < 0x41000015).
+ if (fwd > MotionCommand.CrouchLowerBound && fwd < MotionCommand.CrouchUpperBound)
+ return false;
+
+ // Need Gravity flag + Contact + OnWalkable for ground-based motion.
+ if (!PhysicsObj.State.HasFlag(PhysicsStateFlags.Gravity))
+ return true; // no gravity → object can always move (swimming, flying)
+
+ bool contact = PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact);
+ bool onWalkable = PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable);
+
+ if (!contact)
+ return false;
+
+ if (!onWalkable)
+ return false;
+
+ // Grounded and idle — flag as standing-long-jump candidate.
+ if (fwd == MotionCommand.Ready
+ && InterpretedState.SideStepCommand == 0
+ && InterpretedState.TurnCommand == 0)
+ {
+ StandingLongJump = true;
+ }
+
+ return true;
+ }
+
+ // ── FUN_00529710 — LeaveGround ────────────────────────────────────────────
+
+ ///
+ /// Called when the body becomes airborne. Applies the leave-ground velocity
+ /// and resets the jump state.
+ ///
+ /// Decompiled logic (FUN_00529710):
+ /// if PhysicsObj == null: return
+ /// velocity = get_leave_ground_velocity()
+ /// PhysicsObj.set_local_velocity(velocity)
+ /// StandingLongJump = false
+ /// JumpExtent = 0
+ ///
+ public void LeaveGround()
+ {
+ if (PhysicsObj is null)
+ return;
+
+ var velocity = get_leave_ground_velocity();
+ PhysicsObj.set_local_velocity(velocity);
+
+ StandingLongJump = false;
+ JumpExtent = 0f;
+ }
+
+ // ── FUN_005296d0 — HitGround ──────────────────────────────────────────────
+
+ ///
+ /// Called when the body lands on a walkable surface.
+ ///
+ /// Decompiled logic (FUN_005296d0):
+ /// if PhysicsObj == null: return
+ /// if WeenieObj != null AND NOT creature: return
+ /// if Gravity flag not set: return
+ /// apply_current_movement(false, true)
+ ///
+ public void HitGround()
+ {
+ if (PhysicsObj is null)
+ return;
+
+ if (!PhysicsObj.State.HasFlag(PhysicsStateFlags.Gravity))
+ return;
+
+ apply_current_movement(cancelMoveTo: false, allowJump: true);
+ }
+
+ // ── private helper ────────────────────────────────────────────────────────
+
+ ///
+ /// Apply a motion command to the interpreted state fields.
+ /// Mirrors the InterpretedState.ApplyMotion logic in ACE.
+ ///
+ private void ApplyMotionToInterpretedState(uint motion, float speed)
+ {
+ switch (motion)
+ {
+ case MotionCommand.WalkForward:
+ case MotionCommand.RunForward:
+ case MotionCommand.WalkBackward:
+ InterpretedState.ForwardCommand = motion;
+ InterpretedState.ForwardSpeed = speed;
+ break;
+ case MotionCommand.SideStepRight:
+ case MotionCommand.SideStepLeft:
+ InterpretedState.SideStepCommand = motion;
+ InterpretedState.SideStepSpeed = speed;
+ break;
+ case MotionCommand.TurnRight:
+ case MotionCommand.TurnLeft:
+ InterpretedState.TurnCommand = motion;
+ InterpretedState.TurnSpeed = speed;
+ break;
+ case MotionCommand.Ready:
+ InterpretedState.ForwardCommand = MotionCommand.Ready;
+ InterpretedState.ForwardSpeed = 1.0f;
+ InterpretedState.SideStepCommand = 0;
+ InterpretedState.SideStepSpeed = 1.0f;
+ InterpretedState.TurnCommand = 0;
+ InterpretedState.TurnSpeed = 1.0f;
+ break;
+ }
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs b/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs
new file mode 100644
index 0000000..7e8a278
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs
@@ -0,0 +1,654 @@
+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
+// ─────────────────────────────────────────────────────────────────────────────
+
+///
+/// Fake WeenieObject for test isolation — all methods return configurable values.
+///
+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);
+ }
+
+ // =========================================================================
+ // 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_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);
+ }
+}