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); + } +}