using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics.Motion;
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 GetJumpVZ — get vertical jump velocity (R3-W3 rename)
// FUN_00528cd0 GetLeaveGroundVelocity — compose full 3D launch vector (R3-W3 rename)
// FUN_00528ec0 jump_is_allowed — can we jump? (R3-W3: full verbatim chain)
// FUN_00528dd0 contact_allows_move — slope angle / contact state check
//
// R3-W3 jump-family addresses (docs/research/2026-07-02-r3-motioninterp/):
// 0x00527a50 JumpChargeIsAllowed
// 0x005281c0 ChargeJump — the ONLY place StandingLongJump arms (closes J6)
//
// 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;
///
/// 0x40000015 — Falling (SubState). The airborne cycle. Retail's
/// MotionTable has Links from RunForward/Ready/WalkForward → Falling,
/// and a Cycles entry for (style, Falling) that loops while the body
/// is in the air. Swap via
/// when airborne; swap back to Ready/WalkForward/RunForward on land.
///
public const uint Falling = 0x40000015u;
///
/// 0x2500003B — Jump (Modifier flag). NOT an animation trigger; retail
/// uses this as a state flag internally. Kept for future use.
///
public const uint Jump = 0x2500003Bu;
///
/// 0x1000004B — Jumpup (Action). Not present in the humanoid player
/// motion table's Links dict (empirically verified). Retail uses the
/// Falling SubState for airborne animation instead.
///
public const uint Jumpup = 0x1000004Bu;
///
/// 0x10000050 — FallDown (Action). Same story as Jumpup; not in the
/// humanoid motion table's Links. Landing returns to Ready via the
/// regular SetCycle transition.
///
public const uint FallDown = 0x10000050u;
/// 0x40000011 - persistent dead substate.
public const uint Dead = 0x40000011u;
/// 0x10000057 - Sanctuary death-trigger action.
public const uint Sanctuary = 0x10000057u;
/// 0x41000012 - crouching substate.
public const uint Crouch = 0x41000012u;
/// 0x41000013 - sitting substate.
public const uint Sitting = 0x41000013u;
/// 0x41000014 - sleeping substate.
public const uint Sleeping = 0x41000014u;
/// 0x41000011 — Crouch lower bound for blocked-jump check.
public const uint CrouchLowerBound = 0x41000011u;
/// 0x41000015 - exclusive upper bound of crouch/sit/sleep range.
public const uint CrouchUpperExclusive = 0x41000015u;
}
///
/// Movement type passed in PerformMovement's switch statement. Matches
/// retail's MovementTypes::Type (acclient.h:2856, enum #229) in full.
///
///
/// R4-V1 widening (closes M11). Values 1-5 dispatch through
/// CMotionInterp (the 5-case switch at FUN_00529a90, unchanged since
/// R1-R3); values 6-9 dispatch through MoveToManager
/// (MovementManager::PerformMovement, r4-moveto-decomp.md §2b:
/// (type - 1) > 8 → 0x47, case 0-4 → CMotionInterp, case 5-8 →
/// MoveToManager). Invalid=0 and values > 9 both fail with
/// (0x47) at the
/// MovementManager level — no consumer wiring changes in this slice
/// (mechanical, additive-only; MoveToManager itself is R4-V2+).
///
///
public enum MovementType
{
///
/// 0 — no movement in progress / uninitialized. R4-V1 addition (M11).
/// MoveToManager::InitializeLocalVariables resets
/// movement_type to this value; MovementManager::PerformMovement
/// rejects it with 0x47 (§2b: (type - 1) > 8 underflows to a
/// huge unsigned value for type 0, which is always > 8).
///
Invalid = 0,
/// 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,
///
/// 6 — MoveToObject. R4-V1 addition (M11). Dispatches to
/// MoveToManager::MoveToObject (r4-moveto-decomp.md §3b); uses
/// //
/// /.
///
MoveToObject = 6,
///
/// 7 — MoveToPosition. R4-V1 addition (M11). Dispatches to
/// MoveToManager::MoveToPosition (§3c); uses
/// .
///
MoveToPosition = 7,
///
/// 8 — TurnToObject. R4-V1 addition (M11). Dispatches to
/// MoveToManager::TurnToObject (§3d); uses
/// /.
///
TurnToObject = 8,
///
/// 9 — TurnToHeading. R4-V1 addition (M11). Dispatches to
/// MoveToManager::TurnToHeading (§3e); uses
/// 's DesiredHeading.
///
TurnToHeading = 9,
}
///
/// WeenieError-shaped codes returned by CMotionInterp methods. Values are
/// the hex constants used directly in the decompiled C code.
///
///
/// R3-W1 renumber (closes J16-codes/A10). Prior to this slice, the
/// names were shuffled relative to retail's actual numeric semantics
/// (GeneralMovementFailure was assigned 0x24 and used at the
/// airborne-jump gate, but retail's 0x24 means "not grounded / no contact"
/// and retail's 0x47 is the general-movement-failure code; 0x48 meant
/// "cannot jump while in the air" here, but retail's 0x48 means "jump
/// blocked by the CURRENT MOTION OR POSITION" — a blocklist check
/// (motion_allows_jump), not an airborne check). Renumbered per the
/// definitive, exhaustively-swept table in
/// docs/research/2026-07-02-r3-motioninterp/W0-pins.md §A10 (19
/// return sites + 1 store site over raw 304908-306277 + 300150-300540).
/// These codes are LOCAL-ONLY — never serialized to the wire — so the
/// renumber is safe (verified: no consumer outside
/// MotionInterpreter.cs pattern-matches on the numeric value).
///
///
public enum WeenieError : uint
{
/// 0x00 — success.
None = 0x00,
///
/// 0x08 — no physics_obj. Sites (A10): StopCompletely @305214;
/// DoInterpretedMotion @305579; StopInterpretedMotion @305639;
/// StopMotion @305680; jump @305798; DoMotion @306165.
///
NoPhysicsObject = 0x08,
///
/// 0x0B — NoMotionInterpreter. R4-V1 addition (M12), per
/// docs/research/2026-07-03-r4-moveto/r4-moveto-decomp.md §12 constants
/// inventory (8, 0xb, 0x36, 0x37, 0x38, 0x3d, 0x47). ACE name;
/// the retail sites that store this code were not individually
/// extracted in the R4 pass (no MoveToManager consumer in this slice —
/// V1 pins the numeric value only).
///
NoMotionInterpreter = 0x0B,
///
/// 0x24 — not grounded / no contact. Sites (A10):
/// jump_is_allowed @305570 (gravity-active creature without
/// Contact+OnWalkable; also the physics_obj == null case, which
/// falls out to this same code per the A10 note — NOT 8);
/// DoInterpretedMotion @305622-305623 (action-class motion
/// blocked by contact_allows_move).
///
NotGrounded = 0x24,
///
/// 0x3f — Crouch (0x41000012) rejected while in combat stance. Site:
/// DoMotion @306196.
///
CrouchInCombatStance = 0x3f,
///
/// 0x40 — Sitting (0x41000013) rejected while in combat stance. Site:
/// DoMotion @306199.
///
SitInCombatStance = 0x40,
///
/// 0x41 — Sleeping (0x41000014) rejected while in combat stance. Site:
/// DoMotion @306202.
///
SleepInCombatStance = 0x41,
///
/// 0x42 — motion & 0x2000000 (the chat-emote bit) rejected
/// outside NonCombat (0x8000003d). Site: DoMotion @306205.
///
ChatEmoteOutsideNonCombat = 0x42,
///
/// 0x36 — ActionCancelled. R4-V1 addition (M12). Site:
/// MoveToManager::PerformMovement (r4-moveto-decomp.md §3a
/// @0052a901) — every new moveto cancels the previous one with this
/// code before dispatching; also CPhysicsObj::interrupt_current_movement
/// → MovementManager::CancelMoveTo(0x36) (§9e — the TS-36 cancel
/// entry). Per §7c, MoveToManager::CancelMoveTo's WeenieError arg
/// is NEVER READ in this build's body — kept for parity/logging only.
///
ActionCancelled = 0x36,
///
/// 0x37 — ObjectGone. R4-V1 addition (M12). Site:
/// MoveToManager::HandleUpdateTarget (§6d @307866-307867) — a
/// RETARGET delivery arrives with a non-OK target status (the target
/// object was already being tracked, then went away).
///
ObjectGone = 0x37,
///
/// 0x38 — NoObject. R4-V1 addition (M12). Site:
/// MoveToManager::HandleUpdateTarget (§6d @307857-307858) — the
/// FIRST target callback arrives with a non-OK status (the target never
/// resolved in the first place).
///
NoObject = 0x38,
///
/// 0x45 — action-queue depth cap: an action-class motion (bit
/// 0x10000000) with GetNumActions() >= 6 pending. Site:
/// DoMotion @306209.
///
ActionDepthExceeded = 0x45,
///
/// 0x47 — general movement failure. Sites (A10):
/// jump_is_allowed @305525 (IsFullyConstrained);
/// @305549+305556 (JumpStaminaCost refusal);
/// CMotionInterp::PerformMovement @306227 (dispatch type-1 > 4);
/// MovementManager::PerformMovement @300201 (dispatch type-1 > 8).
///
GeneralMovementFailure = 0x47,
///
/// 0x48 — jump BLOCKED by the current motion or position (A1: the
/// motion_allows_jump literal-range blocklist — NOT an airborne
/// check; airborne is 0x24). Sites (A10):
/// motion_allows_jump @304930; jump_charge_is_allowed
/// @304948 (Fallen or Crouch..Sleeping); charge_jump @305459
/// (same predicate); STORED (not returned) as the queue node's
/// jump_error_code in DoInterpretedMotion @305605 when
/// disable_jump_during_link is set.
///
YouCantJumpFromThisPosition = 0x48,
///
/// 0x49 — the weenie's CanJump(jump_extent) virtual refused
/// (e.g. stamina/burden gate). Sites: jump_charge_is_allowed
/// @304941; charge_jump @305454.
///
CantJumpLoadedDown = 0x49,
///
/// 0x3D — YouChargedTooFar. R4-V1 addition (M12). Site:
/// MoveToManager::HandleMoveToPosition Phase 2 arrival check
/// (r4-moveto-decomp.md §6b) — the fail_distance progress gate
/// exceeded (CheckProgressMade §5b failing for >1s AND the
/// mover overshot fail_distance). NOTE: numerically out of A10's
/// increasing order (0x3D < 0x3F/0x40/0x41/0x42/0x45) because it was
/// not part of the CMotionInterp jump-family sweep this code sits
/// beside — it belongs to the MoveToManager family instead (§7c, §12).
///
YouChargedTooFar = 0x3D,
}
// ── Motion state structs ───────────────────────────────────────────────────────
///
/// Interpreted motion state, derived from the raw state (retail
/// InterpretedMotionState, ctor 0x0051e8d0, decomp 293418-293431).
/// Struct layout: starts at offset +0x44 (ForwardCommand at +0x4C, ForwardSpeed at +0x50).
///
///
/// R3-W1 (closes J2): gains retail's action FIFO
/// (///
/// /) — previously this was
/// a flat 6-field struct with no action tracking, so
/// DoMotion's GetNumActions() >= 6 depth cap and
/// MotionDone's action-class RemoveAction pop had nothing to
/// operate on. The action list is backed by a private
/// lazily created on first mutation (defensive
/// against a bare default(InterpretedMotionState), which C# structs
/// permit even though every constructor in this file routes through
/// ).
///
///
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;
/// Current style / stance (retail current_style). Adopted from
/// the raw state's style channel; NOT part of retail's
/// InterpretedMotionState::ApplyMotion dispatch itself (that
/// writes this->current_style only via the negative-motion
/// branch — see ). Default NonCombat
/// 0x8000003D.
public uint CurrentStyle;
private List? _actions;
/// Action FIFO in retail order (oldest first). Empty (never
/// null) when read — the private field is lazily created.
public readonly IReadOnlyList Actions
=> (IReadOnlyList?)_actions ?? Array.Empty();
/// 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,
CurrentStyle = 0x8000003Du,
};
///
/// InterpretedMotionState::AddAction (0x0051e9e0, decomp
/// 293500-293527): unconditional tail-append of
/// {motion, speed, action_stamp, autonomous}. Identical shape to
/// — retail duplicates the
/// LListData append logic per state type rather than sharing it.
///
public void AddAction(uint motion, float speed, uint actionStamp, bool autonomous)
{
_actions ??= new List();
_actions.Add(new RawMotionAction(
Command: (ushort)motion,
Stamp: (ushort)actionStamp,
Autonomous: autonomous,
Speed: speed));
}
///
/// InterpretedMotionState::RemoveAction (0x0051ead0, decomp
/// 293568-293586): pop the FIFO head unconditionally, returning its
/// motion field (0 when empty).
///
public uint RemoveAction()
{
if (_actions is null || _actions.Count == 0)
return 0;
var head = _actions[0];
_actions.RemoveAt(0);
return head.Command;
}
///
/// InterpretedMotionState::GetNumActions (0x0051eb00, decomp
/// 293590-293603): count the FIFO by walking it (retail has no O(1)
/// count field on the LList).
///
public readonly uint GetNumActions() => (uint)(_actions?.Count ?? 0);
///
/// InterpretedMotionState::ApplyMotion (0x0051ea40, decomp
/// 293531-293564). Verbatim dispatch, quoted from the raw named decomp:
///
/// if (arg2 == 0x6500000d) { turn_command = arg2; turn_speed = params.speed; return; }
/// if (arg2 == 0x6500000f) { sidestep_command = arg2; sidestep_speed = params.speed; return; }
/// if ((arg2 & 0x40000000) != 0) { forward_command = arg2; forward_speed = params.speed; return; }
/// if (arg2 < 0) { forward_command = 0x41000003; current_style = arg2; return; }
/// if ((arg2 & 0x10000000) != 0)
/// AddAction(arg2, params.speed, params.action_stamp, (params.bitfield>>0xc)&1);
///
/// Note only TurnRight (0x6500000d) and SideStepRight (0x6500000f) are
/// tested here — unlike , the
/// LEFT variants (TurnLeft/SideStepLeft) are NOT separately cased;
/// retail's adjust_motion normalizes Left→Right upstream before
/// this ever runs, so this asymmetry is verbatim, not a gap.
///
/// Retail arg2 — the motion id AFTER
/// adjust_motion normalization (the ADJUSTED id, unlike
/// which takes the ORIGINAL).
/// Retail arg3 (MovementParameters const*).
public void ApplyMotion(uint motion, MovementParameters p)
{
if (motion == 0x6500000du) // TurnRight
{
TurnCommand = motion;
TurnSpeed = p.Speed;
return;
}
if (motion == 0x6500000fu) // SideStepRight
{
SideStepCommand = motion;
SideStepSpeed = p.Speed;
return;
}
if ((motion & 0x40000000u) != 0)
{
ForwardCommand = motion;
ForwardSpeed = p.Speed;
return;
}
if (motion >= 0x80000000u) // arg2 < 0 as signed int32
{
ForwardCommand = 0x41000003u;
CurrentStyle = motion;
return;
}
if ((motion & 0x10000000u) != 0)
AddAction(motion, p.Speed, p.ActionStamp, p.Autonomous);
}
///
/// InterpretedMotionState::RemoveMotion (0x0051e790, decomp
/// 293315-293340):
///
/// if (arg2 == 0x6500000d) { turn_command = 0; return; }
/// if (arg2 == 0x6500000f) { sidestep_command = 0; return; }
/// if ((arg2 & 0x40000000) == 0) {
/// if (arg2 < 0 && arg2 == current_style) current_style = 0x8000003d;
/// } else if (arg2 == forward_command) {
/// forward_command = 0x41000003; forward_speed = 1f;
/// }
///
/// Note the asymmetric range test vs
/// (which uses (arg2 - 0x6500000d) > 3 covering all four
/// turn/sidestep ids) — this one only special-cases the RIGHT variants
/// by exact equality; TurnLeft/SideStepLeft fall through to the
/// style/forward-command branch below. Verbatim, not a gap (mirrors
/// retail's own asymmetry between the two RemoveMotion bodies).
///
/// Retail arg2.
public void RemoveMotion(uint motion)
{
if (motion == 0x6500000du)
{
TurnCommand = 0;
return;
}
if (motion == 0x6500000fu)
{
SideStepCommand = 0;
return;
}
if ((motion & 0x40000000u) == 0)
{
if (motion >= 0x80000000u && motion == CurrentStyle)
CurrentStyle = 0x8000003du;
}
else if (motion == ForwardCommand)
{
ForwardCommand = 0x41000003u;
ForwardSpeed = 1f;
}
}
}
///
/// Lightweight struct passed into PerformMovement.
/// Fields correspond to what the retail dispatcher read from param_1 (the movement packet struct).
///
///
/// R4-V1 widening (closes M11). Retail's full MovementStruct
/// (acclient.h:38069, struct #4067):
///
/// struct __cppobj MovementStruct
/// {
/// MovementTypes::Type type;
/// unsigned int motion; // types 1-4 only
/// unsigned int object_id; // types 6, 8
/// unsigned int top_level_id; // types 6, 8
/// Position pos; // type 7
/// float radius; // type 6
/// float height; // type 6
/// MovementParameters *params; // types 1-4, 6-9
/// };
///
/// ///
/// // are the
/// R4-V1 additions — additive only, no consumer wiring in this slice
/// (MoveToManager itself is R4-V2). The pre-R4 fields (/
/// ///
/// /) are
/// untouched. uses acdream's
/// (ObjCellId + CellFrame) rather than retail's block-local Position —
/// V0-pins.md §P5: distances are equivalent after rebase in acdream's
/// streaming-world space.
///
///
public struct MovementStruct
{
/// Which movement type to dispatch (retail MovementTypes::Type, full 0-9 range).
public MovementType Type;
/// Motion command ID (e.g. WalkForward). Types 1-4 only.
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;
///
/// R4-V1 — retail object_id. Types 6 (MoveToObject), 8
/// (TurnToObject) only.
///
public uint ObjectId;
///
/// R4-V1 — retail top_level_id. Types 6 (MoveToObject), 8
/// (TurnToObject) only.
///
public uint TopLevelId;
///
/// R4-V1 — retail pos (world position + cell). Type 7
/// (MoveToPosition) only.
///
public Position Pos;
///
/// R4-V1 — retail radius. Type 6 (MoveToObject) only.
///
public float Radius;
///
/// R4-V1 — retail height. Type 6 (MoveToObject) only.
///
public float Height;
///
/// R4-V1 — retail params (a pointer in retail; a reference
/// here). Types 1-4 and 6-9.
///
public Motion.MovementParameters? Params;
}
// ── 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);
///
/// Retail CWeenieObject::IsCreature. Non-creature weenies bypass
/// the ground-contact gate in contact_allows_move (0x00528240),
/// the run remap in adjust_motion (wired R3-W4, closes J18 —
/// register TS-34 retired), and the /
/// creature gates. Players, NPCs, and
/// monsters are creatures — default true keeps existing implementers
/// retail-correct.
///
bool IsCreature() => true;
///
/// Retail ACCWeenieObject::IsThePlayer (vtable +0x14,
/// 0x0058C3D0): this->id == SmartBox::smartbox->player_id.
/// PINNED (W0-pins.md A3, adversarially verified) as the dual-dispatch
/// gate for apply_current_movement/ReportExhaustion/
/// SetWeenieObject/SetPhysicsObject — NOT IsCreature
/// (a remote player is a creature but not the player; ACE's server-side
/// IsCreature gate is a genuine divergence, not a reading to
/// copy). Default false; only the local player's weenie returns true.
/// No consumer in R3-W3 — the W4 dual-dispatch port is the first reader.
///
bool IsThePlayer() => false;
///
/// Retail CWeenieObject::JumpStaminaCost (vtable +0x44, referenced
/// from jump_is_allowed raw 305549-305556): given the charge
/// , returns whether the weenie can afford the
/// stamina cost of this jump and writes the cost to
/// . jump_is_allowed treats a false
/// return as (0x47).
/// Default true / cost 0 — real stamina gating stays TS-5-deferred (the
/// same register row that already covers CanJump always-true;
/// this extends it to JumpStaminaCost until stat plumbing lands).
///
bool JumpStaminaCost(float extent, out int cost)
{
cost = 0;
return true;
}
}
// ── 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.
///
///
/// R3-W2 (closes J1, J17): implements —
/// the entity's MotionTableManager (R2-Q3) binds its animation-done
/// callback here, standing in for retail's null-guarded relay chain
/// CPhysicsObj::MotionDone 0x0050fdb0 → MovementManager::MotionDone
/// 0x005242d0 → CMotionInterp::MotionDone 0x00527ec0 (r3-port-plan.md
/// §4). This adds the pending_motions queue (retail
/// CMotionInterp::pending_motions, a singly-linked LList —
/// ported as a managed per the AD-34 managed-list
/// register wording) that the funnel's dispatch/stop producers populate and
/// / drain.
///
///
public sealed class MotionInterpreter : IMotionDoneSink
{
// ── animation speed constants (from ACE / confirmed by decompile globals) ─
/// Walk animation base speed. Retail-exact (.rdata 0x007c891c =
/// 3.11999989f); unified for both and
/// 's sidestep scale in D6.2.
public const float WalkAnimSpeed = 3.11999989f;
/// 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). R3-W3 (closes J16-epsilons, A5/A6): retail's exact
/// literal is 0.000199999995f (raw @304959:
/// ((long double)0.000199999995f)), NOT the previous 0.001
/// approximation. Used by both and
/// (three times there, one per
/// axis — A6).
///
public const float JumpVzEpsilon = 0.000199999995f;
/// 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;
///
/// Backward-speed multiplier applied by when
/// remapping WalkBackward → WalkForward (retail .rdata 0x007c8910).
/// Retail-exact value; do not round to 0.65f.
///
public const float BackwardsFactor = 0.649999976f;
///
/// Turn speed multiplier applied by for
/// TurnRight when the hold key is Run (retail apply_run_to_command, no
/// run-rate scaling and no clamp — just a flat ×1.5).
///
public const float RunTurnFactor = 1.5f;
///
/// Symmetric clamp on sidestep interpreted speed applied by
/// for SideStepRight when running
/// (retail apply_run_to_command SideStepRight clamp, ±3.0).
///
public const float MaxSidestepAnimRate = 3.0f;
///
/// Retail-exact sidestep scale factor used by
/// when remapping to SideStepRight (retail SidestepFactor = 0.5).
///
public const float SidestepFactor = 0.5f;
// ── 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).
///
///
/// R3-W1 fold (closes J2): this now holds the SAME retail-faithful,
/// full-field type the outbound
/// wire packer reads from — the former flat LegacyRawMotionState
/// struct (no action FIFO, no ApplyMotion/RemoveMotion)
/// is deleted. One raw-state type end to end.
///
///
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;
///
/// The physics object's currently-held movement key (retail
/// this->raw_state.current_holdkey).
/// falls back to this property when the per-channel holdKey argument
/// passed in is . R3-W6: an ALIAS of
/// — retail's adjust_motion
/// reads raw_state.current_holdkey directly; the former shadow
/// field only synced on the raw dispatch branch, so an edge-driven
/// set_hold_run followed by DoMotion read a stale None and lost the
/// run promotion (the W6 cutover surfaced this).
///
public HoldKey CurrentHoldKey => RawState.CurrentHoldKey;
/// True when crouching-in-place for a standing long jump (offset +0x70).
public bool StandingLongJump;
///
/// R3-W2 — retail CMotionInterp::pending_motions (offset last in
/// the struct, a singly-linked LList<MotionNode>). Ported as
/// a managed — the AD-34 register wording
/// ("retail's intrusive DLList/LList is ported as a managed LinkedList of
/// value nodes; node identity semantics are preserved via
/// LinkedListNode<T> references rather than raw prev/next
/// pointers") applies here exactly as it does to
/// MotionTableManager._pendingAnimations — this is the OTHER,
/// never-merged queue (movement side, waiting for a MotionDone callback,
/// vs the manager's animation side, waiting for a tick countdown; see
/// r3-port-plan.md §4 rule 1). Exposed read-only; mutated only through
/// / /
/// .
///
private readonly LinkedList _pendingMotions = new();
/// Read-only inspection surface (tests + future callers): the
/// pending motion queue in head-to-tail order.
public IEnumerable PendingMotions => _pendingMotions;
///
/// R3-W2 no-op seam standing in for retail CPhysicsObj::unstick_from_object
/// (called from / when
/// the popped queue head is action-class, i.e. Motion & 0x10000000
/// is set). Register row: releases a "stuck to object" sticky-manager
/// attachment — R5 wires the real StickyManager; until then this is an
/// optional callback the App layer may bind, matching the existing
/// Action? seam convention (see MotionTableDispatchSink.TurnStopped).
///
public Action? UnstickFromObject { get; set; }
///
/// R3-W3 no-op seam standing in for retail CPhysicsObj::interrupt_current_movement
/// (called unconditionally at the top of , raw @305800,
/// and by per A9). Register row: retail
/// cancels any in-flight MoveToManager transition before starting a new
/// movement action; R4 wires the real cancel_moveto once
/// MoveToManager exists. Until then this is an optional callback the App
/// layer may bind, matching the seam
/// convention.
///
public Action? InterruptCurrentMovement { get; set; }
///
/// R3-W4 seam standing in for retail CPhysicsObj::RemoveLinkAnimations
/// (0x0050fe20, decomp §9 summary table) → CPartArray →
/// CSequence::remove_all_link_animations — called from
/// / (§4a/4b) whenever
/// the ground-transition body actually runs. This is retail's REAL
/// mechanism for "leaving the ground strips any pending link
/// animation so Falling engages instantly" — the K-fix18 App-side
/// forced-SetCycle(skipTransitionLink:true) hack this seam
/// replaces is deleted in the orchestrator's App-side half (register
/// row retirement rides with that deletion, not this commit — Core
/// has no K-fix18 code to remove). The App layer binds this to the
/// entity's AnimationSequencer/CSequence core
/// (RemoveAllLinkAnimations-equivalent) per entity, matching the
/// existing /
/// Action? seam convention. Optional — a null binding is a
/// silent no-op (safe default for tests and for any physics body with
/// no sequencer attached yet).
///
public Action? RemoveLinkAnimations { get; set; }
///
/// R3-W4 no-op seam standing in for retail
/// CPhysicsObj::InitializeMotionTables, called from
/// (§4d, raw @306124:
/// CPhysicsObj::InitializeMotionTables(this->physics_obj);).
/// This re-seeds the physics object's MotionTableManager/motion
/// table stack — a manager-side (App-bound) concern, not something
/// MotionInterpreter itself owns state for. Register row: no-op
/// until the App layer binds it to the entity's motion-table
/// initialization; matches the seam
/// convention.
///
public Action? InitializeMotionTables { get; set; }
///
/// R3-W5 seam standing in for retail CPhysicsObj::CheckForCompletedMotions
/// (0x0050fe30, decomp §7d @277925) → CPartArray::CheckForCompletedMotions
/// → MotionTableManager::CheckForCompletedMotions (the SECOND call
/// site of the shared animation-completion driver, §7b) — called by
/// after EVERY dispatched op (raw
/// 306234/306241/306248/306255/306262, §2/§7d), synchronously draining
/// any already-elapsed (0-duration) animation-table node right after the
/// motion is applied, in case the newly-applied motion table entry
/// completes immediately. The App layer binds this to the entity's
/// MotionTableManager.CheckForCompletedMotions (R2-Q3 object),
/// matching the existing /
/// Action? seam convention. A null
/// binding is a silent no-op (safe default for tests and for any physics
/// body with no MotionTableManager attached yet).
///
public Action? CheckForCompletedMotions { get; set; }
///
/// R3-W4 — retail CMotionInterp::initted (set by
/// , raw @306152: this->initted =
/// 1;). Gates and
/// 's entry (A3: physics_obj != 0
/// && initted != 0).
///
///
/// Constructor default is true, NOT retail's false
/// (see the final-report note on this divergence). Retail's
/// CMotionInterp is never used standalone — every real
/// construction path (MovementManager::PerformMovement's
/// lazy-create, MovementManager::Create) immediately calls
/// (retail's enter_default_state)
/// before the interpreter is exposed to any caller, so initted
/// is always 1 by the time application code can observe it.
/// acdream's MotionInterpreter constructor is used directly by
/// ~40 pre-existing tests AND by both App call sites
/// (PlayerMovementController, GameWindow) as a
/// complete, immediately-usable object with no separate
/// "enter default state" step — porting retail's literal
/// false default would silently no-op every one of those
/// call sites' apply_current_movement/ReportExhaustion
/// calls until something remembers to call
/// (which nothing currently does).
/// Defaulting true here is the C# equivalent of "the
/// constructor already did what enter_default_state would have
/// done to this flag" — remains the
/// verbatim, fully-testable retail method for callers (tests, and a
/// future App reset-to-idle path) that need the REST of its behavior
/// (state reset, sentinel enqueue, tail).
///
///
public bool Initted { get; set; } = true;
///
/// Optional accessor for the owning entity's current animation cycle
/// velocity (AnimationSequencer.CurrentVelocity, i.e. MotionData.Velocity
/// scaled by speedMod). When wired,
/// uses it as the primary forward-axis drive instead of the hardcoded
/// / constants.
///
///
/// Why: the decompiled get_state_velocity (FUN_00528960)
/// literally computes RunAnimSpeed * ForwardSpeed. That works in
/// retail because retail's Humanoid MotionTable happens to bake
/// MotionData.Velocity == RunAnimSpeed (4.0) for the RunForward
/// cycle — so the constant and the dat data agree. For MotionTables
/// where they disagree (other creatures; swapped weapon-style cycles;
/// modded dats), the constant causes the body's world velocity to
/// drift away from the animation's baked-in root-motion velocity,
/// producing the classic "legs cycle too slowly for how fast the body
/// is sliding" visual bug.
///
///
///
/// Per docs/research/deepdives/r03-motion-animation.md §1.3,
/// the retail animation pipeline treats MotionData.Velocity *
/// speedMod as the canonical per-cycle world velocity. The
/// constant survives in our port only as
/// the max-speed clamp (see below), which matches the decompile's
/// if (|velocity| > RunAnimSpeed * rate) guard.
///
///
///
/// Call site: PlayerMovementController.AttachCycleVelocityAccessor
/// wires this to AnimatedEntity.Sequencer.CurrentVelocity once
/// the player's sequencer is built. Null = fall back to the decompiled
/// constant-based path (used by tests and by any physics body with
/// no sequencer).
///
///
public Func? GetCycleVelocity { get; set; }
// ── constructor ────────────────────────────────────────────────────────────
public MotionInterpreter()
{
RawState = new RawMotionState();
InterpretedState = InterpretedMotionState.Default();
}
public MotionInterpreter(PhysicsBody physicsObj, IWeenieObject? weenieObj = null)
{
PhysicsObj = physicsObj;
WeenieObj = weenieObj;
RawState = new RawMotionState();
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_0050fe30 — CPhysicsObj::CheckForCompletedMotions, called after EVERY
/// dispatched op (raw 306234/306241/306248/306255/306262, R3-W5 closes J14).
///
public WeenieError PerformMovement(MovementStruct mvs)
{
var p = new MovementParameters
{
Speed = mvs.Speed,
ModifyInterpretedState = mvs.ModifyInterpretedState,
ModifyRawState = mvs.ModifyRawState,
};
bool dispatched = true;
WeenieError result = mvs.Type switch
{
MovementType.RawCommand => DoMotion(mvs.Motion, p),
MovementType.InterpretedCommand => DoInterpretedMotion(mvs.Motion, p),
MovementType.StopRawCommand => StopMotion(mvs.Motion, p),
MovementType.StopInterpretedCommand => StopInterpretedMotion(mvs.Motion, p),
MovementType.StopCompletely => StopCompletely(),
_ => Invalid(out dispatched),
};
// R3-W5 (closes J14): CPhysicsObj::CheckForCompletedMotions fires
// after EVERY dispatched op — a synchronous zero-tick flush so a
// newly-applied motion-table entry that completes IMMEDIATELY
// (0-duration node) gets its MotionDone callback in the same call,
// not on the next tick. The type-dispatch failure path (bad
// MovementStruct.type, 0x47) never reaches a dispatch, so it must
// NOT flush — matches retail's raw 306227 early-return before the
// switch even begins.
if (dispatched)
CheckForCompletedMotions?.Invoke();
return result;
static WeenieError Invalid(out bool dispatched)
{
dispatched = false;
return WeenieError.GeneralMovementFailure;
}
}
// ── FUN_00529930 — DoMotion ───────────────────────────────────────────────
///
/// CMotionInterp::DoMotion (0x00528d20, decomp §2 @306159, FULL
/// BODY, R3-W5, closes J3). App-facing 2-arg compat overload —
/// PlayerMovementController's call sites pass a raw
/// (uint motion, float speed) pair; this builds a
/// default-constructed (retail's own
/// UnPack/CommandInterpreter callers build one the same way for a bare
/// raw command) with overridden,
/// then delegates to the verbatim overload below.
///
public WeenieError DoMotion(uint motion, float speed = 1.0f)
=> DoMotion(motion, new MovementParameters { Speed = speed });
///
/// CMotionInterp::DoMotion (0x00528d20, decomp §2 @306159, FULL
/// BODY, R3-W5, closes J3).
///
///
/// Verbatim (raw 306159-306221):
///
/// if (physics_obj == 0) return 8;
/// ebp = arg2; // ORIGINAL motion id, unmutated
/// snapshot every MovementParameters field onto the stack;
/// var_2c = fresh local MovementParameters(); // re-default
/// if (bitfield sign bit) interrupt_current_movement(physics_obj);
/// if (bitfield & 0x800 /*SetHoldKey*/)
/// SetHoldKey(hold_key_to_apply, (bitfield>>0xf)&1 /*cancel_moveto bit, A4*/);
/// adjust_motion(&arg2, &speed, hold_key_to_apply); // mutates arg2/speed in place
/// if (interpreted_state.current_style != 0x8000003d) { // combat/special stance
/// if (ebp == 0x41000012) return 0x3f;
/// if (ebp == 0x41000013) return 0x40;
/// if (ebp == 0x41000014) return 0x41;
/// if (ebp & 0x2000000) return 0x42;
/// }
/// if ((ebp & 0x10000000) && GetNumActions() >= 6) return 0x45;
/// result = DoInterpretedMotion(arg2 /*ADJUSTED*/, &var_2c /*fresh local params*/);
/// if (result == 0 && bitfield & 0x2000 /*ModifyRawState*/)
/// RawMotionState::ApplyMotion(ebp /*ORIGINAL*/, arg3 /*CALLER's params, not var_2c*/);
/// return result;
///
///
///
///
/// Two mirror-discipline details, both verbatim: (1) the
/// call
/// receives a FRESH local (re-defaulted,
/// only Speed/HoldKeyToApply carried through
/// adjust_motion's mutation) — the caller's own
/// distance/heading/fail-distance fields are
/// discarded for the interpreted call, matching retail's explicit
/// re-construction of var_2c. (2) the raw-state mirror at the
/// bottom reads the ORIGINAL arg3 () — NOT
/// var_2c — for /
/// /,
/// per RawMotionState::ApplyMotion's own signature
/// (const MovementParameters*) receiving the same pointer
/// DoMotion was called with.
///
///
/// Retail arg2 (the ORIGINAL, pre-adjustment
/// motion id — ebp).
/// Retail arg3.
public WeenieError DoMotion(uint motion, MovementParameters p)
{
if (PhysicsObj is null)
return WeenieError.NoPhysicsObject;
uint originalMotion = motion;
float speed = p.Speed;
var local = new MovementParameters(); // var_2c — fresh re-default
if (p.CancelMoveTo) // bitfield high-byte sign bit
InterruptCurrentMovement?.Invoke();
if (p.SetHoldKey) // bitfield & 0x800
SetHoldKey(p.HoldKeyToApply, p.CancelMoveTo); // A4: 2nd arg IS the cancel_moveto bit
adjust_motion(ref motion, ref speed, p.HoldKeyToApply); // mutates motion/speed in place
if (InterpretedState.CurrentStyle != 0x8000003du) // not MotionStance_NonCombat
{
if (originalMotion == MotionCommand.Crouch)
return WeenieError.CrouchInCombatStance; // 0x3f
if (originalMotion == MotionCommand.Sitting)
return WeenieError.SitInCombatStance; // 0x40
if (originalMotion == MotionCommand.Sleeping)
return WeenieError.SleepInCombatStance; // 0x41
if ((originalMotion & 0x2000000u) != 0)
return WeenieError.ChatEmoteOutsideNonCombat; // 0x42
}
if ((originalMotion & 0x10000000u) != 0 && InterpretedState.GetNumActions() >= 6)
return WeenieError.ActionDepthExceeded; // 0x45
local.Speed = speed;
local.HoldKeyToApply = p.HoldKeyToApply;
WeenieError result = DoInterpretedMotion(motion, local); // ADJUSTED id, fresh local params
if (result == WeenieError.None && p.ModifyRawState) // bitfield & 0x2000
RawState.ApplyMotion(originalMotion, p); // ORIGINAL id, CALLER's params (arg3, not var_2c)
return result;
}
// ── StopMotion ────────────────────────────────────────────────────────────
///
/// App-facing compat overload for StopMotion (0x00528530, decomp
/// §5b @305674). Builds a default-constructed
/// and delegates to the verbatim overload below.
///
public WeenieError StopMotion(uint motion)
=> StopMotion(motion, new MovementParameters());
///
/// CMotionInterp::StopMotion (0x00528530, decomp §5b @305674, FULL
/// BODY, R3-W5, closes J3's StopMotion leg). The DoMotion mirror:
/// same defaulting/reconstruction pattern, same optional
/// interrupt_current_movement, same adjust_motion
/// reinterpretation — but NO combat-stance or action-depth gating (those
/// are DoMotion-only).
///
///
/// Verbatim (raw 305674-305706): snapshot fields,
/// build a fresh local , optional
/// interrupt on the sign bit, adjust_motion(&arg3, &speed,
/// hold_key_to_apply) (mutates the motion id in place — note retail
/// reuses the arg3 register slot for the motion id here, not a
/// separate local), delegate to
/// StopInterpretedMotion(adjusted, &local); on success AND
/// bit 0x2000 (ModifyRawState), mirror via
/// RawMotionState::RemoveMotion(ORIGINAL motion) — using the
/// ORIGINAL unmutated id, matching DoMotion's equivalent
/// ApplyMotion(ebp, arg3) mirror discipline.
///
///
/// Retail arg2 (the ORIGINAL motion id).
/// Retail arg3.
public WeenieError StopMotion(uint motion, MovementParameters p)
{
if (PhysicsObj is null)
return WeenieError.NoPhysicsObject;
uint originalMotion = motion;
float speed = p.Speed;
var local = new MovementParameters(); // fresh re-default
if (p.CancelMoveTo)
InterruptCurrentMovement?.Invoke();
adjust_motion(ref motion, ref speed, p.HoldKeyToApply);
local.Speed = speed;
local.HoldKeyToApply = p.HoldKeyToApply;
WeenieError result = StopInterpretedMotion(motion, local); // ADJUSTED id
if (result == WeenieError.None && p.ModifyRawState)
RawState.RemoveMotion(originalMotion); // ORIGINAL id
return result;
}
// ── FUN_00527e40 — StopCompletely ─────────────────────────────────────────
///
/// CMotionInterp::StopCompletely (0x00527e40, decomp §5a @305208,
/// FULL BODY, R3-W5, closes J9). W0-pins.md A9 — ported INCLUDING the
/// jump-snapshot quirk.
///
///
/// Verbatim (raw 305208-305236):
///
/// if (physics_obj == 0) return 8;
/// interrupt_current_movement(physics_obj);
/// eax_2 = motion_allows_jump(interpreted_state.forward_command); // OLD fwd, BEFORE overwrite
/// raw_state.forward_command = 0x41000003; raw_state.forward_speed = 1f;
/// raw_state.sidestep_command = 0; raw_state.turn_command = 0;
/// interpreted_state.forward_command = 0x41000003; interpreted_state.forward_speed = 1f;
/// interpreted_state.sidestep_command = 0; interpreted_state.turn_command = 0;
/// StopCompletely_Internal(physics_obj);
/// add_to_queue(0, 0x41000003, eax_2); // eax_2 = the PRE-overwrite snapshot
/// if (physics_obj != 0 && physics_obj->cell == 0) RemoveLinkAnimations(physics_obj);
/// return 0;
///
///
///
///
/// A9 quirk: motion_allows_jump is evaluated on the OLD
/// interpreted_state.forward_command BEFORE it gets overwritten
/// two lines later — the queued node's JumpErrorCode reflects the
/// PRE-stop command, not the post-stop Ready command (which would always
/// pass). J9: retail touches ONLY the forward command/speed plus
/// the sidestep/turn COMMANDS — it does NOT write sidestep_speed
/// or turn_speed on either state. The pre-R3 acdream divergence
/// (unconditional 1.0 speed resets on all four speed fields) is REMOVED
/// here.
///
///
///
/// StopCompletely_Internal (0x0050f5a0, CPhysicsObj-side) stands
/// in as (Zero) — register row,
/// kept (full port is physics-layer scope, r3-port-plan.md §2 KEEP
/// LIST). CurCell proxy: .ObjCellId
/// == 0 (unseeded/detached body — matches cell == 0).
///
///
public WeenieError StopCompletely()
{
if (PhysicsObj is null)
return WeenieError.NoPhysicsObject;
InterruptCurrentMovement?.Invoke();
// A9: snapshot BEFORE the overwrite below.
WeenieError jumpSnapshot = MotionAllowsJump(InterpretedState.ForwardCommand);
// J9: forward cmd/speed + sidestep/turn COMMANDS only — speeds untouched.
RawState.ForwardCommand = MotionCommand.Ready;
RawState.ForwardSpeed = 1.0f;
RawState.SidestepCommand = 0;
RawState.TurnCommand = 0;
InterpretedState.ForwardCommand = MotionCommand.Ready;
InterpretedState.ForwardSpeed = 1.0f;
InterpretedState.SideStepCommand = 0;
InterpretedState.TurnCommand = 0;
// StopCompletely_Internal — R4-V5 wedge-fix CORRECTION of the R3
// misread: retail's body (0x0050ead0, tailcall
// CPartArray::StopCompletelyInternal 0x00518890) is
// MotionTableManager::PerformMovement(type 5) through the ANIMATION
// stack — NOT a physics-side velocity zero (the "0x0050f5a0" the R3
// note cited is a different function's interior). The manager
// queues its type-5 pending_animations entry UNCONDITIONALLY, and
// that entry's AnimationDone → MotionDone is the matched pop for
// the A9 pending_motions node queued below. Without this dispatch
// every StopCompletely (login/teleport SetPosition + every
// MoveToManager PerformMovement head) left an orphan node,
// MotionsPending never drained at idle, and BeginTurnToHeading's
// wait-for-anims gate wedged every armed moveto (the 2026-07-03
// live door bug — server walked the player, the local body never
// moved, rubber-band on the next input).
DefaultSink?.StopCompletely();
// Immediate velocity zero: kept from the R3 stand-in. Retail
// reaches zero through the Ready state's next velocity apply;
// acdream's controller altitude performs that apply in its grounded
// section-2 write, so the immediate zero preserves the established
// teleport/stop behavior for same-frame readers.
PhysicsObj.set_velocity(Vector3.Zero);
AddToQueue(contextId: 0, MotionCommand.Ready, (uint)jumpSnapshot);
// R4-V5 door-swing fix (2026-07-03): retail's guard is
// physics_obj->cell == 0 — DETACHED objects only (raw @305627).
// The old proxy (CellPosition.ObjCellId == 0, #145 machinery seeded
// only by the local player's SnapToCell) read every REMOTE body as
// detached, so every dispatch stripped the just-appended transition
// link (door swings snapped; remote walk↔run links died) — see
// PhysicsBody.InWorld (register row).
if (!PhysicsObj.InWorld)
RemoveLinkAnimations?.Invoke();
return WeenieError.None;
}
// ── FUN_00528960 — get_state_velocity ────────────────────────────────────
///
/// Compute the body-local velocity vector for the current interpreted motion.
///
///
/// Decompiled path (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
///
///
///
///
/// Option B — MotionData-sourced forward velocity:
/// when is wired (i.e. the owning
/// entity has an AnimationSequencer), we prefer
/// MotionData.Velocity.Y * speedMod over the hardcoded
/// / constants.
/// This keeps the body's world velocity locked to the animation's
/// baked-in root-motion velocity (r03 §1.3), so the
/// legs-per-meter ratio is invariant regardless of which motion table
/// drives the character. The decompiled constant was a
/// MotionTable-specific value that happens to equal the Humanoid
/// RunForward MotionData.Velocity — fine as a fallback, but the dat
/// is the ground truth for any non-humanoid creature.
///
///
///
/// The constant survives as the max-speed
/// clamp at the bottom, faithfully matching the decompile's
/// if (|velocity| > RunAnimSpeed * rate) guard. Sidestep
/// continues to use because the
/// sequencer only tracks the current forward cycle — strafe is
/// implemented as a separate axis in our controller (see
/// PlayerMovementController.Update).
///
///
public Vector3 get_state_velocity()
{
var velocity = Vector3.Zero;
if (InterpretedState.SideStepCommand == MotionCommand.SideStepRight)
velocity.X = SidestepAnimSpeed * InterpretedState.SideStepSpeed;
// Forward axis — prefer sequencer's current cycle velocity when available.
// Sequencer's CurrentVelocity is already `MotionData.Velocity * speedMod`
// where speedMod == ForwardSpeed for locomotion cycles, so we use it as-is
// (no additional ForwardSpeed multiplication). Fall back to the decompiled
// constant-based path when the accessor is unwired or returns zero Y
// (e.g. during zero-velocity link transitions — in which case the constant
// is the safe default to keep physics moving at ForwardSpeed).
Vector3? cycleVel = GetCycleVelocity?.Invoke();
bool haveCycleForward = cycleVel.HasValue
&& MathF.Abs(cycleVel.Value.Y) > float.Epsilon;
if (InterpretedState.ForwardCommand == MotionCommand.WalkForward)
{
velocity.Y = haveCycleForward
? cycleVel!.Value.Y
: WalkAnimSpeed * InterpretedState.ForwardSpeed;
}
else if (InterpretedState.ForwardCommand == MotionCommand.RunForward)
{
velocity.Y = haveCycleForward
? cycleVel!.Value.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_00528010 — adjust_motion ──────────────────────────────────────────
///
/// Normalize one raw motion channel (forward, sidestep, or turn) into its
/// canonical interpreted form: fold the "negative" commands (WalkBackward,
/// TurnLeft, SideStepLeft) into their positive counterpart with a negated/
/// scaled speed, then apply the Run hold-key promotion.
///
///
/// Decompiled logic (FUN_00528010, docs/research/named-retail/
/// acclient_2013_pseudo_c.txt:305343-305400):
///
/// if weenie != null and !weenie.IsCreature(): return
/// switch cmd:
/// RunForward: return // no scale, NO holdkey path
/// WalkBackwards: cmd = WalkForward; speed *= -BackwardsFactor
/// TurnLeft: cmd = TurnRight; speed *= -1
/// SideStepLeft: cmd = SideStepRight; speed *= -1
/// if cmd == SideStepRight:
/// speed *= SidestepFactor * (WalkAnimSpeed / SidestepAnimSpeed) // ≈ 1.24799995
/// if holdKey == Invalid: holdKey = raw.current_holdkey
/// if holdKey == Run: apply_run_to_command(ref cmd, ref speed)
///
///
///
///
/// GOTCHA: RunForward early-returns before the sidestep scale AND before
/// the hold-key path — a running forward command is already normalized
/// and must not be touched again. The sidestep negate (SideStepLeft →
/// SideStepRight, ×-1) happens BEFORE the ×1.248 anim-rate scale, so the
/// net multiplier for SideStepLeft is -1.248×speed, not -1×(1.248×speed)
/// applied out of order (same result here, but the ORDER matters once
/// apply_run_to_command's sign-dependent clamp is layered on top).
///
///
///
/// R3-W4 (closes J18, retires register TS-34): creature guard WIRED.
/// Retail (raw @305343): if (weenie != 0 && weenie->IsCreature() == 0)
/// return; — a weenie present whose IsCreature() returns
/// false short-circuits the ENTIRE function (no normalization, no
/// hold-key path). has existed
/// since W3; this was the one caller left un-gated.
///
///
/// Raw motion command, normalized in place.
/// Raw motion speed, normalized in place.
///
/// The hold key for THIS channel (forward/sidestep/turn each carry their
/// own hold key in the retail raw state). Pass
/// to fall back to .
///
public void adjust_motion(ref uint motion, ref float speed, HoldKey holdKey)
{
if (WeenieObj is not null && !WeenieObj.IsCreature())
return;
switch (motion)
{
case MotionCommand.RunForward:
// Already normalized; no scale, no hold-key path.
return;
case MotionCommand.WalkBackward:
motion = MotionCommand.WalkForward;
speed *= -BackwardsFactor;
break;
case MotionCommand.TurnLeft:
motion = MotionCommand.TurnRight;
speed *= -1f;
break;
case MotionCommand.SideStepLeft:
motion = MotionCommand.SideStepRight;
speed *= -1f;
break;
}
if (motion == MotionCommand.SideStepRight)
speed *= SidestepFactor * (WalkAnimSpeed / SidestepAnimSpeed);
if (holdKey == HoldKey.Invalid)
holdKey = CurrentHoldKey;
if (holdKey == HoldKey.Run)
apply_run_to_command(ref motion, ref speed);
}
// ── FUN_00527be0 — apply_run_to_command ───────────────────────────────────
///
/// Apply the Run hold-key promotion/scale to an already-normalized
/// (positive-form) motion command.
///
///
/// Decompiled logic (FUN_00527be0, docs/research/named-retail/
/// acclient_2013_pseudo_c.txt:305062-305123):
///
/// speedMod = 1.0
/// if weenie != null: speedMod = weenie.InqRunRate(out r) ? r : MyRunRate
/// switch cmd:
/// WalkForward:
/// if speed > 0: cmd = RunForward // promote ONLY when moving forward
/// speed *= speedMod // UNCONDITIONAL — applies to backward too
/// TurnRight:
/// speed *= RunTurnFactor // ×1.5, no run-rate, no clamp
/// SideStepRight:
/// speed *= speedMod
/// if abs(speed) > MaxSidestepAnimRate: speed = speed > 0 ? Max : -Max
///
///
///
///
/// GOTCHA: the WalkForward speed *= speedMod is unconditional —
/// it applies even when speed is negative (backward), even though the
/// command is NOT promoted to RunForward in that case (the promotion is
/// sign-gated, the scale is not).
///
///
public void apply_run_to_command(ref uint motion, ref float speed)
{
float speedMod = 1.0f;
if (WeenieObj is not null)
speedMod = WeenieObj.InqRunRate(out float rate) ? rate : MyRunRate;
switch (motion)
{
case MotionCommand.WalkForward:
if (speed > 0f)
motion = MotionCommand.RunForward;
speed *= speedMod;
break;
case MotionCommand.TurnRight:
speed *= RunTurnFactor;
break;
case MotionCommand.SideStepRight:
speed *= speedMod;
if (MathF.Abs(speed) > MaxSidestepAnimRate)
speed = speed > 0f ? MaxSidestepAnimRate : -MaxSidestepAnimRate;
break;
}
}
// ── 0x005287e0 — apply_raw_movement ───────────────────────────────────────
///
/// Retail CMotionInterp::apply_raw_movement orchestrator
/// (0x005287e0, docs/research/named-retail/acclient_2013_pseudo_c.txt:305817-305834).
/// Copies the raw motion state into the interpreted state, then normalizes
/// each of the three channels (forward / sidestep / turn) IN PLACE via
/// using that channel's own hold key.
///
///
/// After this call, yields correct velocity
/// for ALL directions — backward (WalkBackward→WalkForward, ×-0.65) and
/// strafe-left (SideStepLeft→SideStepRight, ×-1×1.248) are normalized here,
/// which is the D6 fix that retires the hand-mirrored controller formulas
/// (register TS-22). Callers must pass RAW speeds (forward_speed = 1.0), NOT
/// pre-run-scaled speeds — applies the run
/// rate, so a pre-scaled input would double-scale.
///
///
public void apply_raw_movement(RawMotionState raw)
{
// R3-W6: CurrentHoldKey is now an alias of RawState.CurrentHoldKey;
// keep the interpreter's raw state authoritative when a snapshot
// arrives through this legacy overload.
RawState.CurrentHoldKey = raw.CurrentHoldKey;
// Copy raw -> interpreted. Retail copies 7 fields; our
// InterpretedMotionState omits current_style (velocity doesn't use it).
InterpretedState.ForwardCommand = raw.ForwardCommand;
InterpretedState.ForwardSpeed = raw.ForwardSpeed;
InterpretedState.SideStepCommand = raw.SidestepCommand;
InterpretedState.SideStepSpeed = raw.SidestepSpeed;
InterpretedState.TurnCommand = raw.TurnCommand;
InterpretedState.TurnSpeed = raw.TurnSpeed;
// Normalize each channel with its OWN per-channel hold key.
uint fCmd = InterpretedState.ForwardCommand; float fSpd = InterpretedState.ForwardSpeed;
adjust_motion(ref fCmd, ref fSpd, raw.ForwardHoldKey);
InterpretedState.ForwardCommand = fCmd; InterpretedState.ForwardSpeed = fSpd;
uint sCmd = InterpretedState.SideStepCommand; float sSpd = InterpretedState.SideStepSpeed;
adjust_motion(ref sCmd, ref sSpd, raw.SidestepHoldKey);
InterpretedState.SideStepCommand = sCmd; InterpretedState.SideStepSpeed = sSpd;
uint tCmd = InterpretedState.TurnCommand; float tSpd = InterpretedState.TurnSpeed;
adjust_motion(ref tCmd, ref tSpd, raw.TurnHoldKey);
InterpretedState.TurnCommand = tCmd; InterpretedState.TurnSpeed = tSpd;
}
// ── FUN_00528870 — apply_current_movement ─────────────────────────────────
///
/// CMotionInterp::apply_current_movement (0x00528870, decomp §4e,
/// W0-pins.md A3, FULL BODY dual dispatch — replaces the pre-W4
/// direct-velocity approximation).
///
///
/// Verbatim (raw 305838-305857):
///
/// if (physics_obj != 0 && initted != 0) {
/// if (weenie_obj != 0) eax_2 = weenie_obj->IsThePlayer();
/// if ((weenie_obj == 0 || eax_2 != 0)
/// && movement_is_autonomous(physics_obj) != 0) {
/// apply_raw_movement(this, arg2, arg3);
/// return;
/// }
/// apply_interpreted_movement(this, arg2, arg3);
/// }
///
///
///
///
/// A3: the dispatch gate is IsThePlayer (no weenie = treat as
/// the player), NOT IsCreature — a remote player weenie
/// (IsThePlayer()==false, IsCreature()==true) routes
/// INTERPRETED even when movement_is_autonomous is true. This is
/// the genuine divergence from ACE's server-side IsCreature gate
/// (ACE MotionInterp.cs:430-438) — do not copy.
///
///
public void apply_current_movement(bool cancelMoveTo, bool allowJump)
{
if (PhysicsObj is null || !Initted)
return;
bool isThePlayer = WeenieObj is null || WeenieObj.IsThePlayer();
if (isThePlayer && PhysicsObj.LastMoveWasAutonomous)
{
apply_raw_movement(cancelMoveTo, allowJump);
return;
}
ApplyCurrentMovementInterpreted(cancelMoveTo, allowJump);
}
///
/// CMotionInterp::apply_raw_movement (0x005287e0, decomp §4e
/// referenced body, raw 305817-305834), the (cancelMoveTo,
/// allowJump)-arg overload reached from
/// 's dual dispatch (A3's
/// "IsThePlayer + autonomous" branch), ,
/// , and .
///
///
/// Verbatim: copy the SEVEN raw-state fields (style + all three
/// axis command/speed pairs) into ,
/// normalize each of the three axes IN PLACE via
/// using THAT axis's own hold key, then
/// tail-call apply_interpreted_movement(this, arg2, arg3) — the
/// SAME retail function (0x00528600) as
/// (the funnel producer). This is NOT a new function: it is the
/// existing overload's
/// body (the D6.2a raw-state-orchestrator port), reached here with
/// itself as the source instead of an externally
/// supplied snapshot, plus the previously-missing
/// apply_interpreted_movement(arg2, arg3) tail call this
/// dispatch site needs.
///
///
public void apply_raw_movement(bool cancelMoveTo, bool allowJump)
{
if (PhysicsObj is null)
return;
apply_raw_movement(RawState);
ApplyCurrentMovementInterpreted(cancelMoveTo, allowJump);
}
///
/// The retail apply_interpreted_movement tail reached from BOTH
/// dual-dispatch branches of (raw
/// 305855/305832 — CMotionInterp::apply_interpreted_movement,
/// 0x00528600, the SAME function as the funnel's
/// , per the raw's identical call
/// target at every one of its six call sites).
///
///
/// Physics-only-port body (pre-W4 approximation, SURVIVES per the
/// plan's "less invasive" choice — see the final-report note): this
/// port has no wired at this call
/// site (retail's real body drives DoInterpretedMotion/
/// StopInterpretedMotion through the animation-table backend,
/// exactly like already does for
/// the INBOUND funnel path — but that path always has a sink available
/// from its caller, while apply_current_movement's callers
/// (//
/// /hold-key toggles/jump) do not thread
/// one through). Kept as the pre-existing direct
/// get_state_velocity → grounded set_local_velocity write
/// — an acdream adaptation of root-motion-driven velocity, register row
/// added same commit — until R6 (root motion drives the body directly
/// and this local-only fallback retires in favor of a real sink here
/// too).
///
///
///
/// R3-W4 (App-bound): the entity's persistent animation-dispatch sink.
/// When set, routes the
/// REAL (retail
/// apply_interpreted_movement 0x00528600 — the same function the
/// inbound funnel drives), so HitGround/LeaveGround/hold-key re-applies
/// dispatch cycles (the airborne-Falling engage) through the
/// motion-table backend exactly as retail does. Null (the local player
/// until R3-W6, interp-less entities) → the AP-77 physics-only tail.
///
public IInterpretedMotionSink? DefaultSink { get; set; }
private void ApplyCurrentMovementInterpreted(bool cancelMoveTo, bool allowJump)
{
if (PhysicsObj is null)
return;
// R3-W4: with a persistent sink bound (remotes), this IS retail's
// apply_interpreted_movement — full dispatch through the funnel
// body (style → forward-or-Falling → sidestep → turn), which is
// what makes LeaveGround engage Falling without the deleted
// K-fix18 forced-SetCycle. Without a sink, fall through to the
// AP-77 physics-only adaptation below.
if (DefaultSink is not null)
{
ApplyInterpretedMovement(InterpretedState.CurrentStyle, DefaultSink,
cancelMoveTo, allowJump);
return;
}
_ = cancelMoveTo;
_ = allowJump;
// Decompile writes back MyRunRate when in run state (offset +0x7C).
if (InterpretedState.ForwardCommand == MotionCommand.RunForward)
MyRunRate = InterpretedState.ForwardSpeed;
// Only replace velocity when grounded. While airborne, the physics
// body's integrated velocity (from LeaveGround) should persist —
// gravity pulls Z down, horizontal momentum is preserved.
// The retail client's apply_current_movement also gates on Contact+OnWalkable
// before writing velocity (the full decompiled flow routes through
// update_object which checks transient state).
if (PhysicsObj.OnWalkable)
{
var localVelocity = get_state_velocity();
// AP-77: this adaptation's fallback write has no real opinion on
// "was this move autonomous" (retail's real set_local_velocity
// call for this dispatch path lives deep inside the animation
// backend this stand-in doesn't have — see the seam's doc
// comment). Preserve whatever LastMoveWasAutonomous already
// holds rather than resetting it, so a caller like LeaveGround
// (which just set it true via its OWN direct set_local_velocity
// call, autonomous=1 per raw @305763-305765) isn't clobbered by
// this immediately-following re-sync tail call.
PhysicsObj.set_local_velocity(localVelocity, PhysicsObj.LastMoveWasAutonomous);
}
}
// ── FUN_005288d0 — ReportExhaustion ────────────────────────────────────────
///
/// CMotionInterp::ReportExhaustion (0x005288d0, decomp §4c
/// @305861, FULL BODY, W0-pins.md A3). Identical entry gate and
/// dual-dispatch predicate to ,
/// with HARDCODED (0, 0) dispatch args (raw 305874/305878) —
/// the retail caller (CPhysicsObj::report_exhaustion 0x0050fdd0
/// → MovementManager::ReportExhaustion 0x00524360) passes no
/// per-call parameters through.
///
///
/// Verbatim (raw 305861-305880):
///
/// if (physics_obj != 0 && initted != 0) {
/// if (weenie_obj != 0) eax_2 = weenie_obj->IsThePlayer();
/// if ((weenie_obj == 0 || eax_2 != 0)
/// && movement_is_autonomous(physics_obj) != 0) {
/// apply_raw_movement(this, 0, 0);
/// return;
/// }
/// apply_interpreted_movement(this, 0, 0);
/// }
///
///
///
public void ReportExhaustion()
{
if (PhysicsObj is null || !Initted)
return;
bool isThePlayer = WeenieObj is null || WeenieObj.IsThePlayer();
if (isThePlayer && PhysicsObj.LastMoveWasAutonomous)
{
apply_raw_movement(cancelMoveTo: false, allowJump: false);
return;
}
ApplyCurrentMovementInterpreted(cancelMoveTo: false, allowJump: false);
}
// ── FUN_00528920 / FUN_00528970 — SetWeenieObject / SetPhysicsObject ───────
///
/// CMotionInterp::SetWeenieObject (0x00528920, decomp §4f
/// @305884, FULL BODY, W0-pins.md A3). Assigns ,
/// then — if already physics-bound AND — re-applies
/// movement via the SAME dual-dispatch predicate as
/// , EXCEPT the weenie tested for
/// IsThePlayer is the INCOMING (not
/// post-assignment — they're the same value
/// here, but retail's raw makes the incoming-arg read explicit: a null
/// incoming weenie takes the SAME branch as a non-null-but-IsThePlayer
/// weenie, both via goto label_528946). Dispatch args are
/// hardcoded (1, 0).
///
///
/// Verbatim (raw 305884-305907):
///
/// bool noPhysics = (physics_obj == 0);
/// weenie_obj = arg2;
/// if (!noPhysics && initted != 0) {
/// if (arg2 == 0) {
/// label_528946:
/// if (movement_is_autonomous(physics_obj) != 0) {
/// apply_raw_movement(this, 1, 0);
/// return;
/// }
/// } else if (arg2->IsThePlayer() != 0)
/// goto label_528946;
/// apply_interpreted_movement(this, 1, 0);
/// }
///
///
///
public void SetWeenieObject(IWeenieObject? weenie)
{
WeenieObj = weenie;
if (PhysicsObj is null || !Initted)
return;
bool isThePlayer = weenie is null || weenie.IsThePlayer();
if (isThePlayer && PhysicsObj.LastMoveWasAutonomous)
{
apply_raw_movement(cancelMoveTo: true, allowJump: false);
return;
}
ApplyCurrentMovementInterpreted(cancelMoveTo: true, allowJump: false);
}
///
/// CMotionInterp::SetPhysicsObject (0x00528970, decomp §4g
/// @305911, FULL BODY, W0-pins.md A3). Assigns
/// unconditionally FIRST, then — if the incoming
/// is non-null AND —
/// re-applies movement via the standard dual-dispatch predicate (this
/// time reading , unchanged by this call).
/// Dispatch args are hardcoded (1, 0), matching
/// .
///
///
/// Verbatim (raw 305911-305932):
///
/// this->physics_obj = arg2;
/// if (arg2 != 0 && initted != 0) {
/// if (weenie_obj != 0) eax_1 = weenie_obj->IsThePlayer();
/// if ((weenie_obj == 0 || eax_1 != 0)
/// && movement_is_autonomous(physics_obj) != 0) {
/// apply_raw_movement(this, 1, 0);
/// return;
/// }
/// apply_interpreted_movement(this, 1, 0);
/// }
///
/// Note the entry gate is on the INCOMING arg2 (not the
/// just-assigned this->physics_obj — same value, but the raw
/// tests the parameter register directly), unlike
/// 's entry gate which tests the
/// PRE-assignment physics_obj snapshot (noPhysics).
///
///
public void SetPhysicsObject(PhysicsBody? physicsObj)
{
PhysicsObj = physicsObj;
if (physicsObj is null || !Initted)
return;
bool isThePlayer = WeenieObj is null || WeenieObj.IsThePlayer();
if (isThePlayer && physicsObj.LastMoveWasAutonomous)
{
apply_raw_movement(cancelMoveTo: true, allowJump: false);
return;
}
ApplyCurrentMovementInterpreted(cancelMoveTo: true, allowJump: false);
}
// ── FUN_00527a50 — jump_charge_is_allowed ─────────────────────────────────
///
/// CMotionInterp::jump_charge_is_allowed (0x00527a50, decomp §3b
/// @304935, W0-pins.md A1 polarity). Gate consulted while the jump
/// charge is accumulating (spacebar held) — NOT the same gate as
/// 's ground check.
///
///
/// Verbatim (raw 304940-304948):
///
/// weenie_obj = this->weenie_obj;
/// if (weenie_obj != 0 && weenie_obj.CanJump(this->jump_extent) == 0)
/// return 0x49;
/// forward_command = this->interpreted_state.forward_command;
/// if (forward_command != 0x40000008
/// && (forward_command <= 0x41000011 || forward_command > 0x41000014))
/// return 0;
/// return 0x48;
///
///
///
public WeenieError JumpChargeIsAllowed(float extent)
{
if (WeenieObj is not null && !WeenieObj.CanJump(extent))
return WeenieError.CantJumpLoadedDown; // 0x49
uint forward = InterpretedState.ForwardCommand;
if (forward != MotionCommand.Fallen
&& (forward <= MotionCommand.CrouchLowerBound || forward > MotionCommand.Sleeping))
return WeenieError.None;
return WeenieError.YouCantJumpFromThisPosition; // 0x48
}
// ── FUN_005281c0 — charge_jump ────────────────────────────────────────────
///
/// CMotionInterp::charge_jump (0x005281c0, decomp §3e @305448,
/// W0-pins.md A1 polarity). R3-W3 (closes J6): the ONLY place retail
/// arms — the old
/// contact_allows_move side effect (:1139-1148 pre-W3 numbering)
/// is DELETED (see 's doc comment).
///
///
/// Verbatim (raw 305453-305466):
///
/// weenie_obj = this->weenie_obj;
/// if (weenie_obj != 0 && weenie_obj.CanJump(this->jump_extent) == 0)
/// return 0x49;
/// forward_command = this->interpreted_state.forward_command;
/// if (forward_command == 0x40000008
/// || (forward_command > 0x41000011 && forward_command <= 0x41000014))
/// return 0x48;
/// transient_state = physics_obj->transient_state;
/// if ((transient_state & 1) != 0 && (transient_state & 2) != 0
/// && forward_command == 0x41000003
/// && interpreted_state.sidestep_command == 0
/// && interpreted_state.turn_command == 0)
/// this->standing_longjump = 1;
/// return 0;
///
///
///
///
/// Note the inverted-but-equivalent posture test vs
/// : that function's PASS condition is
/// forward <= 0x41000011 || forward > 0x41000014; this
/// function's BLOCK condition is forward == 0x40000008 ||
/// (forward > 0x41000011 && forward <= 0x41000014) — the
/// same Fallen-exact / Crouch..Sleeping-range predicate, just phrased as
/// its own negation plus the explicit Fallen exact-match up front.
/// Ported literally rather than reusing
/// to keep each function's control flow traceable to its own raw
/// address.
///
///
///
/// Caller (outside CMotionInterp, raw line 376144, 0x0056afac — the
/// player-input/SmartBox layer, out of R3 scope): drives the charge
/// accumulation loop while spacebar is held. acdream's charge
/// accumulation stays controller-side (AP-24 register row survives);
/// PlayerMovementController may call this once wired (W4/W6 — no
/// regression today since nothing calls ChargeJump yet, so
/// remotes/local both continue unaffected).
///
///
public WeenieError ChargeJump()
{
if (WeenieObj is not null && !WeenieObj.CanJump(JumpExtent))
return WeenieError.CantJumpLoadedDown; // 0x49
uint forward = InterpretedState.ForwardCommand;
if (forward == MotionCommand.Fallen
|| (forward > MotionCommand.CrouchLowerBound && forward <= MotionCommand.Sleeping))
return WeenieError.YouCantJumpFromThisPosition; // 0x48
if (PhysicsObj is not null)
{
bool onGround = PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact)
&& PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable);
if (onGround
&& forward == MotionCommand.Ready
&& InterpretedState.SideStepCommand == 0
&& InterpretedState.TurnCommand == 0)
{
StandingLongJump = true;
}
}
return WeenieError.None;
}
// ── FUN_00528780 — jump ───────────────────────────────────────────────────
///
/// Initiate a jump: validate, store jump extent, leave the ground.
///
///
/// Verbatim (0x00528780, decomp §3f @305792):
///
/// physics_obj = this->physics_obj;
/// if (physics_obj == 0) return 8;
/// interrupt_current_movement(physics_obj);
/// result = jump_is_allowed(this, arg2, arg3);
/// if (result != 0) {
/// this->standing_longjump = 0;
/// return result;
/// }
/// this->jump_extent = arg2;
/// set_on_walkable(physics_obj, 0);
/// return result; // == 0
///
///
///
///
/// R3-W3 (closes J7-interp-side): interrupt_current_movement is
/// now called UNCONDITIONALLY before , via
/// the no-op seam (register row —
/// R4 wires the real cancel_moveto). is
/// cleared ONLY on the failure path — a successful jump leaves it
/// untouched (the caller/LeaveGround owns clearing it on success).
///
///
public WeenieError jump(float extent, int adjustStamina = 0)
{
if (PhysicsObj is null)
return WeenieError.NoPhysicsObject;
InterruptCurrentMovement?.Invoke();
var result = jump_is_allowed(extent, out _);
if (result == WeenieError.None)
{
JumpExtent = extent;
PhysicsObj.set_on_walkable(false);
return WeenieError.None;
}
StandingLongJump = false;
return result;
}
// ── FUN_00527aa0 — get_jump_v_z ──────────────────────────────────────────
///
/// Get the vertical (Z) component of jump velocity.
///
///
/// Verbatim (0x00527aa0, decomp §3c @304953, W0-pins.md A5 — the BN text
/// is x87-flag garbled; ACE's clean-room reading adjudicates):
///
/// extent = this->jump_extent;
/// if (extent < 0.000199999995f) return 0.0f;
/// if (extent > 1.0f) extent = 1.0f;
/// if (this->weenie_obj == 0) return 10.0f;
/// if (weenie_obj.InqJumpVelocity(extent, &extent) != 0) return extent;
/// return 0.0f;
///
///
///
public float GetJumpVZ()
{
float extent = JumpExtent;
if (extent < JumpVzEpsilon)
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_005280c0 — get_leave_ground_velocity ──────────────────────────────
///
/// Compose the full 3D body-local launch velocity when leaving the ground.
///
///
/// Verbatim (0x005280c0, decomp §3d @305404, W0-pins.md A6): body order
/// is get_state_velocity(esi) → esi.z = get_jump_v_z() →
/// fallback fires ONLY when |x| AND |y| AND |z| are ALL
/// < 0.000199999995f (epsilon tested three times, one per
/// component), and then OVERWRITES ALL THREE components (including the
/// z the function just computed) with
/// globaltolocal(physics_obj->m_velocityVector) — decisively
/// pinned as GLOBAL→LOCAL by the row-linear match against
/// Frame::globaltolocalvec (A6). The existing
/// Vector3.Transform(Velocity, Quaternion.Inverse(Orientation))
/// transform already IS global→local — kept unchanged.
///
///
public Vector3 GetLeaveGroundVelocity()
{
var velocity = get_state_velocity();
velocity.Z = GetJumpVZ();
float eps = JumpVzEpsilon;
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_005282b0 — jump_is_allowed ────────────────────────────────────────
///
/// Determine whether a jump is currently permitted. R3-W3 (closes J5):
/// FULL verbatim chain replacing the pre-W3 15-line approximation —
/// entry shape, IsFullyConstrained, the pending-head peek (A2),
/// the charge→motion→stamina chain, all now present.
///
///
/// Verbatim (0x005282b0, decomp §3h, W0-pins.md A2/A10):
///
/// if (physics_obj != 0) {
/// if (weenie_obj != 0) eax_2 = weenie_obj.IsCreature();
/// if (weenie_obj != 0 && eax_2 == 0) goto shared_gate; // non-creature weenie skips ground gate
/// if (physics_obj == 0 || (state bit 0x400) == 0) goto shared_gate; // gravity-state off skips ground gate
/// if (Contact && OnWalkable) goto shared_gate; // grounded also reaches shared gate
/// }
/// return 0x24; // gravity-bound creature, not grounded
///
/// shared_gate:
/// if (IsFullyConstrained(physics_obj) != 0) return 0x47;
/// head = pending_motions.head_;
/// if (head != 0) eax_6 = head.jump_error_code; // +0xc
/// if (head == 0 || eax_6 == 0) {
/// eax_6 = jump_charge_is_allowed(this);
/// if (eax_6 == 0) {
/// eax_7 = motion_allows_jump(this, interpreted_state.forward_command);
/// if (eax_7 != 0) return eax_7;
/// if (weenie_obj_1 == 0) return eax_7; // == 0 (success, no weenie to consult)
/// eax_6 = 0x47;
/// if (weenie_obj_1.JumpStaminaCost(arg2, arg3) != 0) return eax_7; // == 0 (afforded)
/// // JumpStaminaCost returned false -> falls out, eax_6 stays 0x47
/// }
/// }
/// return eax_6;
///
///
///
///
/// A10 note: physics_obj == null returns 0x24 (NotGrounded),
/// NOT 8 — the "8 = no physics obj" convention that holds
/// everywhere else in CMotionInterp does not hold here (the
/// if (physics_obj != 0) {...} wrapper falls out to
/// return 0x24 when physics_obj is null, same as the grounded-check
/// failure path).
///
///
/// Jump charge fraction, forwarded to
/// JumpStaminaCost.
/// Out-param mirroring retail's arg3
/// (int32_t*) — the stamina cost JumpStaminaCost computed,
/// 0 when the chain never reaches that call.
public WeenieError jump_is_allowed(float extent, out int staminaCost)
{
staminaCost = 0;
if (PhysicsObj is not null)
{
bool nonCreatureWeenie = WeenieObj is not null && !WeenieObj.IsCreature();
bool gravityStateOff = !PhysicsObj.State.HasFlag(PhysicsStateFlags.Gravity);
bool grounded = PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact)
&& PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable);
if (nonCreatureWeenie || gravityStateOff || grounded)
return JumpIsAllowedSharedGate(extent, ref staminaCost);
}
return WeenieError.NotGrounded; // 0x24 — gravity-bound creature, not grounded (also physics_obj == null)
}
///
/// The shared_gate label inside
/// (raw 305524-305556) — split out only for C# readability; retail
/// reaches this point via three different goto sites, all folded
/// into one function here since C# has no goto-into-shared-tail idiom
/// as clean as retail's.
///
private WeenieError JumpIsAllowedSharedGate(float extent, ref int staminaCost)
{
if (PhysicsObj is not null && PhysicsObj.IsFullyConstrained)
return WeenieError.GeneralMovementFailure; // 0x47
// A2: peek the pending_motions head WHENEVER non-empty — no Count>1
// gate. A nonzero head.JumpErrorCode short-circuits the whole
// charge/motion/stamina chain below.
var head = _pendingMotions.First;
uint peeked = head is not null ? head.Value.JumpErrorCode : 0;
if (head is null || peeked == 0)
{
WeenieError chargeResult = JumpChargeIsAllowed(extent);
if (chargeResult == WeenieError.None)
{
WeenieError motionResult = MotionAllowsJump(InterpretedState.ForwardCommand);
if (motionResult != WeenieError.None)
return motionResult;
if (WeenieObj is null)
return motionResult; // == None
if (!WeenieObj.JumpStaminaCost(extent, out staminaCost))
return WeenieError.GeneralMovementFailure; // 0x47 — can't afford
return motionResult; // == None (success)
}
return chargeResult;
}
return (WeenieError)peeked;
}
// ── FUN_00528dd0 — contact_allows_move ────────────────────────────────────
///
/// Determine whether the current contact state allows this motion command.
///
///
/// Decompiled logic (0x00528240, pseudo-C 305471): allowed (1) when —
/// motion is TurnRight/TurnLeft (0x6500000D/0E), OR motion is Falling
/// (0x40000015) or Dead-class (0x40000011), OR the weenie exists and is
/// NOT a creature, OR gravity is off, OR the body has Contact +
/// OnWalkable. Everything else (a gravity-bound creature without ground
/// contact) is blocked — this is the real mechanism behind "airborne
/// remotes keep their cycle" (K-fix17's empirical guard).
///
///
///
/// 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".
///
///
///
/// R3-W3 (closes J6): the StandingLongJump side effect that used to
/// live here is DELETED. The S2a port had flagged it explicitly as
/// "PRE-EXISTING acdream side effect (not part of 0x00528240)" — a
/// misattribution: retail's contact_allows_move (0x00528240)
/// never reads or writes standing_longjump at all. The real
/// arming site is (0x005281c0, decomp §3e),
/// which now owns the identical grounded+idle predicate exclusively.
/// Consequence of the OLD bug this retires: every grounded idle contact
/// check (i.e. every frame the player stood still, since
/// ApplyInterpretedMovement calls this every dispatch) flipped
/// the flag regardless of whether a jump charge was ever started —
/// is the only path that can arm it now.
///
///
public bool contact_allows_move(uint motion)
{
if (PhysicsObj is null)
return false;
if (motion > 0x40000015u)
{
if (motion is MotionCommand.TurnRight or MotionCommand.TurnLeft)
return true;
}
else if (motion == MotionCommand.Falling || motion == 0x40000011u)
{
return true;
}
if (WeenieObj is not null && !WeenieObj.IsCreature())
return true;
if (!PhysicsObj.State.HasFlag(PhysicsStateFlags.Gravity))
return true;
bool grounded = PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact)
&& PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable);
return grounded;
}
// ── R3-W2 — pending_motions lifecycle ─────────────────────────────────
// docs/research/2026-07-02-r3-motioninterp/r3-motioninterp-decomp.md §1.
///
/// CMotionInterp::add_to_queue (0x00527b80, decomp §1a @305032):
/// allocate a and append it to the tail of
/// (retail: append at tail; if the queue was
/// empty, head and tail both point at the new node — the C#
/// gives this for free via
/// AddLast).
///
/// Retail arg2 — MotionNode.context_id.
/// Retail arg3 — MotionNode.motion.
/// Retail arg4 — MotionNode.jump_error_code.
public void AddToQueue(uint contextId, uint motion, uint jumpErrorCode)
{
_pendingMotions.AddLast(new MotionNode(contextId, motion, jumpErrorCode));
}
///
/// CMotionInterp::motions_pending (0x00527fe0, decomp §1b
/// @305322): pending_motions.head_ != null.
///
public bool MotionsPending() => _pendingMotions.First is not null;
///
/// CMotionInterp::MotionDone (0x00527ec0, decomp §1c @305238,
/// FULL BODY). A7 (W0-pins.md): and
/// are read into locals by the decompiled
/// relay chain but NEVER actually used by this build's body — the queue
/// head is popped UNCONDITIONALLY, never matched by motion id. Params
/// are kept for R5 signature parity with the real
/// MovementManager::MotionDone relay.
///
///
/// Body (verbatim): no-op if is null or the
/// queue is empty. Peek the HEAD: if head.Motion & 0x10000000
/// (the action-class bit) is set, fire
/// then pop the head of BOTH
/// 's and 's action
/// FIFOs. Then unconditionally dequeue the pending_motions head.
///
///
public void MotionDone(uint motion, bool success)
{
if (PhysicsObj is null)
return;
var head = _pendingMotions.First;
if (head is null)
return;
if ((head.Value.Motion & 0x10000000u) != 0)
{
UnstickFromObject?.Invoke();
InterpretedState.RemoveAction();
RawState.RemoveAction();
}
// Re-peek per retail's structure (head__1 = this->pending_motions.head_,
// a fresh read after the action-class branch above) — functionally
// the same node since nothing else can mutate the queue mid-call.
var head1 = _pendingMotions.First;
if (head1 is not null)
_pendingMotions.RemoveFirst();
}
///
/// CMotionInterp::HandleExitWorld (0x00527f30, decomp §1d):
/// identical body to 's single-pop logic, looped
/// until drains — world exit flushes all
/// pending motion callbacks unconditionally, since no more
/// animation-completion events will arrive.
///
///
/// Ambiguity resolved (not in W0-pins.md — found while porting):
/// the raw decompile's loop condition is head_ != 0
/// (re-read every iteration), but the pop only happens inside a NESTED
/// if (physics_obj != 0 && head_ != 0) guard — a literal
/// translation would infinite-loop if were null
/// with a non-empty queue (the loop variable never advances). This is
/// dead code in retail: a live CMotionInterp::HandleExitWorld call
/// only ever happens on an interp already bound to a physics_obj
/// (retail's CMotionInterp lifetime is physics-object-scoped) —
/// the null check exists defensively, not as a real "still drain, but
/// skip the pop" branch. Porting a genuine infinite loop is not
/// "faithful", it's a bug retail never actually exercises; this port
/// drains unconditionally regardless of (same
/// choice / already
/// make — they too are called before a physics_obj may exist during
/// interp construction/testing).
///
///
public void HandleExitWorld()
{
while (_pendingMotions.First is not null)
{
var head = _pendingMotions.First!;
if ((head.Value.Motion & 0x10000000u) != 0)
{
UnstickFromObject?.Invoke();
InterpretedState.RemoveAction();
RawState.RemoveAction();
}
_pendingMotions.RemoveFirst();
}
}
///
/// CMotionInterp::is_standing_still (0x00527fa0, decomp @305309):
/// on-ground (Contact + OnWalkable) AND ForwardCommand == Ready AND
/// SideStepCommand == 0 AND TurnCommand == 0.
///
public bool IsStandingStill()
{
if (PhysicsObj is null)
return false;
bool grounded = PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact)
&& PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable);
if (!grounded)
return false;
return InterpretedState.ForwardCommand == MotionCommand.Ready
&& InterpretedState.SideStepCommand == 0
&& InterpretedState.TurnCommand == 0;
}
///
/// CMotionInterp::motion_allows_jump (0x005279e0, decomp §3a
/// @304908, __pure). A1 (W0-pins.md, adversarially verified):
/// PINNED as a BLOCKLIST — 0 = jump allowed (pass), 0x48 =
/// jump BLOCKED. Ported as literal uint range comparisons mirroring the
/// decomp's exact branch algebra (NOT enum-ordinal ranges — the whole
/// point of A1 is that ACE's enum-order dependence is fragile).
///
///
/// Blocklist (definitive table, W0-pins.md §A1):
///
/// - [0x1000006f, 0x10000078] — MagicPowerUp01..MagicPowerUp10.
/// - [0x10000128, 0x10000131] — TripleThrustLow..MagicPowerUp07Purple.
/// - 0x40000008 exact — Fallen (NOT Falling; ACE mis-transcribed this).
/// - [0x40000016, 0x40000018] — Reload, Unload, Pickup.
/// - [0x4000001e, 0x40000039] — AimLevel..MagicPray.
/// - [0x41000012, 0x41000014] — Crouch, Sitting, Sleeping.
///
/// Everything else — including Falling 0x40000015, Ready
/// 0x41000003, Dead 0x40000011, all turn/sidestep ids — PASSES.
///
///
///
/// This is a pure function (no instance state) — retail's this
/// parameter is unused in the body (the decomp's __pure
/// attribute confirms it doesn't read this).
///
///
/// Retail arg2 — the motion id to test.
public static WeenieError MotionAllowsJump(uint motion)
{
// Verbatim branch algebra from raw 304908-304931 (W0-pins.md §A1):
// if (arg2 > 0x40000018) {
// if (arg2 > 0x41000014) return 0;
// if (arg2 < 0x41000012 && (arg2 < 0x4000001e || arg2 > 0x40000039)) return 0;
// } else if (arg2 < 0x40000016) {
// if (arg2 > 0x10000131) {
// if (arg2 != 0x40000008) return 0;
// } else if (arg2 < 0x10000128 && (arg2 < 0x1000006f || arg2 > 0x10000078)) return 0;
// }
// return 0x48;
//
// The middle band [0x40000016, 0x40000018] satisfies NEITHER outer
// branch (not > 0x40000018, not < 0x40000016) and falls straight to
// `return 0x48` — a genuine "no early return" gap in the middle.
if (motion > 0x40000018u)
{
if (motion > 0x41000014u)
return WeenieError.None;
if (motion < 0x41000012u && (motion < 0x4000001eu || motion > 0x40000039u))
return WeenieError.None;
}
else if (motion < 0x40000016u)
{
if (motion > 0x10000131u)
{
if (motion != 0x40000008u)
return WeenieError.None;
}
else if (motion < 0x10000128u && (motion < 0x1000006fu || motion > 0x10000078u))
{
return WeenieError.None;
}
}
return WeenieError.YouCantJumpFromThisPosition;
}
// ── FUN_00528b00 — LeaveGround ────────────────────────────────────────────
///
/// CMotionInterp::LeaveGround (0x00528b00, decomp §4b @306022,
/// FULL BODY — replaces the pre-W4 approximation, closes J8's
/// LeaveGround leg). Called when the body becomes airborne (also
/// tail-called unconditionally from ,
/// §4d).
///
///
/// Verbatim (raw 306022-306040, quoted in full in the final-report):
/// creature gate (weenie_obj == 0 || IsCreature() != 0) AND
/// state-0x400 (Gravity) gate — SAME shape as .
/// When both pass: compute the launch velocity via
/// , apply it with
/// set_local_velocity(&v, autonomous=1) (the literal
/// 1 arg —
/// becomes true), reset and
/// to their zero/false defaults (the jump
/// charge is consumed the instant you actually leave the ground), then
/// + apply_current_movement(0,
/// 0) (the same re-sync performs).
///
///
public void LeaveGround()
{
if (PhysicsObj is null)
return;
bool isCreature = WeenieObj is null || WeenieObj.IsCreature();
if (!isCreature)
return;
if (!PhysicsObj.State.HasFlag(PhysicsStateFlags.Gravity))
return;
var velocity = GetLeaveGroundVelocity();
PhysicsObj.set_local_velocity(velocity, autonomous: true);
StandingLongJump = false;
JumpExtent = 0f;
RemoveLinkAnimations?.Invoke();
apply_current_movement(cancelMoveTo: false, allowJump: false);
}
// ── FUN_00528ac0 — HitGround ──────────────────────────────────────────────
///
/// CMotionInterp::HitGround (0x00528ac0, decomp §4a @305996,
/// FULL BODY — replaces the pre-W4 approximation, closes J8's
/// HitGround leg). Called when the body lands on a walkable surface.
///
///
/// Verbatim (raw 305996-306014):
///
/// if (physics_obj != 0) {
/// if (weenie_obj != 0) eax_2 = weenie_obj->IsCreature();
/// if (weenie_obj == 0 || eax_2 != 0) {
/// if (physics_obj != 0 && (state & 0x400) != 0) {
/// RemoveLinkAnimations(physics_obj);
/// apply_current_movement(this, 0, 0);
/// }
/// }
/// }
///
///
///
///
/// A3 note: this creature gate uses IsCreature (vtable +0x2c),
/// NOT IsThePlayer (+0x14) — the anti-artifact proof for A3's
/// pin (BN distinguishes the two slots locally, ~0x250 bytes from the
/// IsThePlayer-gated functions).
///
///
public void HitGround()
{
if (PhysicsObj is null)
return;
bool isCreature = WeenieObj is null || WeenieObj.IsCreature();
if (!isCreature)
return;
if (!PhysicsObj.State.HasFlag(PhysicsStateFlags.Gravity))
return;
RemoveLinkAnimations?.Invoke();
apply_current_movement(cancelMoveTo: false, allowJump: false);
}
// ── FUN_00528c80 — enter_default_state ────────────────────────────────────
///
/// CMotionInterp::enter_default_state (0x00528c80, decomp §4d
/// @306124, FULL BODY, W0-pins.md A8, closes J10). Resets both motion
/// states to fresh defaults, re-initializes the physics object's
/// motion tables, APPENDS the canonical {0, Ready, 0} sentinel
/// to (NO drain — pre-existing queued
/// nodes survive, A8), marks , then tail-calls
/// unconditionally.
///
///
/// Verbatim (raw 306124-306154):
///
/// raw_state = RawMotionState(); // fresh ctor defaults
/// interpreted_state = InterpretedMotionState(); // fresh ctor defaults
/// CPhysicsObj::InitializeMotionTables(physics_obj);
/// node = new MotionNode { context_id=0, motion=0x41000003, jump_error_code=0 };
/// pending_motions.append(node); // tail-splice, NO clear
/// initted = 1;
/// LeaveGround(this);
///
///
///
///
/// Retail's real construction paths (MovementManager::Create +
/// the lazy-create guard at every MovementManager entry point)
/// call this exactly once (or twice on the lazy-create double-call —
/// §6g, genuine retail, not "fixed") before exposing the interpreter to
/// any caller. acdream's constructors default to
/// true directly (see that property's doc comment) rather than
/// routing every construction through this method, so this port is the
/// verbatim, fully-testable "reset to default/idle state" operation —
/// callers that want retail's FULL reset semantics (state defaults +
/// sentinel + LeaveGround tail), not just the Initted flag, call
/// this explicitly.
///
///
public void EnterDefaultState()
{
RawState = new RawMotionState();
InterpretedState = InterpretedMotionState.Default();
InitializeMotionTables?.Invoke();
AddToQueue(contextId: 0, MotionCommand.Ready, jumpErrorCode: 0);
Initted = true;
LeaveGround();
}
// ── FUN_00528b70 / FUN_00528bb0 — set_hold_run / SetHoldKey ────────────────
///
/// CMotionInterp::set_hold_run (0x00528b70, decomp §5c @306053,
/// FULL BODY, closes J13's set_hold_run leg). XOR toggle guard: only
/// acts when actually FLIPS the current
/// effective hold-run state (skip redundant re-application).
///
///
/// Verbatim (raw 306053-306070):
///
/// eax = (arg2 == 0); // "run key is up"
/// edx = (raw_state.current_holdkey != Run);
/// if (eax != edx) { // XOR: state actually changes
/// raw_state.current_holdkey = (arg2 != 0) + 1; // false->1(None), true->2(Run)
/// apply_current_movement(this, arg3, 0);
/// }
///
///
///
/// Retail arg2 — nonzero = the run key
/// is currently held down.
/// Retail arg3 — passed through as
/// apply_current_movement's cancelMoveTo arg.
public void set_hold_run(bool holdingRun, bool interrupt)
{
bool runKeyUp = !holdingRun;
bool notCurrentlyRun = RawState.CurrentHoldKey != HoldKey.Run;
if (runKeyUp != notCurrentlyRun)
{
RawState.CurrentHoldKey = holdingRun ? HoldKey.Run : HoldKey.None;
apply_current_movement(cancelMoveTo: interrupt, allowJump: false);
}
}
///
/// CMotionInterp::SetHoldKey (0x00528bb0, decomp §5d @306072,
/// FULL BODY, closes J13's SetHoldKey leg). No-op if
/// already equals .
/// Setting only takes effect (and
/// re-applies movement) if the CURRENT hold key is
/// — requesting None while already something
/// else (e.g. Invalid) is silently ignored. Setting
/// takes effect whenever the current key
/// isn't already Run.
///
///
/// Verbatim (raw 306072-306095):
///
/// current = raw_state.current_holdkey;
/// if (arg2 != current) {
/// if (arg2 == HoldKey_None) {
/// if (current == HoldKey_Run) {
/// raw_state.current_holdkey = HoldKey_None;
/// apply_current_movement(this, arg3, 0);
/// }
/// } else if (arg2 == HoldKey_Run && current != HoldKey_Run) {
/// raw_state.current_holdkey = HoldKey_Run;
/// apply_current_movement(this, arg3, 0);
/// }
/// }
///
///
///
///
/// This is the function DoMotion (W5) calls when bit
/// 0x800 (SetHoldKey) of the incoming MovementParameters
/// requests a hold-key change, and what
/// reads back via (mirrored
/// onto — see the field doc) to decide
/// whether fires.
///
///
/// Retail arg2.
/// Retail arg3 — passed through as
/// apply_current_movement's cancelMoveTo arg.
public void SetHoldKey(HoldKey key, bool cancelMoveTo)
{
HoldKey current = RawState.CurrentHoldKey;
if (key == current)
return;
if (key == HoldKey.None)
{
if (current == HoldKey.Run)
{
RawState.CurrentHoldKey = HoldKey.None;
apply_current_movement(cancelMoveTo, allowJump: false);
}
}
else if (key == HoldKey.Run && current != HoldKey.Run)
{
RawState.CurrentHoldKey = HoldKey.Run;
apply_current_movement(cancelMoveTo, allowJump: false);
}
}
// ── CMotionInterp::get_max_speed (0x00527cb0) ─────────────────────────────
///
/// Return the maximum movement speed in m/s: run rate × RunAnimSpeed (4.0).
/// Mirrors retail CMotionInterp::get_max_speed at 0x00527cb0.
///
///
/// The ×4.0 is byte-verified retail (UN-2 resolved 2026-06-12).
/// The Binary Ninja pseudo-C (named-retail/acclient_2013_pseudo_c.txt:305127)
/// renders this function as void with a bare this->my_run_rate;
/// statement because it drops x87 instructions — a known BN artifact class.
/// Disassembling the PDB-matched v11.4186 binary at VA 0x00527cb0
/// shows all THREE return paths end with
/// fmul dword ptr [0x007C8918], and the .rdata dword at
/// 0x007C8918 is 0x40800000 = 4.0f (the sibling
/// get_adjusted_max_speed 0x00527d00 carries the same trailing
/// fmul). Re-derive with py tools/verify_un2_fmul.py. The three
/// retail paths: weenie_obj == null → 1.0×4; InqRunRate success →
/// queried×4; InqRunRate failure → my_run_rate×4. ACE's
/// MotionInterp.cs:665-676 ports it identically (RunAnimSpeed = 4.0f).
///
///
///
/// Consequence: the dead-reckoning catch-up speed
/// (InterpolationManager::adjust_offset 0x00555d30, pc:353122)
/// is 2 × get_max_speed() ≈ 23.5 m/s for a run-rate-2.94
/// (run-skill-200) character — that IS retail's value. An earlier
/// doc-comment here claimed the bare rate (~5.9 m/s catch-up) was
/// retail-correct and blamed the ×4 for the multi-second 1-Hz blip on
/// observed retail remotes; that reading trusted the BN x87 dropout
/// and is refuted by the binary. If the blip recurs, its root cause is
/// elsewhere (node-fail handling / progress-quantum abandonment /
/// position-queue feed — the #41 family), NOT this multiply.
///
///
public float GetMaxSpeed()
{
// Retail 0x00527cb0: weenie null → 1.0; InqRunRate ok → queried;
// InqRunRate failed → my_run_rate. Every path × RunAnimSpeed (4.0,
// .rdata 0x007C8918). Note the weenie-null default is the LITERAL 1.0
// (.rdata 0x007928B0), not my_run_rate.
float rate = 1.0f;
if (WeenieObj is not null && !WeenieObj.InqRunRate(out rate))
rate = MyRunRate;
return RunAnimSpeed * rate;
}
// R3-W5: the former `ApplyMotionToInterpretedState` private helper
// (a hand-rolled switch approximating InterpretedMotionState.ApplyMotion)
// is DELETED per the plan (closes J3/J4) — the merged DoInterpretedMotion
// below calls the verbatim InterpretedMotionState.ApplyMotion(motion, p)
// directly (already ported, W1).
// ══ L.2g S2 — the inbound CMotionInterp funnel (DEV-1) ════════════════
// Spec: docs/research/2026-07-02-s2-inbound-funnel-pseudocode.md.
// Dispatch order validated against the LIVE retail-observer cdb trace
// (tools/cdb/l2g-observer.cdb): style → forward → sidestep(-stop) →
// turn(-stop) → actions, wholesale per UM.
///
/// True when this interpreter belongs to the LOCAL player. Retail skips
/// replaying AUTONOMOUS actions on the local player (they are its own
/// echo — move_to_interpreted_state 305977) but applies them on remotes.
///
public bool IsLocalPlayer;
///
/// CMotionInterp::server_action_stamp — 15-bit wraparound stamp
/// of the last applied inbound action (move_to_interpreted_state
/// 305953-305989).
///
public int ServerActionStamp;
///
/// CMotionInterp::move_to_interpreted_state (0x005289c0,
/// pseudo-C 305936): adopt the style into the raw state, FLAT-copy the
/// incoming interpreted state (copy_movement_from 0x0051e750 —
/// every axis overwritten, defaults included), re-apply the whole
/// movement through the sink, then replay fresh actions under the
/// 15-bit stamp gate.
///
///
/// R3-W4 (Adjacent finding, W0-pins.md): raw 305946-305949
/// computes eax_2 = motion_allows_jump(this->interpreted_state.forward_command)
/// — the OLD forward command, BEFORE copy_movement_from
/// overwrites two lines later — then
/// calls apply_current_movement(this, 1, allowJump) where
/// allowJump = (eax_2 == 0) (the raw's own
/// -((esi_1 - esi_1)) is decompiler noise for that same
/// boolean). Ported here as on the
/// PRE-overwrite .ForwardCommand,
/// snapshotted before the flat-copy below. Passed through to
/// per its TODO(W5) — this
/// funnel calls the interpreted body directly (not the
/// dual-dispatch gate), matching
/// the existing S2 architecture: inbound funnel state is always
/// server-authoritative, so it always wants the interpreted path
/// regardless of IsThePlayer/autonomous.
///
///
public int MoveToInterpretedState(in InboundInterpretedState ims, IInterpretedMotionSink? sink = null)
{
if (PhysicsObj is null) return 0;
RawState.CurrentStyle = ims.CurrentStyle;
// motion_allows_jump on the OLD forward_command, BEFORE the
// flat-copy below overwrites it (Adjacent finding).
bool allowJump = MotionAllowsJump(InterpretedState.ForwardCommand) == WeenieError.None;
// copy_movement_from — flat overwrite, no per-field presence checks.
InterpretedState.ForwardCommand = ims.ForwardCommand;
InterpretedState.ForwardSpeed = ims.ForwardSpeed;
InterpretedState.SideStepCommand = ims.SideStepCommand;
InterpretedState.SideStepSpeed = ims.SideStepSpeed;
InterpretedState.TurnCommand = ims.TurnCommand;
InterpretedState.TurnSpeed = ims.TurnSpeed;
ApplyInterpretedMovement(ims.CurrentStyle, sink, cancelMoveTo: true, allowJump: allowJump);
if (ims.Actions is { } actions)
{
foreach (var a in actions)
{
// 15-bit wraparound "newer" gate vs server_action_stamp:
// abs diff > 0x3fff flips the comparison (305955-305969).
int incoming = a.Stamp & 0x7FFF;
int stored = ServerActionStamp & 0x7FFF;
int diff = incoming >= stored ? incoming - stored : stored - incoming;
bool newer = diff <= 0x3FFF ? stored < incoming : incoming < stored;
if (!newer) continue;
// Local player skips its own autonomous echoes (305977).
if (IsLocalPlayer && a.Autonomous) continue;
ServerActionStamp = incoming;
DispatchInterpretedMotion(a.Command, a.Speed, sink);
}
}
return 1;
}
///
/// CMotionInterp::apply_interpreted_movement (0x00528600,
/// pseudo-C 305713): cache my_run_rate from a RunForward speed,
/// then dispatch style / forward-or-Falling / sidestep-or-stop /
/// turn-or-stop in retail order. A non-zero turn EARLY-RETURNS (no
/// turn-stop, no idle bookkeeping).
///
///
/// R3-W2 producer (closes J1's apply_interpreted_movement leg):
/// the tail turn-stop call (raw 305766-305785) is retail's
/// CPhysicsObj::StopInterpretedMotion(physics_obj, 0x6500000d, &var_2c)
/// — our call below IS
/// that dispatch. On success (eax_10 == 0, i.e. the sink accepted
/// the stop — this port has no failure signal from
/// , so it is treated as
/// always-succeeding, matching every real sink implementation which is
/// void), retail immediately re-queues the canonical "return to none"
/// node: add_to_queue(this, var_c /*uninitialized local in the raw
/// decompile — see the final-report contextId note*/, 0x41000003, eax_10)
/// where eax_10 (the stop's own return value) becomes the queued
/// node's jump_error_code — since the stop succeeded, that value
/// is 0.
///
///
///
/// R3-W4 (closes J11's param-plumbing leg): /
/// are retail's arg2/arg3 —
/// threaded through NOW per the plan (every real caller already has an
/// opinion: 's dual-dispatch tail
/// passes its own args through unchanged;
/// passes (true, motion_allows_jump(OLD forward_command) == 0) per
/// the Adjacent finding). Their ONLY retail consumer in this function is
/// the garbled tail expression at raw 305778
/// ((((arg3&1)<<2)|(arg2&1))<<0xf feeding an
/// uninitialized-local byte test before conditionally calling
/// InterpretedMotionState::RemoveMotion(0x6500000d) a SECOND time)
/// — a BN x87/uninit-local artifact class, not a readable retail
/// algorithm. TODO(W5): resolve that tail against a clean decompile (or
/// cdb capture) once MovementParameters flows through this
/// funnel end to end; until then the params are accepted and preserved
/// for signature parity but do not change this function's control flow
/// (matches the funnel's existing "no MovementParameters yet" posture,
/// e.g. 's own TODO(W5)).
///
///
public void ApplyInterpretedMovement(
uint currentStyle, IInterpretedMotionSink? sink,
bool cancelMoveTo = false, bool allowJump = false)
{
if (PhysicsObj is null) return;
// cancelMoveTo/allowJump: accepted for signature parity (see the
// TODO(W5) above) — not yet consumed by this function's body.
_ = cancelMoveTo;
_ = allowJump;
// ENTRY-CACHE all axis values BEFORE the style dispatch (W6 stop-bug
// fix, 2026-07-03). The style dispatch's success path runs
// InterpretedMotionState::ApplyMotion(style), whose style branch
// resets forward_command to Ready UNCONDITIONALLY (raw 0051ea6c —
// verbatim). Retail SELF-HEALS because its compiled apply pass reads
// the axis fields into registers before the style call, so the fwd
// dispatch re-applies the pre-reset command (proven by our own
// 183-case live-retail observer trace: the fwd dispatch carries the
// wire's RunForward after the style dispatch on the same UM — the
// BN pseudo-C's apparent post-style field reads at 0x528687 are
// decompiler rendering of hoisted registers, the same artifact
// class as the A1 polarity). A live-field read here dispatched
// READY after every style apply, leaving the field permanently
// Ready — the "press W and stop instantly" W6 regression.
uint entryFwdCmd = InterpretedState.ForwardCommand;
float entryFwdSpeed = InterpretedState.ForwardSpeed;
uint entrySideCmd = InterpretedState.SideStepCommand;
float entrySideSpeed = InterpretedState.SideStepSpeed;
uint entryTurnCmd = InterpretedState.TurnCommand;
float entryTurnSpeed = InterpretedState.TurnSpeed;
if (entryFwdCmd == MotionCommand.RunForward)
MyRunRate = entryFwdSpeed;
DispatchInterpretedMotion(currentStyle, 1.0f, sink);
if (!contact_allows_move(entryFwdCmd))
{
DispatchInterpretedMotion(MotionCommand.Falling, 1.0f, sink);
}
else if (StandingLongJump)
{
DispatchInterpretedMotion(MotionCommand.Ready, 1.0f, sink);
DispatchStopInterpretedMotion(MotionCommand.SideStepRight, sink);
}
else
{
DispatchInterpretedMotion(entryFwdCmd, entryFwdSpeed, sink);
if (entrySideCmd == 0)
DispatchStopInterpretedMotion(MotionCommand.SideStepRight, sink);
else
DispatchInterpretedMotion(entrySideCmd, entrySideSpeed, sink);
}
if (entryTurnCmd != 0)
{
DispatchInterpretedMotion(entryTurnCmd, entryTurnSpeed, sink);
return; // retail early return — no idle-stop this call
}
// Tail (raw 305766-305786): unconditional StopInterpretedMotion(TurnRight,
// params) — the merged StopInterpretedMotion ITSELF performs the
// add_to_queue(context, Ready, 0) + RemoveMotion(TurnRight) on
// success, so no separate AddToQueue call is needed here anymore
// (R3-W5 moves this bookkeeping into the merged function body).
DispatchStopInterpretedMotion(MotionCommand.TurnRight, sink);
}
///
/// CMotionInterp::DoInterpretedMotion (0x00528360, raw
/// 305575-305631, FULL BODY, R3-W5, closes J4). THE ONE
/// DoInterpretedMotion — merges the former legacy
/// (uint, float, bool) overload's state-management with the S2a
/// funnel's DispatchInterpretedMotion sink-dispatch backend.
///
///
/// Verbatim (raw 305577-305631):
///
/// if (physics_obj == 0) return 8;
/// if (contact_allows_move(arg2)) {
/// if (standing_longjump != 0
/// && (arg2 == WalkForward || arg2 == RunForward || arg2 == SideStepRight))
/// goto label_528440; // StandingLongJump: state-only, no dispatch, no queue
/// if (arg2 == 0x40000011 /*Dead*/) RemoveLinkAnimations(physics_obj);
/// result = sink.ApplyMotion(arg2, arg3); // CPhysicsObj::DoInterpretedMotion stand-in
/// if (result == 0) {
/// if ((arg3->bitfield & 0x20000) == 0) { // NOT DisableJumpDuringLink
/// eax_5 = motion_allows_jump(arg2);
/// if (eax_5 == 0 && (arg2 & 0x10000000) == 0)
/// eax_5 = motion_allows_jump(interpreted_state.forward_command);
/// } else eax_5 = 0x48; // DisableJumpDuringLink FORCES blocked
/// add_to_queue(arg3->context_id, arg2, eax_5);
/// if (arg3->bitfield & 0x4000 /*ModifyInterpretedState*/)
/// InterpretedMotionState::ApplyMotion(arg2, arg3);
/// }
/// } else if ((arg2 & 0x10000000) == 0) {
/// label_528440:
/// if (arg3->bitfield & 0x4000) InterpretedMotionState::ApplyMotion(arg2, arg3);
/// result = 0;
/// } else result = 0x24;
/// if (physics_obj != 0 && physics_obj->cell == 0) RemoveLinkAnimations(physics_obj);
/// return result;
///
///
///
///
/// R3-W5 finding (not in the original W0 pins — discovered while
/// porting): 's
/// bool return IS retail's result == 0 test gating the
/// queue/state-write block. This is load-bearing, not cosmetic: the
/// VERY FIRST dispatch of every apply_interpreted_movement call
/// is the style/stance id (current_style, always
/// >= 0x80000000), which has no locomotion MotionData
/// entry in the dat — CMotionTable::DoObjectMotion genuinely
/// fails for it. If that failure isn't propagated (an earlier revision
/// of this port treated the sink as a void, always-succeeding
/// call), InterpretedMotionState::ApplyMotion's negative-motion
/// branch (arg2 < 0 → forward_command = 0x41000003) clobbers
/// BEFORE the very
/// next line reads it for the real forward dispatch — regressed 74/183
/// cases of the live retail-observer conformance suite
/// (RetailObserverTraceConformanceTests) until the interface was
/// fixed to return the real result.
///
///
///
/// Note the ACTUAL animation-table dispatch (the sink call) happens
/// through — the entity's persistent
/// IInterpretedMotionSink binding (R2-Q5's
/// MotionTableDispatchSink in production). This is the SAME sink
/// already routes through
/// for the apply_current_movement dual-dispatch tail — retail has
/// exactly one CPhysicsObj per CMotionInterp, so there is
/// only ever one dispatch target per entity, matching
/// 's single-binding shape.
///
///
/// Retail arg2.
/// Retail arg3.
public WeenieError DoInterpretedMotion(uint motion, MovementParameters p)
=> DoInterpretedMotion(motion, p, DefaultSink);
///
/// Sink-parameterized core shared by the public
/// (which
/// always dispatches through , matching
/// retail's one-sink-per-CPhysicsObj shape) and the internal
///
/// primitive (which needs an EXPLICIT per-call sink for
/// 's remote-entity callers). Same
/// verbatim body either way — only the dispatch target varies.
///
private WeenieError DoInterpretedMotion(uint motion, MovementParameters p, IInterpretedMotionSink? sink)
{
if (PhysicsObj is null)
return WeenieError.NoPhysicsObject;
WeenieError result;
if (contact_allows_move(motion))
{
bool standingLongJumpStateOnly = StandingLongJump
&& (motion == MotionCommand.WalkForward
|| motion == MotionCommand.RunForward
|| motion == MotionCommand.SideStepRight);
if (standingLongJumpStateOnly)
{
// label_528440 — state-only: no dispatch, no queue.
if (p.ModifyInterpretedState)
InterpretedState.ApplyMotion(motion, p);
return WeenieError.None;
}
if (motion == MotionCommand.Dead)
RemoveLinkAnimations?.Invoke();
// sink.ApplyMotion stands in for CPhysicsObj::DoInterpretedMotion
// — its bool return IS retail's `result == 0` (a null sink, no
// dispatch backend wired, is treated as succeeding — matches
// every call site that doesn't care about the animation-table
// backend, e.g. bare unit tests exercising queue/state behavior
// only). A FAILED dispatch (the motion table found no cycle for
// this id — genuinely the case for style/stance ids) skips BOTH
// the queue AND the state-write, exactly like the "blocked,
// non-action" apply-only path below — but WITHOUT writing state.
bool dispatchOk = sink?.ApplyMotion(motion, p.Speed) ?? true;
if (!dispatchOk)
{
// Retail: `result = CPhysicsObj::DoInterpretedMotion(...)` is
// whatever the failed dispatch returned (raw 305591-305593;
// no `else` branch — `result` simply isn't overwritten by
// the `if (result == 0)` block), so DoInterpretedMotion's
// OWN return value on a failed sink dispatch is that
// nonzero code, NOT 0. The exact numeric value is sink-
// internal (MotionTableManagerError.MotionFailed = 0x43,
// not one of CMotionInterp's own WeenieError codes) — no
// CMotionInterp-level WeenieError constant maps to it
// 1:1, so this port surfaces the closest existing local
// analog (GeneralMovementFailure, 0x47) rather than
// inventing a new enum member for a sink-internal code
// that's never queued or otherwise observed by any
// CMotionInterp caller. Flagged as an ambiguity — no test
// in this repo asserts DoInterpretedMotion's return value
// on a failed dispatch (only the queue/state SIDE EFFECTS,
// which correctly do NOT run here).
result = WeenieError.GeneralMovementFailure;
}
else
{
WeenieError jumpErr;
if (!p.DisableJumpDuringLink)
{
jumpErr = MotionAllowsJump(motion);
if (jumpErr == WeenieError.None && (motion & 0x10000000u) == 0)
jumpErr = MotionAllowsJump(InterpretedState.ForwardCommand);
}
else
{
jumpErr = WeenieError.YouCantJumpFromThisPosition; // 0x48 — forced BLOCKED
}
AddToQueue(p.ContextId, motion, (uint)jumpErr);
if (p.ModifyInterpretedState)
InterpretedState.ApplyMotion(motion, p);
result = WeenieError.None;
}
}
else if ((motion & 0x10000000u) == 0)
{
// label_528440 (reached without the StandingLongJump goto) —
// apply-only: state kept, no cycle change, no queue. This is
// retail's real mechanism behind "airborne remotes keep their
// Falling cycle".
if (p.ModifyInterpretedState)
InterpretedState.ApplyMotion(motion, p);
result = WeenieError.None;
}
else
{
result = WeenieError.NotGrounded; // 0x24 (A10) — blocked action-class motion
}
// R4-V5 door-swing fix (2026-07-03): retail's guard is
// physics_obj->cell == 0 — DETACHED objects only (raw @305627).
// The old proxy (CellPosition.ObjCellId == 0, #145 machinery seeded
// only by the local player's SnapToCell) read every REMOTE body as
// detached, so every dispatch stripped the just-appended transition
// link (door swings snapped; remote walk↔run links died) — see
// PhysicsBody.InWorld (register row).
if (!PhysicsObj.InWorld)
RemoveLinkAnimations?.Invoke();
return result;
}
///
/// CMotionInterp::StopInterpretedMotion (0x00528470, raw
/// 305635-305670, FULL BODY, R3-W5, closes J4's StopInterpretedMotion
/// leg). THE ONE StopInterpretedMotion — merges the former legacy
/// overload with the funnel's bare sink.StopMotion call.
///
///
/// Verbatim (raw 305638-305670):
///
/// if (physics_obj == 0) return 8;
/// if (contact_allows_move(arg2) == 0
/// || (standing_longjump != 0
/// && (arg2 == WalkForward || arg2 == RunForward || arg2 == SideStepRight))) {
/// if (arg3->bitfield & 0x4000) InterpretedMotionState::RemoveMotion(arg2);
/// result = 0;
/// } else {
/// result = sink.StopMotion(arg2, arg3); // CPhysicsObj::StopInterpretedMotion stand-in
/// if (result == 0) {
/// add_to_queue(arg3->context_id, 0x41000003, result); // result == 0 here
/// if (arg3->bitfield & 0x4000) InterpretedMotionState::RemoveMotion(arg2);
/// }
/// }
/// if (physics_obj != 0 && physics_obj->cell == 0) RemoveLinkAnimations(physics_obj);
/// return result;
///
///
///
///
/// R3-W5 (post-fix):
/// returns a real success signal (see that interface member's doc for
/// why a void sink broke the 183-case live retail conformance
/// suite on the analogous DoInterpretedMotion path) — a FAILED
/// stop dispatch skips both the add_to_queue node and the
/// InterpretedMotionState::RemoveMotion state clear, matching
/// retail's if (result == 0) { ... } gate exactly. On success,
/// the queued node's JumpErrorCode is always 0 (the stop's
/// own "return value" that becomes the queued error code is
/// result, which is 0 on that path).
///
///
/// Retail arg2.
/// Retail arg3.
public WeenieError StopInterpretedMotion(uint motion, MovementParameters p)
=> StopInterpretedMotion(motion, p, DefaultSink);
///
/// Sink-parameterized core shared by the public
/// and the
/// internal primitive — same
/// split rationale as
/// .
///
private WeenieError StopInterpretedMotion(uint motion, MovementParameters p, IInterpretedMotionSink? sink)
{
if (PhysicsObj is null)
return WeenieError.NoPhysicsObject;
WeenieError result;
bool standingLongJumpStateOnly = StandingLongJump
&& (motion == MotionCommand.WalkForward
|| motion == MotionCommand.RunForward
|| motion == MotionCommand.SideStepRight);
if (!contact_allows_move(motion) || standingLongJumpStateOnly)
{
if (p.ModifyInterpretedState)
InterpretedState.RemoveMotion(motion);
result = WeenieError.None;
}
else
{
// sink.StopMotion stands in for CPhysicsObj::StopInterpretedMotion
// — its bool return IS retail's `result == 0`. A null sink (no
// dispatch backend wired) is treated as succeeding, matching
// DoInterpretedMotion's identical posture.
bool dispatchOk = sink?.StopMotion(motion) ?? true;
if (!dispatchOk)
{
// See DoInterpretedMotion's identical ambiguity note: retail
// returns the sink's own nonzero code here (raw 305652-305653,
// no `else` — `result` keeps whatever CPhysicsObj::StopInterpretedMotion
// returned); ported as the closest local analog.
result = WeenieError.GeneralMovementFailure;
}
else
{
result = WeenieError.None;
AddToQueue(p.ContextId, MotionCommand.Ready, (uint)result);
if (p.ModifyInterpretedState)
InterpretedState.RemoveMotion(motion);
}
}
// R4-V5 door-swing fix (2026-07-03): retail's guard is
// physics_obj->cell == 0 — DETACHED objects only (raw @305627).
// The old proxy (CellPosition.ObjCellId == 0, #145 machinery seeded
// only by the local player's SnapToCell) read every REMOTE body as
// detached, so every dispatch stripped the just-appended transition
// link (door swings snapped; remote walk↔run links died) — see
// PhysicsBody.InWorld (register row).
if (!PhysicsObj.InWorld)
RemoveLinkAnimations?.Invoke();
return result;
}
///
/// R3-W5 internal per-axis dispatch primitive
/// (DispatchInterpretedMotion, formerly the S2a funnel's public
/// surface). 's four
/// DoInterpretedMotion-shaped calls (style, forward-or-Falling,
/// sidestep, turn) each need to dispatch through an EXPLICIT per-call
/// (a remote entity's own sink, threaded in from
/// ) — retail's own
/// apply_interpreted_movement calls CMotionInterp::DoInterpretedMotion
/// directly (the SAME function
///
/// implements) with a fresh local MovementParameters per axis —
/// this wraps that shape with a throwaway params object carrying only
/// Speed (ctor default for everything else, matching retail's own
/// local var_2c).
///
private WeenieError DispatchInterpretedMotion(uint motion, float speed, IInterpretedMotionSink? sink)
=> DoInterpretedMotion(motion, new MovementParameters { Speed = speed }, sink);
///
/// R3-W5 internal per-axis STOP primitive — the
///
/// analogue of , used by
/// 's sidestep-stop/turn-stop tail
/// calls (raw 305739/305748/305770 — retail's own
/// CPhysicsObj::StopInterpretedMotion calls with a fresh local
/// MovementParameters).
///
private WeenieError DispatchStopInterpretedMotion(uint motion, IInterpretedMotionSink? sink)
=> StopInterpretedMotion(motion, new MovementParameters(), sink);
}