feat(core): port decompiled AC client MotionInterpreter — 10 methods, 45 tests

C# port of CMotionInterp from chunk_00520000.c — the AC client's
motion state machine that controls walk/run/jump/turn.

Methods ported:
- PerformMovement: top-level 5-case dispatcher
- DoMotion: process raw motion command from packet
- StopCompletely: reset to Ready (0x41000003)
- get_state_velocity: compute velocity for current interpreted state
- apply_current_movement: push velocity to PhysicsBody
- jump: initiate jump (validate + set extent + leave ground)
- get_jump_v_z: vertical jump velocity (delegates to WeenieObj)
- get_leave_ground_velocity: full 3D launch vector
- jump_is_allowed: requires Gravity + Contact + OnWalkable
- contact_allows_move: slope angle + state checks

Supporting types: MotionCommand constants, MovementType enum,
WeenieError enum, RawMotionState/InterpretedMotionState structs,
IWeenieObject interface.

412 total tests (303 core + 109 net). 45 new MotionInterpreter tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-13 00:00:39 +02:00
parent 6a5d8c1580
commit e3f8f95dfc
2 changed files with 1496 additions and 0 deletions

View file

@ -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) ────────────────
/// <summary>
/// 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.
/// </summary>
public static class MotionCommand
{
/// <summary>0x41000003 — idle/default state.</summary>
public const uint Ready = 0x41000003u;
/// <summary>0x45000005 — walk forward.</summary>
public const uint WalkForward = 0x45000005u;
/// <summary>0x44000007 — run forward.</summary>
public const uint RunForward = 0x44000007u;
/// <summary>0x45000006 — walk backward.</summary>
public const uint WalkBackward = 0x45000006u;
/// <summary>0x6500000D — turn right.</summary>
public const uint TurnRight = 0x6500000Du;
/// <summary>0x6500000E — turn left.</summary>
public const uint TurnLeft = 0x6500000Eu;
/// <summary>0x6500000F — sidestep right.</summary>
public const uint SideStepRight = 0x6500000Fu;
/// <summary>0x65000010 — sidestep left.</summary>
public const uint SideStepLeft = 0x65000010u;
/// <summary>0x40000008 — Fallen (lying on ground).</summary>
public const uint Fallen = 0x40000008u;
/// <summary>0x10000057 — Dead.</summary>
public const uint Dead = 0x10000057u;
/// <summary>0x41000011 — Crouch lower bound for blocked-jump check.</summary>
public const uint CrouchLowerBound = 0x41000011u;
/// <summary>0x41000014 — upper bound of crouch/sit/sleep range.</summary>
public const uint CrouchUpperBound = 0x41000014u;
}
/// <summary>
/// Movement type passed in PerformMovement's switch statement.
/// Matches the 5-case switch at FUN_00529a90.
/// </summary>
public enum MovementType
{
/// <summary>case 1 — raw motion command (DoMotion).</summary>
RawCommand = 1,
/// <summary>case 2 — interpreted motion command (DoInterpretedMotion).</summary>
InterpretedCommand = 2,
/// <summary>case 3 — stop raw motion (StopMotion).</summary>
StopRawCommand = 3,
/// <summary>case 4 — stop interpreted motion (StopInterpretedMotion).</summary>
StopInterpretedCommand = 4,
/// <summary>case 5 — stop completely (StopCompletely).</summary>
StopCompletely = 5,
}
/// <summary>
/// WeenieError codes returned by CMotionInterp methods.
/// Values are the hex constants used directly in the decompiled C code.
/// </summary>
public enum WeenieError : uint
{
/// <summary>0x00 — success.</summary>
None = 0x00,
/// <summary>0x08 — PhysicsObj is null.</summary>
NoPhysicsObject = 0x08,
/// <summary>0x24 — general movement failure.</summary>
GeneralMovementFailure = 0x24,
/// <summary>0x47 — cannot jump from this position (motion state blocks it).</summary>
YouCantJumpFromThisPosition = 0x47,
/// <summary>0x48 — cannot jump while in the air.</summary>
YouCantJumpWhileInTheAir = 0x48,
/// <summary>0x49 — loaded down / weenie blocked the jump.</summary>
CantJumpLoadedDown = 0x49,
}
// ── Motion state structs ───────────────────────────────────────────────────────
/// <summary>
/// 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.).
/// </summary>
public struct RawMotionState
{
/// <summary>Forward/backward motion command (offset +0x20).</summary>
public uint ForwardCommand;
/// <summary>Speed scalar for forward motion (offset +0x28).</summary>
public float ForwardSpeed;
/// <summary>Sidestep command (offset +0x2C).</summary>
public uint SideStepCommand;
/// <summary>Speed scalar for sidestep (offset +0x34, inferred from ACE).</summary>
public float SideStepSpeed;
/// <summary>Turn command (offset +0x38).</summary>
public uint TurnCommand;
/// <summary>Speed scalar for turn (offset +0x40, inferred).</summary>
public float TurnSpeed;
/// <summary>Initialize to the idle/ready state (1.0 speed, Ready command).</summary>
public static RawMotionState Default() => new()
{
ForwardCommand = MotionCommand.Ready,
ForwardSpeed = 1.0f,
SideStepCommand = 0,
SideStepSpeed = 1.0f,
TurnCommand = 0,
TurnSpeed = 1.0f,
};
}
/// <summary>
/// Interpreted motion state, derived from the raw state.
/// Struct layout: starts at offset +0x44 (ForwardCommand at +0x4C, ForwardSpeed at +0x50).
/// </summary>
public struct InterpretedMotionState
{
/// <summary>Forward/backward interpreted command (offset +0x4C).</summary>
public uint ForwardCommand;
/// <summary>Speed scalar for interpreted forward motion (offset +0x50).</summary>
public float ForwardSpeed;
/// <summary>Sidestep interpreted command (offset +0x54).</summary>
public uint SideStepCommand;
/// <summary>Speed scalar for interpreted sidestep (offset +0x58).</summary>
public float SideStepSpeed;
/// <summary>Turn interpreted command (offset +0x5C).</summary>
public uint TurnCommand;
/// <summary>Speed scalar for turn (offset +0x60).</summary>
public float TurnSpeed;
/// <summary>Initialize to the idle/ready state.</summary>
public static InterpretedMotionState Default() => new()
{
ForwardCommand = MotionCommand.Ready,
ForwardSpeed = 1.0f,
SideStepCommand = 0,
SideStepSpeed = 1.0f,
TurnCommand = 0,
TurnSpeed = 1.0f,
};
}
/// <summary>
/// Lightweight struct passed into PerformMovement.
/// Fields correspond to what the retail dispatcher read from param_1 (the movement packet struct).
/// </summary>
public struct MovementStruct
{
/// <summary>Which of the 5 motion types to dispatch.</summary>
public MovementType Type;
/// <summary>Motion command ID (e.g. WalkForward).</summary>
public uint Motion;
/// <summary>Speed scalar for this motion.</summary>
public float Speed;
/// <summary>Autonomous (player-initiated) flag.</summary>
public bool Autonomous;
/// <summary>Whether to modify the interpreted state.</summary>
public bool ModifyInterpretedState;
/// <summary>Whether to modify the raw state.</summary>
public bool ModifyRawState;
}
// ── Optional WeenieObject interface ──────────────────────────────────────────
/// <summary>
/// Minimal interface for the server-side WeenieObject callbacks that CMotionInterp
/// reaches through at vtable offsets +0x30, +0x34, +0x3C.
/// Allows testing without a real weenie.
/// </summary>
public interface IWeenieObject
{
/// <summary>vtable +0x30 — InqJumpVelocity. Returns true and sets vz if valid.</summary>
bool InqJumpVelocity(float extent, out float vz);
/// <summary>vtable +0x34 — InqRunRate. Returns true and sets rate if valid.</summary>
bool InqRunRate(out float rate);
/// <summary>vtable +0x3C — CanJump. Returns true if the weenie can jump at this extent.</summary>
bool CanJump(float extent);
}
// ── MotionInterpreter ─────────────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
public sealed class MotionInterpreter
{
// ── animation speed constants (from ACE / confirmed by decompile globals) ─
/// <summary>Walk animation base speed (_DAT_007c96e4 family).</summary>
public const float WalkAnimSpeed = 3.12f;
/// <summary>Run animation base speed (_DAT_007c96e0 family).</summary>
public const float RunAnimSpeed = 4.0f;
/// <summary>Sidestep animation base speed (_DAT_007c96e8 family).</summary>
public const float SidestepAnimSpeed = 1.25f;
/// <summary>Minimum jump extent before get_jump_v_z bothers computing (_DAT_007c9734).</summary>
public const float JumpExtentEpsilon = 0.001f;
/// <summary>Fallback vertical jump velocity when WeenieObj is absent (_DAT_0079c6d4).</summary>
public const float DefaultJumpVz = 10.0f;
/// <summary>Maximum jump extent clamped by get_jump_v_z (_DAT_007938b0 = 1.0f).</summary>
public const float MaxJumpExtent = 1.0f;
// ── fields (matching struct layout from acclient_function_map.md) ─────────
/// <summary>The physics body this interpreter controls (struct offset +0x08).</summary>
public PhysicsBody? PhysicsObj { get; set; }
/// <summary>Optional WeenieObject for stamina / run-rate queries (struct offset +0x04).</summary>
public IWeenieObject? WeenieObj { get; set; }
/// <summary>Raw (network-derived) motion state (struct offsets +0x14..+0x44).</summary>
public RawMotionState RawState;
/// <summary>Interpreted motion state derived from raw (struct offsets +0x44..+0x7C).</summary>
public InterpretedMotionState InterpretedState;
/// <summary>Jump charge accumulator — set in jump(), cleared in LeaveGround() (offset +0x74).</summary>
public float JumpExtent;
/// <summary>Stored run rate from last successful InqRunRate call (offset +0x7C).</summary>
public float MyRunRate = 1.0f;
/// <summary>True when crouching-in-place for a standing long jump (offset +0x70).</summary>
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 ────────────────────────────────────────
/// <summary>
/// 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)
/// </summary>
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 ───────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
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 ────────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
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 ────────────────────────────────────────────────────────────
/// <summary>
/// Stop a specific raw motion (FUN_00529140 → StopInterpretedMotion).
/// </summary>
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 ────────────────────────────────────────────────
/// <summary>
/// Stop a specific interpreted motion (FUN_00529080).
/// </summary>
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 ─────────────────────────────────────────
/// <summary>
/// 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
/// </summary>
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 ────────────────────────────────────
/// <summary>
/// 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
/// </summary>
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 ─────────────────────────────────
/// <summary>
/// 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.
/// </summary>
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 ───────────────────────────────────────────────────
/// <summary>
/// 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
/// </summary>
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 ──────────────────────────────────────────
/// <summary>
/// Get the vertical (Z) component of jump velocity.
///
/// Decompiled logic (FUN_005286b0):
/// local_4 = *(+0x74) ← JumpExtent
/// if local_4 &lt; _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, &amp;local_4) — vtable +0x30
/// if (cVar1 != 0): return local_4
/// return _DAT_00796344 (0.0)
/// </summary>
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 ──────────────────────────────
/// <summary>
/// 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 &lt; 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)).
/// </summary>
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 ────────────────────────────────────────
/// <summary>
/// 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
/// </summary>
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 ────────────────────────────────────
/// <summary>
/// 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 &lt; uVar1 &lt; 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".
/// </summary>
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 ────────────────────────────────────────────
/// <summary>
/// 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
/// </summary>
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 ──────────────────────────────────────────────
/// <summary>
/// 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)
/// </summary>
public void HitGround()
{
if (PhysicsObj is null)
return;
if (!PhysicsObj.State.HasFlag(PhysicsStateFlags.Gravity))
return;
apply_current_movement(cancelMoveTo: false, allowJump: true);
}
// ── private helper ────────────────────────────────────────────────────────
/// <summary>
/// Apply a motion command to the interpreted state fields.
/// Mirrors the InterpretedState.ApplyMotion logic in ACE.
/// </summary>
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;
}
}
}

View file

@ -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
// ─────────────────────────────────────────────────────────────────────────────
/// <summary>
/// Fake WeenieObject for test isolation — all methods return configurable values.
/// </summary>
file sealed class FakeWeenie : IWeenieObject
{
public float RunRate = 1.0f;
public float JumpVz = 10.0f;
public bool CanJumpResult = true;
public bool InqRunRateResult = true;
public bool InqJumpVelocityResult = true;
public bool InqJumpVelocity(float extent, out float vz)
{
vz = JumpVz * extent;
return InqJumpVelocityResult;
}
public bool InqRunRate(out float rate)
{
rate = RunRate;
return InqRunRateResult;
}
public bool CanJump(float extent) => CanJumpResult;
}
public sealed class MotionInterpreterTests
{
// ── helpers ───────────────────────────────────────────────────────────────
private static PhysicsBody MakeGrounded()
{
var body = new PhysicsBody
{
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
};
body.TransientState = TransientStateFlags.Contact
| TransientStateFlags.OnWalkable
| TransientStateFlags.Active;
return body;
}
private static PhysicsBody MakeAirborne()
{
var body = new PhysicsBody
{
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
};
body.TransientState = TransientStateFlags.Active;
return body;
}
private static MotionInterpreter MakeInterp(PhysicsBody? body = null, IWeenieObject? weenie = null)
{
body ??= MakeGrounded();
return new MotionInterpreter(body, weenie);
}
// =========================================================================
// PerformMovement (FUN_00529a90) — dispatcher
// =========================================================================
[Theory]
[InlineData(MovementType.RawCommand)]
[InlineData(MovementType.InterpretedCommand)]
[InlineData(MovementType.StopRawCommand)]
[InlineData(MovementType.StopInterpretedCommand)]
[InlineData(MovementType.StopCompletely)]
public void PerformMovement_ValidTypes_ReturnNone(MovementType type)
{
var interp = MakeInterp();
var mvs = new MovementStruct
{
Type = type,
Motion = MotionCommand.WalkForward,
Speed = 1.0f,
ModifyInterpretedState = true,
ModifyRawState = false,
};
var result = interp.PerformMovement(mvs);
Assert.Equal(WeenieError.None, result);
}
[Fact]
public void PerformMovement_InvalidType_ReturnsGeneralFailure()
{
var interp = MakeInterp();
var mvs = new MovementStruct { Type = (MovementType)99, Motion = MotionCommand.WalkForward };
var result = interp.PerformMovement(mvs);
Assert.Equal(WeenieError.GeneralMovementFailure, result);
}
[Fact]
public void PerformMovement_StopCompletely_ResetsToReady()
{
var interp = MakeInterp();
interp.RawState.ForwardCommand = MotionCommand.WalkForward;
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
var mvs = new MovementStruct { Type = MovementType.StopCompletely };
interp.PerformMovement(mvs);
Assert.Equal(MotionCommand.Ready, interp.RawState.ForwardCommand);
Assert.Equal(MotionCommand.Ready, interp.InterpretedState.ForwardCommand);
}
[Fact]
public void PerformMovement_NullPhysicsObj_ReturnsNoPhysicsObject()
{
var interp = new MotionInterpreter(); // no PhysicsObj
var mvs = new MovementStruct { Type = MovementType.RawCommand, Motion = MotionCommand.WalkForward };
var result = interp.PerformMovement(mvs);
Assert.Equal(WeenieError.NoPhysicsObject, result);
}
// =========================================================================
// DoMotion (FUN_00529930) — raw motion command sets RawState
// =========================================================================
[Fact]
public void DoMotion_WalkForward_SetsRawForwardCommand()
{
var interp = MakeInterp();
interp.DoMotion(MotionCommand.WalkForward, 0.8f);
Assert.Equal(MotionCommand.WalkForward, interp.RawState.ForwardCommand);
Assert.Equal(0.8f, interp.RawState.ForwardSpeed, precision: 5);
}
[Fact]
public void DoMotion_NullPhysicsObj_ReturnsNoPhysicsObject()
{
var interp = new MotionInterpreter();
var result = interp.DoMotion(MotionCommand.WalkForward);
Assert.Equal(WeenieError.NoPhysicsObject, result);
}
// =========================================================================
// StopCompletely (FUN_00528a50)
// =========================================================================
[Fact]
public void StopCompletely_ResetsRawAndInterpretedToReady()
{
var interp = MakeInterp();
interp.RawState.ForwardCommand = MotionCommand.WalkForward;
interp.RawState.SideStepCommand = MotionCommand.SideStepRight;
interp.RawState.TurnCommand = MotionCommand.TurnRight;
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
interp.InterpretedState.TurnCommand = MotionCommand.TurnLeft;
var result = interp.StopCompletely();
Assert.Equal(WeenieError.None, result);
Assert.Equal(MotionCommand.Ready, interp.RawState.ForwardCommand);
Assert.Equal(1.0f, interp.RawState.ForwardSpeed, precision: 5);
Assert.Equal(0u, interp.RawState.SideStepCommand);
Assert.Equal(0u, interp.RawState.TurnCommand);
Assert.Equal(MotionCommand.Ready, interp.InterpretedState.ForwardCommand);
Assert.Equal(1.0f, interp.InterpretedState.ForwardSpeed, precision: 5);
Assert.Equal(0u, interp.InterpretedState.SideStepCommand);
Assert.Equal(0u, interp.InterpretedState.TurnCommand);
}
[Fact]
public void StopCompletely_ZerosPhysicsBodyVelocity()
{
var body = MakeGrounded();
body.set_velocity(new Vector3(5f, 3f, 0f));
var interp = MakeInterp(body);
interp.StopCompletely();
Assert.Equal(Vector3.Zero, body.Velocity);
}
[Fact]
public void StopCompletely_NullPhysicsObj_ReturnsNoPhysicsObject()
{
var interp = new MotionInterpreter();
var result = interp.StopCompletely();
Assert.Equal(WeenieError.NoPhysicsObject, result);
}
// =========================================================================
// get_state_velocity (FUN_00528960)
// =========================================================================
[Fact]
public void GetStateVelocity_Idle_ReturnsZero()
{
var interp = MakeInterp();
// Default interpreted state = Ready, no sidestep, no turn
var vel = interp.get_state_velocity();
Assert.Equal(Vector3.Zero, vel);
}
[Fact]
public void GetStateVelocity_WalkForward_ReturnsWalkSpeed()
{
var interp = MakeInterp();
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
interp.InterpretedState.ForwardSpeed = 1.0f;
var vel = interp.get_state_velocity();
// Y = WalkAnimSpeed * 1.0 = 3.12
Assert.Equal(0f, vel.X, precision: 5);
Assert.Equal(MotionInterpreter.WalkAnimSpeed, vel.Y, precision: 4);
Assert.Equal(0f, vel.Z, precision: 5);
}
[Fact]
public void GetStateVelocity_RunForward_ReturnsRunSpeed()
{
var interp = MakeInterp();
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
interp.InterpretedState.ForwardSpeed = 1.0f;
var vel = interp.get_state_velocity();
// Y = RunAnimSpeed * 1.0 = 4.0
Assert.Equal(MotionInterpreter.RunAnimSpeed, vel.Y, precision: 4);
}
[Fact]
public void GetStateVelocity_SidestepRight_ReturnsPositiveX()
{
var interp = MakeInterp();
interp.InterpretedState.SideStepCommand = MotionCommand.SideStepRight;
interp.InterpretedState.SideStepSpeed = 1.0f;
var vel = interp.get_state_velocity();
Assert.Equal(MotionInterpreter.SidestepAnimSpeed, vel.X, precision: 4);
Assert.Equal(0f, vel.Y, precision: 5);
}
[Fact]
public void GetStateVelocity_ClampsToMaxSpeed_WhenExceedRunRate()
{
// Speed=10 for walk would produce velocity.Y = 3.12*10 = 31.2, well over
// RunAnimSpeed * runRate = 4.0 * 1.0 = 4.0; must be clamped.
var interp = MakeInterp();
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
interp.InterpretedState.ForwardSpeed = 10.0f;
var vel = interp.get_state_velocity();
float maxSpeed = MotionInterpreter.RunAnimSpeed * interp.MyRunRate;
Assert.True(vel.Length() <= maxSpeed + 1e-4f, $"velocity {vel.Length()} exceeds maxSpeed {maxSpeed}");
}
[Fact]
public void GetStateVelocity_UsesWeenieRunRate()
{
var weenie = new FakeWeenie { RunRate = 2.0f };
var interp = MakeInterp(weenie: weenie);
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
interp.InterpretedState.ForwardSpeed = 1.0f;
var vel = interp.get_state_velocity();
// maxSpeed = 4.0 * 2.0 = 8.0; velocity.Y = RunAnimSpeed * 1.0 = 4.0 ≤ 8.0
Assert.Equal(MotionInterpreter.RunAnimSpeed, vel.Y, precision: 4);
}
// =========================================================================
// 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);
}
}