acdream/src/AcDream.Core/Physics/MotionInterpreter.cs
Erik 350fb5e3a5 fix(R4-V5): remote transition links stripped by the detached-object guard - door swings snapped (adds register TS-40)
Root cause (pinned by the worktree investigation + verified against
source): CMotionInterp's dispatch tails strip link animations for
DETACHED objects only (retail `if (physics_obj->cell == 0)
RemoveLinkAnimations`, raw @305627, three ported sites:
DoInterpretedMotion / StopInterpretedMotion / StopCompletely). The R3
port proxied retail's cell pointer with CellPosition.ObjCellId == 0 -
but that #145 field is seeded ONLY by SnapToCell, whose single
production caller is the LOCAL PLAYER's SetPosition. Every REMOTE body
therefore read "detached" forever, and every dispatch stripped the
transition link the motion table had just appended: a used door's
swing link died the same tick (pose snapped closed<->open - the live
symptom), and remote walk<->run link poses have been silently eaten
since the guard landed (masked: locomotion cycles resemble each other;
a door's two poses don't). The dispatcher itself (GetObjectSequence
link path) was verified retail-verbatim end-to-end - not the bug.

Fix: PhysicsBody.InWorld - an explicit "placed in the world" flag
carrying exactly the guard's retail meaning (register row TS-40,
replacing the previously UNREGISTERED ObjCellId proxy). Set by
SnapToCell (retail: enter_world/set_cell assigns the cell) and by
RemoteMotion construction (a RemoteMotion exists only for a world
entity). All three guard sites now test !InWorld. Default false =
retail's pre-enter_world detached state, preserving the guard for
genuinely unplaced bodies (bare-harness interps unchanged).

Tests: InWorldLinkGuardTests pins the polarity at all guard classes
(in-world dispatch keeps links; detached strips; StopCompletely same).
Full suite green: 3,961.

If the door still snaps after this, the open question is DATA (does
the door's table author an On<->Off link at all) - next step per the
investigation: dump the real table's Links/StyleDefaults via a
DoorSetupGfxObjInspectionTests extension.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:10:16 +02:00

3295 lines
152 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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) ────────────────
/// <summary>
/// Raw AC motion command IDs used in CMotionInterp.
/// Values sourced from the decompiled comparisons in chunk_00520000.c and
/// confirmed against ACE's MotionCommand enum.
/// </summary>
public static class MotionCommand
{
/// <summary>0x41000003 — idle/default state.</summary>
public const uint Ready = 0x41000003u;
/// <summary>0x45000005 — walk forward.</summary>
public const uint WalkForward = 0x45000005u;
/// <summary>0x44000007 — run forward.</summary>
public const uint RunForward = 0x44000007u;
/// <summary>0x45000006 — walk backward.</summary>
public const uint WalkBackward = 0x45000006u;
/// <summary>0x6500000D — turn right.</summary>
public const uint TurnRight = 0x6500000Du;
/// <summary>0x6500000E — turn left.</summary>
public const uint TurnLeft = 0x6500000Eu;
/// <summary>0x6500000F — sidestep right.</summary>
public const uint SideStepRight = 0x6500000Fu;
/// <summary>0x65000010 — sidestep left.</summary>
public const uint SideStepLeft = 0x65000010u;
/// <summary>0x40000008 — Fallen (lying on ground).</summary>
public const uint Fallen = 0x40000008u;
/// <summary>
/// 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 <see cref="AnimationSequencer.SetCycle"/>
/// when airborne; swap back to Ready/WalkForward/RunForward on land.
/// </summary>
public const uint Falling = 0x40000015u;
/// <summary>
/// 0x2500003B — Jump (Modifier flag). NOT an animation trigger; retail
/// uses this as a state flag internally. Kept for future use.
/// </summary>
public const uint Jump = 0x2500003Bu;
/// <summary>
/// 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.
/// </summary>
public const uint Jumpup = 0x1000004Bu;
/// <summary>
/// 0x10000050 — FallDown (Action). Same story as Jumpup; not in the
/// humanoid motion table's Links. Landing returns to Ready via the
/// regular SetCycle transition.
/// </summary>
public const uint FallDown = 0x10000050u;
/// <summary>0x40000011 - persistent dead substate.</summary>
public const uint Dead = 0x40000011u;
/// <summary>0x10000057 - Sanctuary death-trigger action.</summary>
public const uint Sanctuary = 0x10000057u;
/// <summary>0x41000012 - crouching substate.</summary>
public const uint Crouch = 0x41000012u;
/// <summary>0x41000013 - sitting substate.</summary>
public const uint Sitting = 0x41000013u;
/// <summary>0x41000014 - sleeping substate.</summary>
public const uint Sleeping = 0x41000014u;
/// <summary>0x41000011 — Crouch lower bound for blocked-jump check.</summary>
public const uint CrouchLowerBound = 0x41000011u;
/// <summary>0x41000015 - exclusive upper bound of crouch/sit/sleep range.</summary>
public const uint CrouchUpperExclusive = 0x41000015u;
}
/// <summary>
/// Movement type passed in PerformMovement's switch statement. Matches
/// retail's <c>MovementTypes::Type</c> (acclient.h:2856, enum #229) in full.
///
/// <para>
/// <b>R4-V1 widening (closes M11).</b> Values 1-5 dispatch through
/// <c>CMotionInterp</c> (the 5-case switch at FUN_00529a90, unchanged since
/// R1-R3); values 6-9 dispatch through <c>MoveToManager</c>
/// (<c>MovementManager::PerformMovement</c>, r4-moveto-decomp.md §2b:
/// <c>(type - 1) &gt; 8 → 0x47</c>, case 0-4 → CMotionInterp, case 5-8 →
/// MoveToManager). <c>Invalid=0</c> and values &gt; 9 both fail with
/// <see cref="WeenieError.GeneralMovementFailure"/> (0x47) at the
/// MovementManager level — no consumer wiring changes in this slice
/// (mechanical, additive-only; MoveToManager itself is R4-V2+).
/// </para>
/// </summary>
public enum MovementType
{
/// <summary>
/// 0 — no movement in progress / uninitialized. R4-V1 addition (M11).
/// <c>MoveToManager::InitializeLocalVariables</c> resets
/// <c>movement_type</c> to this value; <c>MovementManager::PerformMovement</c>
/// rejects it with 0x47 (§2b: <c>(type - 1) &gt; 8</c> underflows to a
/// huge unsigned value for type 0, which is always &gt; 8).
/// </summary>
Invalid = 0,
/// <summary>case 1 — raw motion command (DoMotion).</summary>
RawCommand = 1,
/// <summary>case 2 — interpreted motion command (DoInterpretedMotion).</summary>
InterpretedCommand = 2,
/// <summary>case 3 — stop raw motion (StopMotion).</summary>
StopRawCommand = 3,
/// <summary>case 4 — stop interpreted motion (StopInterpretedMotion).</summary>
StopInterpretedCommand = 4,
/// <summary>case 5 — stop completely (StopCompletely).</summary>
StopCompletely = 5,
/// <summary>
/// 6 — MoveToObject. R4-V1 addition (M11). Dispatches to
/// <c>MoveToManager::MoveToObject</c> (r4-moveto-decomp.md §3b); uses
/// <see cref="MovementStruct.ObjectId"/>/<see cref="MovementStruct.TopLevelId"/>/
/// <see cref="MovementStruct.Radius"/>/<see cref="MovementStruct.Height"/>.
/// </summary>
MoveToObject = 6,
/// <summary>
/// 7 — MoveToPosition. R4-V1 addition (M11). Dispatches to
/// <c>MoveToManager::MoveToPosition</c> (§3c); uses
/// <see cref="MovementStruct.Pos"/>.
/// </summary>
MoveToPosition = 7,
/// <summary>
/// 8 — TurnToObject. R4-V1 addition (M11). Dispatches to
/// <c>MoveToManager::TurnToObject</c> (§3d); uses
/// <see cref="MovementStruct.ObjectId"/>/<see cref="MovementStruct.TopLevelId"/>.
/// </summary>
TurnToObject = 8,
/// <summary>
/// 9 — TurnToHeading. R4-V1 addition (M11). Dispatches to
/// <c>MoveToManager::TurnToHeading</c> (§3e); uses
/// <see cref="MovementStruct.Params"/>'s <c>DesiredHeading</c>.
/// </summary>
TurnToHeading = 9,
}
/// <summary>
/// WeenieError-shaped codes returned by CMotionInterp methods. Values are
/// the hex constants used directly in the decompiled C code.
///
/// <para>
/// <b>R3-W1 renumber (closes J16-codes/A10).</b> Prior to this slice, the
/// names were shuffled relative to retail's actual numeric semantics
/// (<c>GeneralMovementFailure</c> 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
/// (<c>motion_allows_jump</c>), not an airborne check). Renumbered per the
/// definitive, exhaustively-swept table in
/// <c>docs/research/2026-07-02-r3-motioninterp/W0-pins.md</c> §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
/// <c>MotionInterpreter.cs</c> pattern-matches on the numeric value).
/// </para>
/// </summary>
public enum WeenieError : uint
{
/// <summary>0x00 — success.</summary>
None = 0x00,
/// <summary>
/// 0x08 — no <c>physics_obj</c>. Sites (A10): StopCompletely @305214;
/// DoInterpretedMotion @305579; StopInterpretedMotion @305639;
/// StopMotion @305680; jump @305798; DoMotion @306165.
/// </summary>
NoPhysicsObject = 0x08,
/// <summary>
/// 0x0B — NoMotionInterpreter. R4-V1 addition (M12), per
/// docs/research/2026-07-03-r4-moveto/r4-moveto-decomp.md §12 constants
/// inventory (<c>8, 0xb, 0x36, 0x37, 0x38, 0x3d, 0x47</c>). 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).
/// </summary>
NoMotionInterpreter = 0x0B,
/// <summary>
/// 0x24 — not grounded / no contact. Sites (A10):
/// <c>jump_is_allowed</c> @305570 (gravity-active creature without
/// Contact+OnWalkable; also the <c>physics_obj == null</c> case, which
/// falls out to this same code per the A10 note — NOT 8);
/// <c>DoInterpretedMotion</c> @305622-305623 (action-class motion
/// blocked by <c>contact_allows_move</c>).
/// </summary>
NotGrounded = 0x24,
/// <summary>
/// 0x3f — Crouch (0x41000012) rejected while in combat stance. Site:
/// DoMotion @306196.
/// </summary>
CrouchInCombatStance = 0x3f,
/// <summary>
/// 0x40 — Sitting (0x41000013) rejected while in combat stance. Site:
/// DoMotion @306199.
/// </summary>
SitInCombatStance = 0x40,
/// <summary>
/// 0x41 — Sleeping (0x41000014) rejected while in combat stance. Site:
/// DoMotion @306202.
/// </summary>
SleepInCombatStance = 0x41,
/// <summary>
/// 0x42 — <c>motion &amp; 0x2000000</c> (the chat-emote bit) rejected
/// outside NonCombat (0x8000003d). Site: DoMotion @306205.
/// </summary>
ChatEmoteOutsideNonCombat = 0x42,
/// <summary>
/// 0x36 — ActionCancelled. R4-V1 addition (M12). Site:
/// <c>MoveToManager::PerformMovement</c> (r4-moveto-decomp.md §3a
/// @0052a901) — every new moveto cancels the previous one with this
/// code before dispatching; also <c>CPhysicsObj::interrupt_current_movement</c>
/// → <c>MovementManager::CancelMoveTo(0x36)</c> (§9e — the TS-36 cancel
/// entry). Per §7c, <c>MoveToManager::CancelMoveTo</c>'s WeenieError arg
/// is NEVER READ in this build's body — kept for parity/logging only.
/// </summary>
ActionCancelled = 0x36,
/// <summary>
/// 0x37 — ObjectGone. R4-V1 addition (M12). Site:
/// <c>MoveToManager::HandleUpdateTarget</c> (§6d @307866-307867) — a
/// RETARGET delivery arrives with a non-OK target status (the target
/// object was already being tracked, then went away).
/// </summary>
ObjectGone = 0x37,
/// <summary>
/// 0x38 — NoObject. R4-V1 addition (M12). Site:
/// <c>MoveToManager::HandleUpdateTarget</c> (§6d @307857-307858) — the
/// FIRST target callback arrives with a non-OK status (the target never
/// resolved in the first place).
/// </summary>
NoObject = 0x38,
/// <summary>
/// 0x45 — action-queue depth cap: an action-class motion (bit
/// 0x10000000) with <c>GetNumActions() &gt;= 6</c> pending. Site:
/// DoMotion @306209.
/// </summary>
ActionDepthExceeded = 0x45,
/// <summary>
/// 0x47 — general movement failure. Sites (A10):
/// <c>jump_is_allowed</c> @305525 (<c>IsFullyConstrained</c>);
/// @305549+305556 (<c>JumpStaminaCost</c> refusal);
/// <c>CMotionInterp::PerformMovement</c> @306227 (dispatch type-1 &gt; 4);
/// <c>MovementManager::PerformMovement</c> @300201 (dispatch type-1 &gt; 8).
/// </summary>
GeneralMovementFailure = 0x47,
/// <summary>
/// 0x48 — jump BLOCKED by the current motion or position (A1: the
/// <c>motion_allows_jump</c> literal-range blocklist — NOT an airborne
/// check; airborne is 0x24). Sites (A10):
/// <c>motion_allows_jump</c> @304930; <c>jump_charge_is_allowed</c>
/// @304948 (Fallen or Crouch..Sleeping); <c>charge_jump</c> @305459
/// (same predicate); STORED (not returned) as the queue node's
/// <c>jump_error_code</c> in <c>DoInterpretedMotion</c> @305605 when
/// <c>disable_jump_during_link</c> is set.
/// </summary>
YouCantJumpFromThisPosition = 0x48,
/// <summary>
/// 0x49 — the weenie's <c>CanJump(jump_extent)</c> virtual refused
/// (e.g. stamina/burden gate). Sites: <c>jump_charge_is_allowed</c>
/// @304941; <c>charge_jump</c> @305454.
/// </summary>
CantJumpLoadedDown = 0x49,
/// <summary>
/// 0x3D — YouChargedTooFar. R4-V1 addition (M12). Site:
/// <c>MoveToManager::HandleMoveToPosition</c> Phase 2 arrival check
/// (r4-moveto-decomp.md §6b) — the <c>fail_distance</c> progress gate
/// exceeded (<c>CheckProgressMade</c> §5b failing for &gt;1s AND the
/// mover overshot <c>fail_distance</c>). NOTE: numerically out of A10's
/// increasing order (0x3D &lt; 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).
/// </summary>
YouChargedTooFar = 0x3D,
}
// ── Motion state structs ───────────────────────────────────────────────────────
/// <summary>
/// Interpreted motion state, derived from the raw state (retail
/// <c>InterpretedMotionState</c>, ctor 0x0051e8d0, decomp 293418-293431).
/// Struct layout: starts at offset +0x44 (ForwardCommand at +0x4C, ForwardSpeed at +0x50).
///
/// <para>
/// <b>R3-W1 (closes J2):</b> gains retail's action FIFO
/// (<see cref="AddAction"/>/<see cref="RemoveAction"/>/<see cref="GetNumActions"/>/
/// <see cref="ApplyMotion"/>/<see cref="RemoveMotion"/>) — previously this was
/// a flat 6-field struct with no action tracking, so
/// <c>DoMotion</c>'s <c>GetNumActions() &gt;= 6</c> depth cap and
/// <c>MotionDone</c>'s action-class <c>RemoveAction</c> pop had nothing to
/// operate on. The action list is backed by a private
/// <see cref="List{T}"/> lazily created on first mutation (defensive
/// against a bare <c>default(InterpretedMotionState)</c>, which C# structs
/// permit even though every constructor in this file routes through
/// <see cref="Default"/>).
/// </para>
/// </summary>
public struct InterpretedMotionState
{
/// <summary>Forward/backward interpreted command (offset +0x4C).</summary>
public uint ForwardCommand;
/// <summary>Speed scalar for interpreted forward motion (offset +0x50).</summary>
public float ForwardSpeed;
/// <summary>Sidestep interpreted command (offset +0x54).</summary>
public uint SideStepCommand;
/// <summary>Speed scalar for interpreted sidestep (offset +0x58).</summary>
public float SideStepSpeed;
/// <summary>Turn interpreted command (offset +0x5C).</summary>
public uint TurnCommand;
/// <summary>Speed scalar for turn (offset +0x60).</summary>
public float TurnSpeed;
/// <summary>Current style / stance (retail current_style). Adopted from
/// the raw state's style channel; NOT part of retail's
/// <c>InterpretedMotionState::ApplyMotion</c> dispatch itself (that
/// writes <c>this-&gt;current_style</c> only via the negative-motion
/// branch — see <see cref="ApplyMotion"/>). Default NonCombat
/// 0x8000003D.</summary>
public uint CurrentStyle;
private List<RawMotionAction>? _actions;
/// <summary>Action FIFO in retail order (oldest first). Empty (never
/// null) when read — the private field is lazily created.</summary>
public readonly IReadOnlyList<RawMotionAction> Actions
=> (IReadOnlyList<RawMotionAction>?)_actions ?? Array.Empty<RawMotionAction>();
/// <summary>Initialize to the idle/ready state.</summary>
public static InterpretedMotionState Default() => new()
{
ForwardCommand = MotionCommand.Ready,
ForwardSpeed = 1.0f,
SideStepCommand = 0,
SideStepSpeed = 1.0f,
TurnCommand = 0,
TurnSpeed = 1.0f,
CurrentStyle = 0x8000003Du,
};
/// <summary>
/// <c>InterpretedMotionState::AddAction</c> (0x0051e9e0, decomp
/// 293500-293527): unconditional tail-append of
/// <c>{motion, speed, action_stamp, autonomous}</c>. Identical shape to
/// <see cref="RawMotionState.AddAction"/> — retail duplicates the
/// LListData append logic per state type rather than sharing it.
/// </summary>
public void AddAction(uint motion, float speed, uint actionStamp, bool autonomous)
{
_actions ??= new List<RawMotionAction>();
_actions.Add(new RawMotionAction(
Command: (ushort)motion,
Stamp: (ushort)actionStamp,
Autonomous: autonomous,
Speed: speed));
}
/// <summary>
/// <c>InterpretedMotionState::RemoveAction</c> (0x0051ead0, decomp
/// 293568-293586): pop the FIFO head unconditionally, returning its
/// <c>motion</c> field (0 when empty).
/// </summary>
public uint RemoveAction()
{
if (_actions is null || _actions.Count == 0)
return 0;
var head = _actions[0];
_actions.RemoveAt(0);
return head.Command;
}
/// <summary>
/// <c>InterpretedMotionState::GetNumActions</c> (0x0051eb00, decomp
/// 293590-293603): count the FIFO by walking it (retail has no O(1)
/// count field on the LList).
/// </summary>
public readonly uint GetNumActions() => (uint)(_actions?.Count ?? 0);
/// <summary>
/// <c>InterpretedMotionState::ApplyMotion</c> (0x0051ea40, decomp
/// 293531-293564). Verbatim dispatch, quoted from the raw named decomp:
/// <code>
/// if (arg2 == 0x6500000d) { turn_command = arg2; turn_speed = params.speed; return; }
/// if (arg2 == 0x6500000f) { sidestep_command = arg2; sidestep_speed = params.speed; return; }
/// if ((arg2 &amp; 0x40000000) != 0) { forward_command = arg2; forward_speed = params.speed; return; }
/// if (arg2 &lt; 0) { forward_command = 0x41000003; current_style = arg2; return; }
/// if ((arg2 &amp; 0x10000000) != 0)
/// AddAction(arg2, params.speed, params.action_stamp, (params.bitfield&gt;&gt;0xc)&amp;1);
/// </code>
/// Note only TurnRight (0x6500000d) and SideStepRight (0x6500000f) are
/// tested here — unlike <see cref="RawMotionState.ApplyMotion"/>, the
/// LEFT variants (TurnLeft/SideStepLeft) are NOT separately cased;
/// retail's <c>adjust_motion</c> normalizes Left→Right upstream before
/// this ever runs, so this asymmetry is verbatim, not a gap.
/// </summary>
/// <param name="motion">Retail <c>arg2</c> — the motion id AFTER
/// <c>adjust_motion</c> normalization (the ADJUSTED id, unlike
/// <see cref="RawMotionState.ApplyMotion"/> which takes the ORIGINAL).</param>
/// <param name="p">Retail <c>arg3</c> (<c>MovementParameters const*</c>).</param>
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);
}
/// <summary>
/// <c>InterpretedMotionState::RemoveMotion</c> (0x0051e790, decomp
/// 293315-293340):
/// <code>
/// if (arg2 == 0x6500000d) { turn_command = 0; return; }
/// if (arg2 == 0x6500000f) { sidestep_command = 0; return; }
/// if ((arg2 &amp; 0x40000000) == 0) {
/// if (arg2 &lt; 0 &amp;&amp; arg2 == current_style) current_style = 0x8000003d;
/// } else if (arg2 == forward_command) {
/// forward_command = 0x41000003; forward_speed = 1f;
/// }
/// </code>
/// Note the asymmetric range test vs <see cref="RawMotionState.RemoveMotion"/>
/// (which uses <c>(arg2 - 0x6500000d) &gt; 3</c> 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).
/// </summary>
/// <param name="motion">Retail <c>arg2</c>.</param>
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;
}
}
}
/// <summary>
/// Lightweight struct passed into PerformMovement.
/// Fields correspond to what the retail dispatcher read from param_1 (the movement packet struct).
///
/// <para>
/// <b>R4-V1 widening (closes M11).</b> Retail's full <c>MovementStruct</c>
/// (acclient.h:38069, struct #4067):
/// <code>
/// 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
/// };
/// </code>
/// <see cref="ObjectId"/>/<see cref="TopLevelId"/>/<see cref="Pos"/>/
/// <see cref="Radius"/>/<see cref="Height"/>/<see cref="Params"/> are the
/// R4-V1 additions — additive only, no consumer wiring in this slice
/// (MoveToManager itself is R4-V2). The pre-R4 fields (<see cref="Type"/>/
/// <see cref="Motion"/>/<see cref="Speed"/>/<see cref="Autonomous"/>/
/// <see cref="ModifyInterpretedState"/>/<see cref="ModifyRawState"/>) are
/// untouched. <see cref="Pos"/> uses acdream's <see cref="Position"/>
/// (ObjCellId + CellFrame) rather than retail's block-local Position —
/// V0-pins.md §P5: distances are equivalent after rebase in acdream's
/// streaming-world space.
/// </para>
/// </summary>
public struct MovementStruct
{
/// <summary>Which movement type to dispatch (retail <c>MovementTypes::Type</c>, full 0-9 range).</summary>
public MovementType Type;
/// <summary>Motion command ID (e.g. WalkForward). Types 1-4 only.</summary>
public uint Motion;
/// <summary>Speed scalar for this motion.</summary>
public float Speed;
/// <summary>Autonomous (player-initiated) flag.</summary>
public bool Autonomous;
/// <summary>Whether to modify the interpreted state.</summary>
public bool ModifyInterpretedState;
/// <summary>Whether to modify the raw state.</summary>
public bool ModifyRawState;
/// <summary>
/// R4-V1 — retail <c>object_id</c>. Types 6 (MoveToObject), 8
/// (TurnToObject) only.
/// </summary>
public uint ObjectId;
/// <summary>
/// R4-V1 — retail <c>top_level_id</c>. Types 6 (MoveToObject), 8
/// (TurnToObject) only.
/// </summary>
public uint TopLevelId;
/// <summary>
/// R4-V1 — retail <c>pos</c> (world position + cell). Type 7
/// (MoveToPosition) only.
/// </summary>
public Position Pos;
/// <summary>
/// R4-V1 — retail <c>radius</c>. Type 6 (MoveToObject) only.
/// </summary>
public float Radius;
/// <summary>
/// R4-V1 — retail <c>height</c>. Type 6 (MoveToObject) only.
/// </summary>
public float Height;
/// <summary>
/// R4-V1 — retail <c>params</c> (a pointer in retail; a reference
/// here). Types 1-4 and 6-9.
/// </summary>
public Motion.MovementParameters? Params;
}
// ── Optional WeenieObject interface ──────────────────────────────────────────
/// <summary>
/// Minimal interface for the server-side WeenieObject callbacks that CMotionInterp
/// reaches through at vtable offsets +0x30, +0x34, +0x3C.
/// Allows testing without a real weenie.
/// </summary>
public interface IWeenieObject
{
/// <summary>vtable +0x30 — InqJumpVelocity. Returns true and sets vz if valid.</summary>
bool InqJumpVelocity(float extent, out float vz);
/// <summary>vtable +0x34 — InqRunRate. Returns true and sets rate if valid.</summary>
bool InqRunRate(out float rate);
/// <summary>vtable +0x3C — CanJump. Returns true if the weenie can jump at this extent.</summary>
bool CanJump(float extent);
/// <summary>
/// Retail <c>CWeenieObject::IsCreature</c>. Non-creature weenies bypass
/// the ground-contact gate in <c>contact_allows_move</c> (0x00528240),
/// the run remap in <c>adjust_motion</c> (wired R3-W4, closes J18 —
/// register TS-34 retired), and the <see cref="HitGround"/>/
/// <see cref="LeaveGround"/> creature gates. Players, NPCs, and
/// monsters are creatures — default true keeps existing implementers
/// retail-correct.
/// </summary>
bool IsCreature() => true;
/// <summary>
/// Retail <c>ACCWeenieObject::IsThePlayer</c> (vtable +0x14,
/// 0x0058C3D0): <c>this-&gt;id == SmartBox::smartbox-&gt;player_id</c>.
/// PINNED (W0-pins.md A3, adversarially verified) as the dual-dispatch
/// gate for <c>apply_current_movement</c>/<c>ReportExhaustion</c>/
/// <c>SetWeenieObject</c>/<c>SetPhysicsObject</c> — NOT <c>IsCreature</c>
/// (a remote player is a creature but not the player; ACE's server-side
/// <c>IsCreature</c> 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.
/// </summary>
bool IsThePlayer() => false;
/// <summary>
/// Retail <c>CWeenieObject::JumpStaminaCost</c> (vtable +0x44, referenced
/// from <c>jump_is_allowed</c> raw 305549-305556): given the charge
/// <paramref name="extent"/>, returns whether the weenie can afford the
/// stamina cost of this jump and writes the cost to
/// <paramref name="cost"/>. <c>jump_is_allowed</c> treats a <c>false</c>
/// return as <see cref="WeenieError.GeneralMovementFailure"/> (0x47).
/// Default true / cost 0 — real stamina gating stays TS-5-deferred (the
/// same register row that already covers <c>CanJump</c> always-true;
/// this extends it to <c>JumpStaminaCost</c> until stat plumbing lands).
/// </summary>
bool JumpStaminaCost(float extent, out int cost)
{
cost = 0;
return true;
}
}
// ── MotionInterpreter ─────────────────────────────────────────────────────────
/// <summary>
/// C# port of CMotionInterp (chunk_00520000.c).
///
/// Owns the raw and interpreted motion states for a physics object and
/// translates network movement commands into PhysicsBody velocity calls.
///
/// <para>
/// <b>R3-W2 (closes J1, J17):</b> implements <see cref="IMotionDoneSink"/> —
/// the entity's <c>MotionTableManager</c> (R2-Q3) binds its animation-done
/// callback here, standing in for retail's null-guarded relay chain
/// <c>CPhysicsObj::MotionDone</c> 0x0050fdb0 → <c>MovementManager::MotionDone</c>
/// 0x005242d0 → <c>CMotionInterp::MotionDone</c> 0x00527ec0 (r3-port-plan.md
/// §4). This adds the <c>pending_motions</c> queue (retail
/// <c>CMotionInterp::pending_motions</c>, a singly-linked <c>LList</c> —
/// ported as a managed <see cref="LinkedList{T}"/> per the AD-34 managed-list
/// register wording) that the funnel's dispatch/stop producers populate and
/// <see cref="MotionDone"/>/<see cref="HandleExitWorld"/> drain.
/// </para>
/// </summary>
public sealed class MotionInterpreter : IMotionDoneSink
{
// ── animation speed constants (from ACE / confirmed by decompile globals) ─
/// <summary>Walk animation base speed. Retail-exact (.rdata 0x007c891c =
/// 3.11999989f); unified for both <see cref="get_state_velocity"/> and
/// <see cref="adjust_motion"/>'s sidestep scale in D6.2.</summary>
public const float WalkAnimSpeed = 3.11999989f;
/// <summary>Run animation base speed (_DAT_007c96e0 family).</summary>
public const float RunAnimSpeed = 4.0f;
/// <summary>Sidestep animation base speed (_DAT_007c96e8 family).</summary>
public const float SidestepAnimSpeed = 1.25f;
/// <summary>
/// Minimum jump extent before get_jump_v_z bothers computing
/// (_DAT_007c9734). R3-W3 (closes J16-epsilons, A5/A6): retail's exact
/// literal is <c>0.000199999995f</c> (raw @304959:
/// <c>((long double)0.000199999995f)</c>), NOT the previous <c>0.001</c>
/// approximation. Used by both <see cref="GetJumpVZ"/> and
/// <see cref="GetLeaveGroundVelocity"/> (three times there, one per
/// axis — A6).
/// </summary>
public const float JumpVzEpsilon = 0.000199999995f;
/// <summary>Fallback vertical jump velocity when WeenieObj is absent (_DAT_0079c6d4).</summary>
public const float DefaultJumpVz = 10.0f;
/// <summary>Maximum jump extent clamped by get_jump_v_z (_DAT_007938b0 = 1.0f).</summary>
public const float MaxJumpExtent = 1.0f;
/// <summary>
/// Backward-speed multiplier applied by <see cref="adjust_motion"/> when
/// remapping WalkBackward → WalkForward (retail .rdata 0x007c8910).
/// Retail-exact value; do not round to 0.65f.
/// </summary>
public const float BackwardsFactor = 0.649999976f;
/// <summary>
/// Turn speed multiplier applied by <see cref="apply_run_to_command"/> 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).
/// </summary>
public const float RunTurnFactor = 1.5f;
/// <summary>
/// Symmetric clamp on sidestep interpreted speed applied by
/// <see cref="apply_run_to_command"/> for SideStepRight when running
/// (retail apply_run_to_command SideStepRight clamp, ±3.0).
/// </summary>
public const float MaxSidestepAnimRate = 3.0f;
/// <summary>
/// Retail-exact sidestep scale factor used by <see cref="adjust_motion"/>
/// when remapping to SideStepRight (retail SidestepFactor = 0.5).
/// </summary>
public const float SidestepFactor = 0.5f;
// ── fields (matching struct layout from acclient_function_map.md) ─────────
/// <summary>The physics body this interpreter controls (struct offset +0x08).</summary>
public PhysicsBody? PhysicsObj { get; set; }
/// <summary>Optional WeenieObject for stamina / run-rate queries (struct offset +0x04).</summary>
public IWeenieObject? WeenieObj { get; set; }
/// <summary>
/// Raw (network-derived) motion state (struct offsets +0x14..+0x44).
///
/// <para>
/// R3-W1 fold (closes J2): this now holds the SAME retail-faithful,
/// full-field <see cref="Physics.RawMotionState"/> type the outbound
/// wire packer reads from — the former flat <c>LegacyRawMotionState</c>
/// struct (no action FIFO, no <c>ApplyMotion</c>/<c>RemoveMotion</c>)
/// is deleted. One raw-state type end to end.
/// </para>
/// </summary>
public RawMotionState RawState;
/// <summary>Interpreted motion state derived from raw (struct offsets +0x44..+0x7C).</summary>
public InterpretedMotionState InterpretedState;
/// <summary>Jump charge accumulator — set in jump(), cleared in LeaveGround() (offset +0x74).</summary>
public float JumpExtent;
/// <summary>Stored run rate from last successful InqRunRate call (offset +0x7C).</summary>
public float MyRunRate = 1.0f;
/// <summary>
/// The physics object's currently-held movement key (retail
/// <c>this->raw_state.current_holdkey</c>). <see cref="adjust_motion"/>
/// falls back to this property when the per-channel holdKey argument
/// passed in is <see cref="HoldKey.Invalid"/>. R3-W6: an ALIAS of
/// <see cref="RawMotionState.CurrentHoldKey"/> — retail's adjust_motion
/// reads <c>raw_state.current_holdkey</c> 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).
/// </summary>
public HoldKey CurrentHoldKey => RawState.CurrentHoldKey;
/// <summary>True when crouching-in-place for a standing long jump (offset +0x70).</summary>
public bool StandingLongJump;
/// <summary>
/// R3-W2 — retail <c>CMotionInterp::pending_motions</c> (offset last in
/// the struct, a singly-linked <c>LList&lt;MotionNode&gt;</c>). Ported as
/// a managed <see cref="LinkedList{T}"/> — 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&lt;T&gt; references rather than raw prev/next
/// pointers") applies here exactly as it does to
/// <c>MotionTableManager._pendingAnimations</c> — 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
/// <see cref="AddToQueue"/> / <see cref="MotionDone"/> /
/// <see cref="HandleExitWorld"/>.
/// </summary>
private readonly LinkedList<MotionNode> _pendingMotions = new();
/// <summary>Read-only inspection surface (tests + future callers): the
/// pending motion queue in head-to-tail order.</summary>
public IEnumerable<MotionNode> PendingMotions => _pendingMotions;
/// <summary>
/// R3-W2 no-op seam standing in for retail <c>CPhysicsObj::unstick_from_object</c>
/// (called from <see cref="MotionDone"/>/<see cref="HandleExitWorld"/> when
/// the popped queue head is action-class, i.e. <c>Motion &amp; 0x10000000</c>
/// 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
/// <c>Action?</c> seam convention (see <c>MotionTableDispatchSink.TurnStopped</c>).
/// </summary>
public Action? UnstickFromObject { get; set; }
/// <summary>
/// R3-W3 no-op seam standing in for retail <c>CPhysicsObj::interrupt_current_movement</c>
/// (called unconditionally at the top of <see cref="jump"/>, raw @305800,
/// and by <see cref="StopCompletely"/> per A9). Register row: retail
/// cancels any in-flight MoveToManager transition before starting a new
/// movement action; R4 wires the real <c>cancel_moveto</c> once
/// MoveToManager exists. Until then this is an optional callback the App
/// layer may bind, matching the <see cref="UnstickFromObject"/> seam
/// convention.
/// </summary>
public Action? InterruptCurrentMovement { get; set; }
/// <summary>
/// R3-W4 seam standing in for retail <c>CPhysicsObj::RemoveLinkAnimations</c>
/// (0x0050fe20, decomp §9 summary table) → <c>CPartArray</c> →
/// <c>CSequence::remove_all_link_animations</c> — called from
/// <see cref="HitGround"/>/<see cref="LeaveGround"/> (§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-<c>SetCycle(skipTransitionLink:true)</c> 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 <c>AnimationSequencer</c>/<c>CSequence</c> core
/// (<c>RemoveAllLinkAnimations</c>-equivalent) per entity, matching the
/// existing <see cref="UnstickFromObject"/>/<see cref="InterruptCurrentMovement"/>
/// <c>Action?</c> 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).
/// </summary>
public Action? RemoveLinkAnimations { get; set; }
/// <summary>
/// R3-W4 no-op seam standing in for retail
/// <c>CPhysicsObj::InitializeMotionTables</c>, called from
/// <see cref="EnterDefaultState"/> (§4d, raw @306124:
/// <c>CPhysicsObj::InitializeMotionTables(this-&gt;physics_obj);</c>).
/// This re-seeds the physics object's <c>MotionTableManager</c>/motion
/// table stack — a manager-side (App-bound) concern, not something
/// <c>MotionInterpreter</c> itself owns state for. Register row: no-op
/// until the App layer binds it to the entity's motion-table
/// initialization; matches the <see cref="RemoveLinkAnimations"/> seam
/// convention.
/// </summary>
public Action? InitializeMotionTables { get; set; }
/// <summary>
/// R3-W5 seam standing in for retail <c>CPhysicsObj::CheckForCompletedMotions</c>
/// (0x0050fe30, decomp §7d @277925) → <c>CPartArray::CheckForCompletedMotions</c>
/// → <c>MotionTableManager::CheckForCompletedMotions</c> (the SECOND call
/// site of the shared animation-completion driver, §7b) — called by
/// <see cref="PerformMovement"/> 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
/// <c>MotionTableManager.CheckForCompletedMotions</c> (R2-Q3 object),
/// matching the existing <see cref="RemoveLinkAnimations"/>/
/// <see cref="UnstickFromObject"/> <c>Action?</c> seam convention. A null
/// binding is a silent no-op (safe default for tests and for any physics
/// body with no MotionTableManager attached yet).
/// </summary>
public Action? CheckForCompletedMotions { get; set; }
/// <summary>
/// R3-W4 — retail <c>CMotionInterp::initted</c> (set by
/// <see cref="EnterDefaultState"/>, raw @306152: <c>this-&gt;initted =
/// 1;</c>). Gates <see cref="apply_current_movement"/> and
/// <see cref="ReportExhaustion"/>'s entry (A3: <c>physics_obj != 0
/// &amp;&amp; initted != 0</c>).
///
/// <para>
/// <b>Constructor default is <c>true</c>, NOT retail's <c>false</c>
/// (see the final-report note on this divergence).</b> Retail's
/// <c>CMotionInterp</c> is never used standalone — every real
/// construction path (<c>MovementManager::PerformMovement</c>'s
/// lazy-create, <c>MovementManager::Create</c>) immediately calls
/// <see cref="EnterDefaultState"/> (retail's <c>enter_default_state</c>)
/// before the interpreter is exposed to any caller, so <c>initted</c>
/// is always <c>1</c> by the time application code can observe it.
/// acdream's <c>MotionInterpreter</c> constructor is used directly by
/// ~40 pre-existing tests AND by both App call sites
/// (<c>PlayerMovementController</c>, <c>GameWindow</c>) as a
/// complete, immediately-usable object with no separate
/// "enter default state" step — porting retail's literal
/// <c>false</c> default would silently no-op every one of those
/// call sites' <c>apply_current_movement</c>/<c>ReportExhaustion</c>
/// calls until something remembers to call
/// <see cref="EnterDefaultState"/> (which nothing currently does).
/// Defaulting <c>true</c> here is the C# equivalent of "the
/// constructor already did what <c>enter_default_state</c> would have
/// done to this flag" — <see cref="EnterDefaultState"/> 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, <see cref="LeaveGround"/> tail).
/// </para>
/// </summary>
public bool Initted { get; set; } = true;
/// <summary>
/// Optional accessor for the owning entity's current animation cycle
/// velocity (AnimationSequencer.CurrentVelocity, i.e. MotionData.Velocity
/// scaled by speedMod). When wired, <see cref="get_state_velocity"/>
/// uses it as the primary forward-axis drive instead of the hardcoded
/// <see cref="RunAnimSpeed"/> / <see cref="WalkAnimSpeed"/> constants.
///
/// <para>
/// <b>Why:</b> the decompiled <c>get_state_velocity</c> (FUN_00528960)
/// literally computes <c>RunAnimSpeed * ForwardSpeed</c>. That works in
/// retail because retail's Humanoid MotionTable happens to bake
/// <c>MotionData.Velocity == RunAnimSpeed (4.0)</c> 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.
/// </para>
///
/// <para>
/// Per <c>docs/research/deepdives/r03-motion-animation.md</c> §1.3,
/// the retail animation pipeline treats <c>MotionData.Velocity *
/// speedMod</c> as the canonical per-cycle world velocity. The
/// <see cref="RunAnimSpeed"/> constant survives in our port only as
/// the max-speed clamp (see below), which matches the decompile's
/// <c>if (|velocity| > RunAnimSpeed * rate)</c> guard.
/// </para>
///
/// <para>
/// Call site: <c>PlayerMovementController.AttachCycleVelocityAccessor</c>
/// wires this to <c>AnimatedEntity.Sequencer.CurrentVelocity</c> 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).
/// </para>
/// </summary>
public Func<Vector3>? 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 ────────────────────────────────────────
/// <summary>
/// Top-level dispatcher for a network movement struct.
///
/// Decompiled logic (FUN_00529a90):
/// switch(*param_1) { ← MovementStruct.Type
/// case 1: DoMotion(…) → raw command
/// case 2: DoInterpretedMotion(…)
/// case 3: StopMotion(…)
/// case 4: StopInterpretedMotion(…)
/// case 5: StopCompletely()
/// default: return 0x47
/// }
/// FUN_0050fe30 — CPhysicsObj::CheckForCompletedMotions, called after EVERY
/// dispatched op (raw 306234/306241/306248/306255/306262, R3-W5 closes J14).
/// </summary>
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 ───────────────────────────────────────────────
/// <summary>
/// <c>CMotionInterp::DoMotion</c> (0x00528d20, decomp §2 @306159, FULL
/// BODY, R3-W5, closes J3). App-facing 2-arg compat overload —
/// PlayerMovementController's call sites pass a raw
/// <c>(uint motion, float speed)</c> pair; this builds a
/// default-constructed <see cref="MovementParameters"/> (retail's own
/// UnPack/CommandInterpreter callers build one the same way for a bare
/// raw command) with <see cref="MovementParameters.Speed"/> overridden,
/// then delegates to the verbatim overload below.
/// </summary>
public WeenieError DoMotion(uint motion, float speed = 1.0f)
=> DoMotion(motion, new MovementParameters { Speed = speed });
/// <summary>
/// <c>CMotionInterp::DoMotion</c> (0x00528d20, decomp §2 @306159, FULL
/// BODY, R3-W5, closes J3).
///
/// <para>
/// Verbatim (raw 306159-306221):
/// <code>
/// 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 &amp; 0x800 /*SetHoldKey*/)
/// SetHoldKey(hold_key_to_apply, (bitfield&gt;&gt;0xf)&amp;1 /*cancel_moveto bit, A4*/);
/// adjust_motion(&amp;arg2, &amp;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 &amp; 0x2000000) return 0x42;
/// }
/// if ((ebp &amp; 0x10000000) &amp;&amp; GetNumActions() &gt;= 6) return 0x45;
/// result = DoInterpretedMotion(arg2 /*ADJUSTED*/, &amp;var_2c /*fresh local params*/);
/// if (result == 0 &amp;&amp; bitfield &amp; 0x2000 /*ModifyRawState*/)
/// RawMotionState::ApplyMotion(ebp /*ORIGINAL*/, arg3 /*CALLER's params, not var_2c*/);
/// return result;
/// </code>
/// </para>
///
/// <para>
/// Two mirror-discipline details, both verbatim: (1) the
/// <see cref="DoInterpretedMotion(uint,MovementParameters)"/> call
/// receives a FRESH local <see cref="MovementParameters"/> (re-defaulted,
/// only <c>Speed</c>/<c>HoldKeyToApply</c> carried through
/// <c>adjust_motion</c>'s mutation) — the caller's own
/// <paramref name="p"/> distance/heading/fail-distance fields are
/// discarded for the interpreted call, matching retail's explicit
/// re-construction of <c>var_2c</c>. (2) the raw-state mirror at the
/// bottom reads the ORIGINAL <c>arg3</c> (<paramref name="p"/>) — NOT
/// <c>var_2c</c> — for <see cref="MovementParameters.SetHoldKey"/>/
/// <see cref="MovementParameters.HoldKeyToApply"/>/<see cref="MovementParameters.Speed"/>,
/// per <c>RawMotionState::ApplyMotion</c>'s own signature
/// (<c>const MovementParameters*</c>) receiving the same pointer
/// <c>DoMotion</c> was called with.
/// </para>
/// </summary>
/// <param name="motion">Retail <c>arg2</c> (the ORIGINAL, pre-adjustment
/// motion id — <c>ebp</c>).</param>
/// <param name="p">Retail <c>arg3</c>.</param>
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 ────────────────────────────────────────────────────────────
/// <summary>
/// App-facing compat overload for <c>StopMotion</c> (0x00528530, decomp
/// §5b @305674). Builds a default-constructed <see cref="MovementParameters"/>
/// and delegates to the verbatim overload below.
/// </summary>
public WeenieError StopMotion(uint motion)
=> StopMotion(motion, new MovementParameters());
/// <summary>
/// <c>CMotionInterp::StopMotion</c> (0x00528530, decomp §5b @305674, FULL
/// BODY, R3-W5, closes J3's StopMotion leg). The <c>DoMotion</c> mirror:
/// same defaulting/reconstruction pattern, same optional
/// <c>interrupt_current_movement</c>, same <c>adjust_motion</c>
/// reinterpretation — but NO combat-stance or action-depth gating (those
/// are <c>DoMotion</c>-only).
///
/// <para>
/// Verbatim (raw 305674-305706): snapshot <paramref name="p"/> fields,
/// build a fresh local <see cref="MovementParameters"/>, optional
/// interrupt on the sign bit, <c>adjust_motion(&amp;arg3, &amp;speed,
/// hold_key_to_apply)</c> (mutates the motion id in place — note retail
/// reuses the <c>arg3</c> register slot for the motion id here, not a
/// separate local), delegate to
/// <c>StopInterpretedMotion(adjusted, &amp;local)</c>; on success AND
/// bit <c>0x2000</c> (ModifyRawState), mirror via
/// <c>RawMotionState::RemoveMotion(ORIGINAL motion)</c> — using the
/// ORIGINAL unmutated id, matching <c>DoMotion</c>'s equivalent
/// <c>ApplyMotion(ebp, arg3)</c> mirror discipline.
/// </para>
/// </summary>
/// <param name="motion">Retail <c>arg2</c> (the ORIGINAL motion id).</param>
/// <param name="p">Retail <c>arg3</c>.</param>
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 ─────────────────────────────────────────
/// <summary>
/// <c>CMotionInterp::StopCompletely</c> (0x00527e40, decomp §5a @305208,
/// FULL BODY, R3-W5, closes J9). W0-pins.md A9 — ported INCLUDING the
/// jump-snapshot quirk.
///
/// <para>
/// Verbatim (raw 305208-305236):
/// <code>
/// 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 &amp;&amp; physics_obj-&gt;cell == 0) RemoveLinkAnimations(physics_obj);
/// return 0;
/// </code>
/// </para>
///
/// <para>
/// <b>A9 quirk</b>: <c>motion_allows_jump</c> is evaluated on the OLD
/// <c>interpreted_state.forward_command</c> BEFORE it gets overwritten
/// two lines later — the queued node's <c>JumpErrorCode</c> reflects the
/// PRE-stop command, not the post-stop Ready command (which would always
/// pass). <b>J9</b>: retail touches ONLY the forward command/speed plus
/// the sidestep/turn COMMANDS — it does NOT write <c>sidestep_speed</c>
/// or <c>turn_speed</c> on either state. The pre-R3 acdream divergence
/// (unconditional 1.0 speed resets on all four speed fields) is REMOVED
/// here.
/// </para>
///
/// <para>
/// <c>StopCompletely_Internal</c> (0x0050f5a0, CPhysicsObj-side) stands
/// in as <see cref="PhysicsBody.set_velocity"/>(Zero) — register row,
/// kept (full port is physics-layer scope, r3-port-plan.md §2 KEEP
/// LIST). CurCell proxy: <see cref="PhysicsBody.CellPosition"/>.<c>ObjCellId
/// == 0</c> (unseeded/detached body — matches <c>cell == 0</c>).
/// </para>
/// </summary>
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 ────────────────────────────────────
/// <summary>
/// Compute the body-local velocity vector for the current interpreted motion.
///
/// <para>
/// <b>Decompiled path (FUN_00528960)</b>:
/// <code>
/// 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
/// </code>
/// </para>
///
/// <para>
/// <b>Option B — MotionData-sourced forward velocity</b>:
/// when <see cref="GetCycleVelocity"/> is wired (i.e. the owning
/// entity has an AnimationSequencer), we prefer
/// <c>MotionData.Velocity.Y * speedMod</c> over the hardcoded
/// <see cref="WalkAnimSpeed"/> / <see cref="RunAnimSpeed"/> constants.
/// This keeps the body's world velocity locked to the animation's
/// baked-in root-motion velocity (<c>r03 §1.3</c>), 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.
/// </para>
///
/// <para>
/// The <see cref="RunAnimSpeed"/> constant survives as the max-speed
/// clamp at the bottom, faithfully matching the decompile's
/// <c>if (|velocity| > RunAnimSpeed * rate)</c> guard. Sidestep
/// continues to use <see cref="SidestepAnimSpeed"/> because the
/// sequencer only tracks the current forward cycle — strafe is
/// implemented as a separate axis in our controller (see
/// <c>PlayerMovementController.Update</c>).
/// </para>
/// </summary>
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 ──────────────────────────────────────────
/// <summary>
/// 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.
///
/// <para>
/// Decompiled logic (FUN_00528010, <c>docs/research/named-retail/
/// acclient_2013_pseudo_c.txt:305343-305400</c>):
/// <code>
/// 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)
/// </code>
/// </para>
///
/// <para>
/// 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).
/// </para>
///
/// <para>
/// <b>R3-W4 (closes J18, retires register TS-34): creature guard WIRED.</b>
/// Retail (raw @305343): <c>if (weenie != 0 &amp;&amp; weenie-&gt;IsCreature() == 0)
/// return;</c> — a weenie present whose <c>IsCreature()</c> returns
/// false short-circuits the ENTIRE function (no normalization, no
/// hold-key path). <see cref="IWeenieObject.IsCreature"/> has existed
/// since W3; this was the one caller left un-gated.
/// </para>
/// </summary>
/// <param name="motion">Raw motion command, normalized in place.</param>
/// <param name="speed">Raw motion speed, normalized in place.</param>
/// <param name="holdKey">
/// The hold key for THIS channel (forward/sidestep/turn each carry their
/// own hold key in the retail raw state). Pass <see cref="HoldKey.Invalid"/>
/// to fall back to <see cref="CurrentHoldKey"/>.
/// </param>
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 ───────────────────────────────────
/// <summary>
/// Apply the Run hold-key promotion/scale to an already-normalized
/// (positive-form) motion command.
///
/// <para>
/// Decompiled logic (FUN_00527be0, <c>docs/research/named-retail/
/// acclient_2013_pseudo_c.txt:305062-305123</c>):
/// <code>
/// 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
/// </code>
/// </para>
///
/// <para>
/// GOTCHA: the WalkForward <c>speed *= speedMod</c> 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).
/// </para>
/// </summary>
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 ───────────────────────────────────────
/// <summary>
/// Retail <c>CMotionInterp::apply_raw_movement</c> orchestrator
/// (<c>0x005287e0</c>, <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:305817-305834</c>).
/// Copies the raw motion state into the interpreted state, then normalizes
/// each of the three channels (forward / sidestep / turn) IN PLACE via
/// <see cref="adjust_motion"/> using that channel's own hold key.
///
/// <para>
/// After this call, <see cref="get_state_velocity"/> 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 — <see cref="apply_run_to_command"/> applies the run
/// rate, so a pre-scaled input would double-scale.
/// </para>
/// </summary>
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 ─────────────────────────────────
/// <summary>
/// <c>CMotionInterp::apply_current_movement</c> (0x00528870, decomp §4e,
/// W0-pins.md A3, FULL BODY dual dispatch — replaces the pre-W4
/// direct-velocity approximation).
///
/// <para>
/// Verbatim (raw 305838-305857):
/// <code>
/// if (physics_obj != 0 &amp;&amp; initted != 0) {
/// if (weenie_obj != 0) eax_2 = weenie_obj-&gt;IsThePlayer();
/// if ((weenie_obj == 0 || eax_2 != 0)
/// &amp;&amp; movement_is_autonomous(physics_obj) != 0) {
/// apply_raw_movement(this, arg2, arg3);
/// return;
/// }
/// apply_interpreted_movement(this, arg2, arg3);
/// }
/// </code>
/// </para>
///
/// <para>
/// A3: the dispatch gate is <c>IsThePlayer</c> (no weenie = treat as
/// the player), NOT <c>IsCreature</c> — a remote player weenie
/// (<c>IsThePlayer()==false</c>, <c>IsCreature()==true</c>) routes
/// INTERPRETED even when <c>movement_is_autonomous</c> is true. This is
/// the genuine divergence from ACE's server-side <c>IsCreature</c> gate
/// (ACE MotionInterp.cs:430-438) — do not copy.
/// </para>
/// </summary>
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);
}
/// <summary>
/// <c>CMotionInterp::apply_raw_movement</c> (0x005287e0, decomp §4e
/// referenced body, raw 305817-305834), the <c>(cancelMoveTo,
/// allowJump)</c>-arg overload reached from
/// <see cref="apply_current_movement"/>'s dual dispatch (A3's
/// "IsThePlayer + autonomous" branch), <see cref="ReportExhaustion"/>,
/// <see cref="SetWeenieObject"/>, and <see cref="SetPhysicsObject"/>.
///
/// <para>
/// Verbatim: copy the SEVEN raw-state fields (style + all three
/// axis command/speed pairs) into <see cref="InterpretedState"/>,
/// normalize each of the three axes IN PLACE via <see cref="adjust_motion"/>
/// using THAT axis's own <see cref="RawState"/> hold key, then
/// tail-call <c>apply_interpreted_movement(this, arg2, arg3)</c> — the
/// SAME retail function (0x00528600) as <see cref="ApplyInterpretedMovement"/>
/// (the funnel producer). This is NOT a new function: it is the
/// existing <see cref="apply_raw_movement(RawMotionState)"/> overload's
/// body (the D6.2a raw-state-orchestrator port), reached here with
/// <see cref="RawState"/> itself as the source instead of an externally
/// supplied snapshot, plus the previously-missing
/// <c>apply_interpreted_movement(arg2, arg3)</c> tail call this
/// dispatch site needs.
/// </para>
/// </summary>
public void apply_raw_movement(bool cancelMoveTo, bool allowJump)
{
if (PhysicsObj is null)
return;
apply_raw_movement(RawState);
ApplyCurrentMovementInterpreted(cancelMoveTo, allowJump);
}
/// <summary>
/// The retail <c>apply_interpreted_movement</c> tail reached from BOTH
/// dual-dispatch branches of <see cref="apply_current_movement"/> (raw
/// 305855/305832 — <c>CMotionInterp::apply_interpreted_movement</c>,
/// 0x00528600, the SAME function as the funnel's
/// <see cref="ApplyInterpretedMovement"/>, per the raw's identical call
/// target at every one of its six call sites).
///
/// <para>
/// <b>Physics-only-port body (pre-W4 approximation, SURVIVES per the
/// plan's "less invasive" choice — see the final-report note):</b> this
/// port has no <see cref="IInterpretedMotionSink"/> wired at this call
/// site (retail's real body drives <c>DoInterpretedMotion</c>/
/// <c>StopInterpretedMotion</c> through the animation-table backend,
/// exactly like <see cref="ApplyInterpretedMovement"/> already does for
/// the INBOUND funnel path — but that path always has a sink available
/// from its caller, while <c>apply_current_movement</c>'s callers
/// (<see cref="HitGround"/>/<see cref="LeaveGround"/>/
/// <see cref="ReportExhaustion"/>/hold-key toggles/jump) do not thread
/// one through). Kept as the pre-existing direct
/// <c>get_state_velocity</c> → grounded <c>set_local_velocity</c> 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).
/// </para>
/// </summary>
/// <summary>
/// R3-W4 (App-bound): the entity's persistent animation-dispatch sink.
/// When set, <see cref="ApplyCurrentMovementInterpreted"/> routes the
/// REAL <see cref="ApplyInterpretedMovement"/> (retail
/// <c>apply_interpreted_movement</c> 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.
/// </summary>
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 ────────────────────────────────────────
/// <summary>
/// <c>CMotionInterp::ReportExhaustion</c> (0x005288d0, decomp §4c
/// @305861, FULL BODY, W0-pins.md A3). Identical entry gate and
/// dual-dispatch predicate to <see cref="apply_current_movement"/>,
/// with HARDCODED <c>(0, 0)</c> dispatch args (raw 305874/305878) —
/// the retail caller (<c>CPhysicsObj::report_exhaustion</c> 0x0050fdd0
/// → <c>MovementManager::ReportExhaustion</c> 0x00524360) passes no
/// per-call parameters through.
///
/// <para>
/// Verbatim (raw 305861-305880):
/// <code>
/// if (physics_obj != 0 &amp;&amp; initted != 0) {
/// if (weenie_obj != 0) eax_2 = weenie_obj-&gt;IsThePlayer();
/// if ((weenie_obj == 0 || eax_2 != 0)
/// &amp;&amp; movement_is_autonomous(physics_obj) != 0) {
/// apply_raw_movement(this, 0, 0);
/// return;
/// }
/// apply_interpreted_movement(this, 0, 0);
/// }
/// </code>
/// </para>
/// </summary>
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 ───────
/// <summary>
/// <c>CMotionInterp::SetWeenieObject</c> (0x00528920, decomp §4f
/// @305884, FULL BODY, W0-pins.md A3). Assigns <see cref="WeenieObj"/>,
/// then — if already physics-bound AND <see cref="Initted"/> — re-applies
/// movement via the SAME dual-dispatch predicate as
/// <see cref="apply_current_movement"/>, EXCEPT the weenie tested for
/// <c>IsThePlayer</c> is the INCOMING <paramref name="weenie"/> (not
/// <see cref="WeenieObj"/> 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 <c>goto label_528946</c>). Dispatch args are
/// hardcoded <c>(1, 0)</c>.
///
/// <para>
/// Verbatim (raw 305884-305907):
/// <code>
/// bool noPhysics = (physics_obj == 0);
/// weenie_obj = arg2;
/// if (!noPhysics &amp;&amp; initted != 0) {
/// if (arg2 == 0) {
/// label_528946:
/// if (movement_is_autonomous(physics_obj) != 0) {
/// apply_raw_movement(this, 1, 0);
/// return;
/// }
/// } else if (arg2-&gt;IsThePlayer() != 0)
/// goto label_528946;
/// apply_interpreted_movement(this, 1, 0);
/// }
/// </code>
/// </para>
/// </summary>
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);
}
/// <summary>
/// <c>CMotionInterp::SetPhysicsObject</c> (0x00528970, decomp §4g
/// @305911, FULL BODY, W0-pins.md A3). Assigns <see cref="PhysicsObj"/>
/// unconditionally FIRST, then — if the incoming
/// <paramref name="physicsObj"/> is non-null AND <see cref="Initted"/> —
/// re-applies movement via the standard dual-dispatch predicate (this
/// time reading <see cref="WeenieObj"/>, unchanged by this call).
/// Dispatch args are hardcoded <c>(1, 0)</c>, matching
/// <see cref="SetWeenieObject"/>.
///
/// <para>
/// Verbatim (raw 305911-305932):
/// <code>
/// this-&gt;physics_obj = arg2;
/// if (arg2 != 0 &amp;&amp; initted != 0) {
/// if (weenie_obj != 0) eax_1 = weenie_obj-&gt;IsThePlayer();
/// if ((weenie_obj == 0 || eax_1 != 0)
/// &amp;&amp; movement_is_autonomous(physics_obj) != 0) {
/// apply_raw_movement(this, 1, 0);
/// return;
/// }
/// apply_interpreted_movement(this, 1, 0);
/// }
/// </code>
/// Note the entry gate is on the INCOMING <c>arg2</c> (not the
/// just-assigned <c>this-&gt;physics_obj</c> — same value, but the raw
/// tests the parameter register directly), unlike
/// <see cref="SetWeenieObject"/>'s entry gate which tests the
/// PRE-assignment <c>physics_obj</c> snapshot (<c>noPhysics</c>).
/// </para>
/// </summary>
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 ─────────────────────────────────
/// <summary>
/// <c>CMotionInterp::jump_charge_is_allowed</c> (0x00527a50, decomp §3b
/// @304935, W0-pins.md A1 polarity). Gate consulted while the jump
/// charge is accumulating (spacebar held) — NOT the same gate as
/// <see cref="jump_is_allowed"/>'s ground check.
///
/// <para>
/// Verbatim (raw 304940-304948):
/// <code>
/// weenie_obj = this-&gt;weenie_obj;
/// if (weenie_obj != 0 &amp;&amp; weenie_obj.CanJump(this-&gt;jump_extent) == 0)
/// return 0x49;
/// forward_command = this-&gt;interpreted_state.forward_command;
/// if (forward_command != 0x40000008
/// &amp;&amp; (forward_command &lt;= 0x41000011 || forward_command &gt; 0x41000014))
/// return 0;
/// return 0x48;
/// </code>
/// </para>
/// </summary>
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 ────────────────────────────────────────────
/// <summary>
/// <c>CMotionInterp::charge_jump</c> (0x005281c0, decomp §3e @305448,
/// W0-pins.md A1 polarity). R3-W3 (closes J6): the ONLY place retail
/// arms <see cref="StandingLongJump"/> — the old
/// <c>contact_allows_move</c> side effect (:1139-1148 pre-W3 numbering)
/// is DELETED (see <see cref="contact_allows_move"/>'s doc comment).
///
/// <para>
/// Verbatim (raw 305453-305466):
/// <code>
/// weenie_obj = this-&gt;weenie_obj;
/// if (weenie_obj != 0 &amp;&amp; weenie_obj.CanJump(this-&gt;jump_extent) == 0)
/// return 0x49;
/// forward_command = this-&gt;interpreted_state.forward_command;
/// if (forward_command == 0x40000008
/// || (forward_command &gt; 0x41000011 &amp;&amp; forward_command &lt;= 0x41000014))
/// return 0x48;
/// transient_state = physics_obj-&gt;transient_state;
/// if ((transient_state &amp; 1) != 0 &amp;&amp; (transient_state &amp; 2) != 0
/// &amp;&amp; forward_command == 0x41000003
/// &amp;&amp; interpreted_state.sidestep_command == 0
/// &amp;&amp; interpreted_state.turn_command == 0)
/// this-&gt;standing_longjump = 1;
/// return 0;
/// </code>
/// </para>
///
/// <para>
/// Note the inverted-but-equivalent posture test vs
/// <see cref="JumpChargeIsAllowed"/>: that function's PASS condition is
/// <c>forward &lt;= 0x41000011 || forward &gt; 0x41000014</c>; this
/// function's BLOCK condition is <c>forward == 0x40000008 ||
/// (forward &gt; 0x41000011 &amp;&amp; forward &lt;= 0x41000014)</c> — 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 <see cref="JumpChargeIsAllowed"/>
/// to keep each function's control flow traceable to its own raw
/// address.
/// </para>
///
/// <para>
/// 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);
/// <c>PlayerMovementController</c> may call this once wired (W4/W6 — no
/// regression today since nothing calls <c>ChargeJump</c> yet, so
/// remotes/local both continue unaffected).
/// </para>
/// </summary>
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 ───────────────────────────────────────────────────
/// <summary>
/// Initiate a jump: validate, store jump extent, leave the ground.
///
/// <para>
/// Verbatim (0x00528780, decomp §3f @305792):
/// <code>
/// physics_obj = this-&gt;physics_obj;
/// if (physics_obj == 0) return 8;
/// interrupt_current_movement(physics_obj);
/// result = jump_is_allowed(this, arg2, arg3);
/// if (result != 0) {
/// this-&gt;standing_longjump = 0;
/// return result;
/// }
/// this-&gt;jump_extent = arg2;
/// set_on_walkable(physics_obj, 0);
/// return result; // == 0
/// </code>
/// </para>
///
/// <para>
/// R3-W3 (closes J7-interp-side): <c>interrupt_current_movement</c> is
/// now called UNCONDITIONALLY before <see cref="jump_is_allowed"/>, via
/// the <see cref="InterruptCurrentMovement"/> no-op seam (register row —
/// R4 wires the real cancel_moveto). <see cref="StandingLongJump"/> is
/// cleared ONLY on the failure path — a successful jump leaves it
/// untouched (the caller/LeaveGround owns clearing it on success).
/// </para>
/// </summary>
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 ──────────────────────────────────────────
/// <summary>
/// Get the vertical (Z) component of jump velocity.
///
/// <para>
/// Verbatim (0x00527aa0, decomp §3c @304953, W0-pins.md A5 — the BN text
/// is x87-flag garbled; ACE's clean-room reading adjudicates):
/// <code>
/// extent = this-&gt;jump_extent;
/// if (extent &lt; 0.000199999995f) return 0.0f;
/// if (extent &gt; 1.0f) extent = 1.0f;
/// if (this-&gt;weenie_obj == 0) return 10.0f;
/// if (weenie_obj.InqJumpVelocity(extent, &amp;extent) != 0) return extent;
/// return 0.0f;
/// </code>
/// </para>
/// </summary>
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 ──────────────────────────────
/// <summary>
/// Compose the full 3D body-local launch velocity when leaving the ground.
///
/// <para>
/// Verbatim (0x005280c0, decomp §3d @305404, W0-pins.md A6): body order
/// is <c>get_state_velocity(esi)</c> → <c>esi.z = get_jump_v_z()</c> →
/// fallback fires ONLY when <c>|x| AND |y| AND |z|</c> are ALL
/// <c>&lt; 0.000199999995f</c> (epsilon tested three times, one per
/// component), and then OVERWRITES ALL THREE components (including the
/// z the function just computed) with
/// <c>globaltolocal(physics_obj-&gt;m_velocityVector)</c> — decisively
/// pinned as GLOBAL→LOCAL by the row-linear match against
/// <c>Frame::globaltolocalvec</c> (A6). The existing
/// <c>Vector3.Transform(Velocity, Quaternion.Inverse(Orientation))</c>
/// transform already IS global→local — kept unchanged.
/// </para>
/// </summary>
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 ────────────────────────────────────────
/// <summary>
/// Determine whether a jump is currently permitted. R3-W3 (closes J5):
/// FULL verbatim chain replacing the pre-W3 15-line approximation —
/// entry shape, <c>IsFullyConstrained</c>, the pending-head peek (A2),
/// the charge→motion→stamina chain, all now present.
///
/// <para>
/// Verbatim (0x005282b0, decomp §3h, W0-pins.md A2/A10):
/// <code>
/// if (physics_obj != 0) {
/// if (weenie_obj != 0) eax_2 = weenie_obj.IsCreature();
/// if (weenie_obj != 0 &amp;&amp; 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 &amp;&amp; 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;
/// </code>
/// </para>
///
/// <para>
/// A10 note: <c>physics_obj == null</c> returns <c>0x24</c> (NotGrounded),
/// NOT <c>8</c> — the "8 = no physics obj" convention that holds
/// everywhere else in CMotionInterp does not hold here (the
/// <c>if (physics_obj != 0) {...}</c> wrapper falls out to
/// <c>return 0x24</c> when physics_obj is null, same as the grounded-check
/// failure path).
/// </para>
/// </summary>
/// <param name="extent">Jump charge fraction, forwarded to
/// <c>JumpStaminaCost</c>.</param>
/// <param name="staminaCost">Out-param mirroring retail's <c>arg3</c>
/// (<c>int32_t*</c>) — the stamina cost <c>JumpStaminaCost</c> computed,
/// 0 when the chain never reaches that call.</param>
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)
}
/// <summary>
/// The <c>shared_gate</c> label inside <see cref="jump_is_allowed"/>
/// (raw 305524-305556) — split out only for C# readability; retail
/// reaches this point via three different <c>goto</c> sites, all folded
/// into one function here since C# has no goto-into-shared-tail idiom
/// as clean as retail's.
/// </summary>
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 ────────────────────────────────────
/// <summary>
/// Determine whether the current contact state allows this motion command.
///
/// <para>
/// 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).
/// </para>
///
/// <para>
/// 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".
/// </para>
///
/// <para>
/// <b>R3-W3 (closes J6): the StandingLongJump side effect that used to
/// live here is DELETED.</b> The S2a port had flagged it explicitly as
/// "PRE-EXISTING acdream side effect (not part of 0x00528240)" — a
/// misattribution: retail's <c>contact_allows_move</c> (0x00528240)
/// never reads or writes <c>standing_longjump</c> at all. The real
/// arming site is <see cref="ChargeJump"/> (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
/// <c>ApplyInterpretedMovement</c> calls this every dispatch) flipped
/// the flag regardless of whether a jump charge was ever started —
/// <see cref="ChargeJump"/> is the only path that can arm it now.
/// </para>
/// </summary>
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.
/// <summary>
/// <c>CMotionInterp::add_to_queue</c> (0x00527b80, decomp §1a @305032):
/// allocate a <see cref="MotionNode"/> and append it to the tail of
/// <see cref="PendingMotions"/> (retail: append at tail; if the queue was
/// empty, head and tail both point at the new node — the C#
/// <see cref="LinkedList{T}"/> gives this for free via
/// <c>AddLast</c>).
/// </summary>
/// <param name="contextId">Retail <c>arg2</c> — <c>MotionNode.context_id</c>.</param>
/// <param name="motion">Retail <c>arg3</c> — <c>MotionNode.motion</c>.</param>
/// <param name="jumpErrorCode">Retail <c>arg4</c> — <c>MotionNode.jump_error_code</c>.</param>
public void AddToQueue(uint contextId, uint motion, uint jumpErrorCode)
{
_pendingMotions.AddLast(new MotionNode(contextId, motion, jumpErrorCode));
}
/// <summary>
/// <c>CMotionInterp::motions_pending</c> (0x00527fe0, decomp §1b
/// @305322): <c>pending_motions.head_ != null</c>.
/// </summary>
public bool MotionsPending() => _pendingMotions.First is not null;
/// <summary>
/// <c>CMotionInterp::MotionDone</c> (0x00527ec0, decomp §1c @305238,
/// FULL BODY). A7 (W0-pins.md): <paramref name="motion"/> and
/// <paramref name="success"/> 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
/// <c>MovementManager::MotionDone</c> relay.
///
/// <para>
/// Body (verbatim): no-op if <see cref="PhysicsObj"/> is null or the
/// queue is empty. Peek the HEAD: if <c>head.Motion &amp; 0x10000000</c>
/// (the action-class bit) is set, fire
/// <see cref="UnstickFromObject"/> then pop the head of BOTH
/// <see cref="InterpretedState"/>'s and <see cref="RawState"/>'s action
/// FIFOs. Then unconditionally dequeue the pending_motions head.
/// </para>
/// </summary>
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();
}
/// <summary>
/// <c>CMotionInterp::HandleExitWorld</c> (0x00527f30, decomp §1d):
/// identical body to <see cref="MotionDone"/>'s single-pop logic, looped
/// until <see cref="PendingMotions"/> drains — world exit flushes all
/// pending motion callbacks unconditionally, since no more
/// animation-completion events will arrive.
///
/// <para>
/// <b>Ambiguity resolved (not in W0-pins.md — found while porting):</b>
/// the raw decompile's loop condition is <c>head_ != 0</c>
/// (re-read every iteration), but the pop only happens inside a NESTED
/// <c>if (physics_obj != 0 &amp;&amp; head_ != 0)</c> guard — a literal
/// translation would infinite-loop if <see cref="PhysicsObj"/> were null
/// with a non-empty queue (the loop variable never advances). This is
/// dead code in retail: a live <c>CMotionInterp::HandleExitWorld</c> call
/// only ever happens on an interp already bound to a <c>physics_obj</c>
/// (retail's <c>CMotionInterp</c> 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 <see cref="PhysicsObj"/> (same
/// choice <see cref="AddToQueue"/>/<see cref="MotionsPending"/> already
/// make — they too are called before a physics_obj may exist during
/// interp construction/testing).
/// </para>
/// </summary>
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();
}
}
/// <summary>
/// <c>CMotionInterp::is_standing_still</c> (0x00527fa0, decomp @305309):
/// on-ground (Contact + OnWalkable) AND ForwardCommand == Ready AND
/// SideStepCommand == 0 AND TurnCommand == 0.
/// </summary>
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;
}
/// <summary>
/// <c>CMotionInterp::motion_allows_jump</c> (0x005279e0, decomp §3a
/// @304908, <c>__pure</c>). A1 (W0-pins.md, adversarially verified):
/// PINNED as a BLOCKLIST — <c>0</c> = jump allowed (pass), <c>0x48</c> =
/// 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).
///
/// <para>
/// Blocklist (definitive table, W0-pins.md §A1):
/// <list type="bullet">
/// <item><c>[0x1000006f, 0x10000078]</c> — MagicPowerUp01..MagicPowerUp10.</item>
/// <item><c>[0x10000128, 0x10000131]</c> — TripleThrustLow..MagicPowerUp07Purple.</item>
/// <item><c>0x40000008</c> exact — Fallen (NOT Falling; ACE mis-transcribed this).</item>
/// <item><c>[0x40000016, 0x40000018]</c> — Reload, Unload, Pickup.</item>
/// <item><c>[0x4000001e, 0x40000039]</c> — AimLevel..MagicPray.</item>
/// <item><c>[0x41000012, 0x41000014]</c> — Crouch, Sitting, Sleeping.</item>
/// </list>
/// Everything else — including Falling <c>0x40000015</c>, Ready
/// <c>0x41000003</c>, Dead <c>0x40000011</c>, all turn/sidestep ids — PASSES.
/// </para>
///
/// <para>
/// This is a pure function (no instance state) — retail's <c>this</c>
/// parameter is unused in the body (the decomp's <c>__pure</c>
/// attribute confirms it doesn't read <c>this</c>).
/// </para>
/// </summary>
/// <param name="motion">Retail <c>arg2</c> — the motion id to test.</param>
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 ────────────────────────────────────────────
/// <summary>
/// <c>CMotionInterp::LeaveGround</c> (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 <see cref="EnterDefaultState"/>,
/// §4d).
///
/// <para>
/// Verbatim (raw 306022-306040, quoted in full in the final-report):
/// creature gate (<c>weenie_obj == 0 || IsCreature() != 0</c>) AND
/// state-0x400 (Gravity) gate — SAME shape as <see cref="HitGround"/>.
/// When both pass: compute the launch velocity via
/// <see cref="GetLeaveGroundVelocity"/>, apply it with
/// <c>set_local_velocity(&amp;v, autonomous=1)</c> (the literal
/// <c>1</c> arg — <see cref="PhysicsBody.LastMoveWasAutonomous"/>
/// becomes true), reset <see cref="StandingLongJump"/> and
/// <see cref="JumpExtent"/> to their zero/false defaults (the jump
/// charge is consumed the instant you actually leave the ground), then
/// <see cref="RemoveLinkAnimations"/> + <c>apply_current_movement(0,
/// 0)</c> (the same re-sync <see cref="HitGround"/> performs).
/// </para>
/// </summary>
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 ──────────────────────────────────────────────
/// <summary>
/// <c>CMotionInterp::HitGround</c> (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.
///
/// <para>
/// Verbatim (raw 305996-306014):
/// <code>
/// if (physics_obj != 0) {
/// if (weenie_obj != 0) eax_2 = weenie_obj-&gt;IsCreature();
/// if (weenie_obj == 0 || eax_2 != 0) {
/// if (physics_obj != 0 &amp;&amp; (state &amp; 0x400) != 0) {
/// RemoveLinkAnimations(physics_obj);
/// apply_current_movement(this, 0, 0);
/// }
/// }
/// }
/// </code>
/// </para>
///
/// <para>
/// A3 note: this creature gate uses <c>IsCreature</c> (vtable +0x2c),
/// NOT <c>IsThePlayer</c> (+0x14) — the anti-artifact proof for A3's
/// pin (BN distinguishes the two slots locally, ~0x250 bytes from the
/// IsThePlayer-gated functions).
/// </para>
/// </summary>
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 ────────────────────────────────────
/// <summary>
/// <c>CMotionInterp::enter_default_state</c> (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 <c>{0, Ready, 0}</c> sentinel
/// to <see cref="PendingMotions"/> (NO drain — pre-existing queued
/// nodes survive, A8), marks <see cref="Initted"/>, then tail-calls
/// <see cref="LeaveGround"/> unconditionally.
///
/// <para>
/// Verbatim (raw 306124-306154):
/// <code>
/// 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);
/// </code>
/// </para>
///
/// <para>
/// Retail's real construction paths (<c>MovementManager::Create</c> +
/// the lazy-create guard at every <c>MovementManager</c> 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 <see cref="Initted"/> to
/// <c>true</c> 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 <c>Initted</c> flag, call
/// this explicitly.
/// </para>
/// </summary>
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 ────────────────
/// <summary>
/// <c>CMotionInterp::set_hold_run</c> (0x00528b70, decomp §5c @306053,
/// FULL BODY, closes J13's set_hold_run leg). XOR toggle guard: only
/// acts when <paramref name="holdingRun"/> actually FLIPS the current
/// effective hold-run state (skip redundant re-application).
///
/// <para>
/// Verbatim (raw 306053-306070):
/// <code>
/// 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);
/// }
/// </code>
/// </para>
/// </summary>
/// <param name="holdingRun">Retail <c>arg2</c> — nonzero = the run key
/// is currently held down.</param>
/// <param name="interrupt">Retail <c>arg3</c> — passed through as
/// <c>apply_current_movement</c>'s <c>cancelMoveTo</c> arg.</param>
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);
}
}
/// <summary>
/// <c>CMotionInterp::SetHoldKey</c> (0x00528bb0, decomp §5d @306072,
/// FULL BODY, closes J13's SetHoldKey leg). No-op if
/// <paramref name="key"/> already equals <see cref="RawMotionState.CurrentHoldKey"/>.
/// Setting <see cref="HoldKey.None"/> only takes effect (and
/// re-applies movement) if the CURRENT hold key is
/// <see cref="HoldKey.Run"/> — requesting None while already something
/// else (e.g. Invalid) is silently ignored. Setting
/// <see cref="HoldKey.Run"/> takes effect whenever the current key
/// isn't already Run.
///
/// <para>
/// Verbatim (raw 306072-306095):
/// <code>
/// 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 &amp;&amp; current != HoldKey_Run) {
/// raw_state.current_holdkey = HoldKey_Run;
/// apply_current_movement(this, arg3, 0);
/// }
/// }
/// </code>
/// </para>
///
/// <para>
/// This is the function <c>DoMotion</c> (W5) calls when bit
/// <c>0x800</c> (SetHoldKey) of the incoming <c>MovementParameters</c>
/// requests a hold-key change, and what <see cref="adjust_motion"/>
/// reads back via <see cref="RawMotionState.CurrentHoldKey"/> (mirrored
/// onto <see cref="CurrentHoldKey"/> — see the field doc) to decide
/// whether <see cref="apply_run_to_command"/> fires.
/// </para>
/// </summary>
/// <param name="key">Retail <c>arg2</c>.</param>
/// <param name="cancelMoveTo">Retail <c>arg3</c> — passed through as
/// <c>apply_current_movement</c>'s <c>cancelMoveTo</c> arg.</param>
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) ─────────────────────────────
/// <summary>
/// Return the maximum movement speed in m/s: run rate × RunAnimSpeed (4.0).
/// Mirrors retail <c>CMotionInterp::get_max_speed</c> at <c>0x00527cb0</c>.
///
/// <para>
/// <b>The ×4.0 is byte-verified retail (UN-2 resolved 2026-06-12).</b>
/// The Binary Ninja pseudo-C (named-retail/acclient_2013_pseudo_c.txt:305127)
/// renders this function as <c>void</c> with a bare <c>this->my_run_rate;</c>
/// statement because it drops x87 instructions — a known BN artifact class.
/// Disassembling the PDB-matched v11.4186 binary at VA <c>0x00527cb0</c>
/// shows all THREE return paths end with
/// <c>fmul dword ptr [0x007C8918]</c>, and the .rdata dword at
/// <c>0x007C8918</c> is <c>0x40800000</c> = 4.0f (the sibling
/// <c>get_adjusted_max_speed</c> 0x00527d00 carries the same trailing
/// fmul). Re-derive with <c>py tools/verify_un2_fmul.py</c>. 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).
/// </para>
///
/// <para>
/// Consequence: the dead-reckoning catch-up speed
/// (<c>InterpolationManager::adjust_offset</c> 0x00555d30, pc:353122)
/// is <c>2 × get_max_speed()</c> ≈ 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.
/// </para>
/// </summary>
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.
/// <summary>
/// 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.
/// </summary>
public bool IsLocalPlayer;
/// <summary>
/// <c>CMotionInterp::server_action_stamp</c> — 15-bit wraparound stamp
/// of the last applied inbound action (move_to_interpreted_state
/// 305953-305989).
/// </summary>
public int ServerActionStamp;
/// <summary>
/// <c>CMotionInterp::move_to_interpreted_state</c> (0x005289c0,
/// pseudo-C 305936): adopt the style into the raw state, FLAT-copy the
/// incoming interpreted state (<c>copy_movement_from</c> 0x0051e750 —
/// every axis overwritten, defaults included), re-apply the whole
/// movement through the sink, then replay fresh actions under the
/// 15-bit stamp gate.
///
/// <para>
/// <b>R3-W4 (Adjacent finding, W0-pins.md):</b> raw 305946-305949
/// computes <c>eax_2 = motion_allows_jump(this-&gt;interpreted_state.forward_command)</c>
/// — the OLD forward command, BEFORE <c>copy_movement_from</c>
/// overwrites <see cref="InterpretedState"/> two lines later — then
/// calls <c>apply_current_movement(this, 1, allowJump)</c> where
/// <c>allowJump = (eax_2 == 0)</c> (the raw's own
/// <c>-((esi_1 - esi_1))</c> is decompiler noise for that same
/// boolean). Ported here as <see cref="MotionAllowsJump"/> on the
/// PRE-overwrite <see cref="InterpretedState"/>.ForwardCommand,
/// snapshotted before the flat-copy below. Passed through to
/// <see cref="ApplyInterpretedMovement"/> per its TODO(W5) — this
/// funnel calls the interpreted body directly (not the
/// <see cref="apply_current_movement"/> 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.
/// </para>
/// </summary>
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;
}
/// <summary>
/// <c>CMotionInterp::apply_interpreted_movement</c> (0x00528600,
/// pseudo-C 305713): cache <c>my_run_rate</c> 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).
///
/// <para>
/// <b>R3-W2 producer (closes J1's apply_interpreted_movement leg):</b>
/// the tail turn-stop call (raw 305766-305785) is retail's
/// <c>CPhysicsObj::StopInterpretedMotion(physics_obj, 0x6500000d, &amp;var_2c)</c>
/// — our <see cref="IInterpretedMotionSink.StopMotion"/> call below IS
/// that dispatch. On success (<c>eax_10 == 0</c>, i.e. the sink accepted
/// the stop — this port has no failure signal from
/// <see cref="IInterpretedMotionSink.StopMotion"/>, 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: <c>add_to_queue(this, var_c /*uninitialized local in the raw
/// decompile — see the final-report contextId note*/, 0x41000003, eax_10)</c>
/// where <c>eax_10</c> (the stop's own return value) becomes the queued
/// node's <c>jump_error_code</c> — since the stop succeeded, that value
/// is <c>0</c>.
/// </para>
///
/// <para>
/// <b>R3-W4 (closes J11's param-plumbing leg):</b> <paramref name="cancelMoveTo"/>/
/// <paramref name="allowJump"/> are retail's <c>arg2</c>/<c>arg3</c> —
/// threaded through NOW per the plan (every real caller already has an
/// opinion: <see cref="apply_current_movement"/>'s dual-dispatch tail
/// passes its own args through unchanged; <see cref="MoveToInterpretedState"/>
/// passes <c>(true, motion_allows_jump(OLD forward_command) == 0)</c> per
/// the Adjacent finding). Their ONLY retail consumer in this function is
/// the garbled tail expression at raw 305778
/// (<c>(((arg3&amp;1)&lt;&lt;2)|(arg2&amp;1))&lt;&lt;0xf</c> feeding an
/// uninitialized-local byte test before conditionally calling
/// <c>InterpretedMotionState::RemoveMotion(0x6500000d)</c> 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 <c>MovementParameters</c> 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. <see cref="DispatchInterpretedMotion"/>'s own TODO(W5)).
/// </para>
/// </summary>
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);
}
/// <summary>
/// <c>CMotionInterp::DoInterpretedMotion</c> (0x00528360, raw
/// 305575-305631, FULL BODY, R3-W5, closes J4). THE ONE
/// <c>DoInterpretedMotion</c> — merges the former legacy
/// <c>(uint, float, bool)</c> overload's state-management with the S2a
/// funnel's <c>DispatchInterpretedMotion</c> sink-dispatch backend.
///
/// <para>
/// Verbatim (raw 305577-305631):
/// <code>
/// if (physics_obj == 0) return 8;
/// if (contact_allows_move(arg2)) {
/// if (standing_longjump != 0
/// &amp;&amp; (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-&gt;bitfield &amp; 0x20000) == 0) { // NOT DisableJumpDuringLink
/// eax_5 = motion_allows_jump(arg2);
/// if (eax_5 == 0 &amp;&amp; (arg2 &amp; 0x10000000) == 0)
/// eax_5 = motion_allows_jump(interpreted_state.forward_command);
/// } else eax_5 = 0x48; // DisableJumpDuringLink FORCES blocked
/// add_to_queue(arg3-&gt;context_id, arg2, eax_5);
/// if (arg3-&gt;bitfield &amp; 0x4000 /*ModifyInterpretedState*/)
/// InterpretedMotionState::ApplyMotion(arg2, arg3);
/// }
/// } else if ((arg2 &amp; 0x10000000) == 0) {
/// label_528440:
/// if (arg3-&gt;bitfield &amp; 0x4000) InterpretedMotionState::ApplyMotion(arg2, arg3);
/// result = 0;
/// } else result = 0x24;
/// if (physics_obj != 0 &amp;&amp; physics_obj-&gt;cell == 0) RemoveLinkAnimations(physics_obj);
/// return result;
/// </code>
/// </para>
///
/// <para>
/// <b>R3-W5 finding (not in the original W0 pins — discovered while
/// porting):</b> <see cref="IInterpretedMotionSink.ApplyMotion"/>'s
/// <c>bool</c> return IS retail's <c>result == 0</c> test gating the
/// queue/state-write block. This is load-bearing, not cosmetic: the
/// VERY FIRST dispatch of every <c>apply_interpreted_movement</c> call
/// is the style/stance id (<c>current_style</c>, always
/// <c>&gt;= 0x80000000</c>), which has no locomotion <c>MotionData</c>
/// entry in the dat — <c>CMotionTable::DoObjectMotion</c> genuinely
/// fails for it. If that failure isn't propagated (an earlier revision
/// of this port treated the sink as a <c>void</c>, always-succeeding
/// call), <c>InterpretedMotionState::ApplyMotion</c>'s negative-motion
/// branch (<c>arg2 &lt; 0 → forward_command = 0x41000003</c>) clobbers
/// <see cref="InterpretedMotionState.ForwardCommand"/> BEFORE the very
/// next line reads it for the real forward dispatch — regressed 74/183
/// cases of the live retail-observer conformance suite
/// (<c>RetailObserverTraceConformanceTests</c>) until the interface was
/// fixed to return the real result.
/// </para>
///
/// <para>
/// Note the ACTUAL animation-table dispatch (the sink call) happens
/// through <see cref="DefaultSink"/> — the entity's persistent
/// <c>IInterpretedMotionSink</c> binding (R2-Q5's
/// <c>MotionTableDispatchSink</c> in production). This is the SAME sink
/// <see cref="ApplyCurrentMovementInterpreted"/> already routes through
/// for the <c>apply_current_movement</c> dual-dispatch tail — retail has
/// exactly one <c>CPhysicsObj</c> per <c>CMotionInterp</c>, so there is
/// only ever one dispatch target per entity, matching
/// <see cref="DefaultSink"/>'s single-binding shape.
/// </para>
/// </summary>
/// <param name="motion">Retail <c>arg2</c>.</param>
/// <param name="p">Retail <c>arg3</c>.</param>
public WeenieError DoInterpretedMotion(uint motion, MovementParameters p)
=> DoInterpretedMotion(motion, p, DefaultSink);
/// <summary>
/// Sink-parameterized core shared by the public
/// <see cref="DoInterpretedMotion(uint,MovementParameters)"/> (which
/// always dispatches through <see cref="DefaultSink"/>, matching
/// retail's one-sink-per-<c>CPhysicsObj</c> shape) and the internal
/// <see cref="DispatchInterpretedMotion(uint,float,IInterpretedMotionSink?)"/>
/// primitive (which needs an EXPLICIT per-call sink for
/// <see cref="MoveToInterpretedState"/>'s remote-entity callers). Same
/// verbatim body either way — only the dispatch target varies.
/// </summary>
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;
}
/// <summary>
/// <c>CMotionInterp::StopInterpretedMotion</c> (0x00528470, raw
/// 305635-305670, FULL BODY, R3-W5, closes J4's StopInterpretedMotion
/// leg). THE ONE <c>StopInterpretedMotion</c> — merges the former legacy
/// overload with the funnel's bare <c>sink.StopMotion</c> call.
///
/// <para>
/// Verbatim (raw 305638-305670):
/// <code>
/// if (physics_obj == 0) return 8;
/// if (contact_allows_move(arg2) == 0
/// || (standing_longjump != 0
/// &amp;&amp; (arg2 == WalkForward || arg2 == RunForward || arg2 == SideStepRight))) {
/// if (arg3-&gt;bitfield &amp; 0x4000) InterpretedMotionState::RemoveMotion(arg2);
/// result = 0;
/// } else {
/// result = sink.StopMotion(arg2, arg3); // CPhysicsObj::StopInterpretedMotion stand-in
/// if (result == 0) {
/// add_to_queue(arg3-&gt;context_id, 0x41000003, result); // result == 0 here
/// if (arg3-&gt;bitfield &amp; 0x4000) InterpretedMotionState::RemoveMotion(arg2);
/// }
/// }
/// if (physics_obj != 0 &amp;&amp; physics_obj-&gt;cell == 0) RemoveLinkAnimations(physics_obj);
/// return result;
/// </code>
/// </para>
///
/// <para>
/// R3-W5 (post-fix): <see cref="IInterpretedMotionSink.StopMotion"/>
/// returns a real success signal (see that interface member's doc for
/// why a <c>void</c> sink broke the 183-case live retail conformance
/// suite on the analogous <c>DoInterpretedMotion</c> path) — a FAILED
/// stop dispatch skips both the <c>add_to_queue</c> node and the
/// <c>InterpretedMotionState::RemoveMotion</c> state clear, matching
/// retail's <c>if (result == 0) { ... }</c> gate exactly. On success,
/// the queued node's <c>JumpErrorCode</c> is always <c>0</c> (the stop's
/// own "return value" that becomes the queued error code is
/// <c>result</c>, which is <c>0</c> on that path).
/// </para>
/// </summary>
/// <param name="motion">Retail <c>arg2</c>.</param>
/// <param name="p">Retail <c>arg3</c>.</param>
public WeenieError StopInterpretedMotion(uint motion, MovementParameters p)
=> StopInterpretedMotion(motion, p, DefaultSink);
/// <summary>
/// Sink-parameterized core shared by the public
/// <see cref="StopInterpretedMotion(uint,MovementParameters)"/> and the
/// internal <see cref="DispatchStopInterpretedMotion"/> primitive — same
/// split rationale as
/// <see cref="DoInterpretedMotion(uint,MovementParameters,IInterpretedMotionSink?)"/>.
/// </summary>
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;
}
/// <summary>
/// R3-W5 internal per-axis dispatch primitive
/// (<c>DispatchInterpretedMotion</c>, formerly the S2a funnel's public
/// surface). <see cref="ApplyInterpretedMovement"/>'s four
/// <c>DoInterpretedMotion</c>-shaped calls (style, forward-or-Falling,
/// sidestep, turn) each need to dispatch through an EXPLICIT per-call
/// <paramref name="sink"/> (a remote entity's own sink, threaded in from
/// <see cref="MoveToInterpretedState"/>) — retail's own
/// <c>apply_interpreted_movement</c> calls <c>CMotionInterp::DoInterpretedMotion</c>
/// directly (the SAME function
/// <see cref="DoInterpretedMotion(uint,MovementParameters,IInterpretedMotionSink?)"/>
/// implements) with a fresh local <c>MovementParameters</c> per axis —
/// this wraps that shape with a throwaway params object carrying only
/// <c>Speed</c> (ctor default for everything else, matching retail's own
/// local <c>var_2c</c>).
/// </summary>
private WeenieError DispatchInterpretedMotion(uint motion, float speed, IInterpretedMotionSink? sink)
=> DoInterpretedMotion(motion, new MovementParameters { Speed = speed }, sink);
/// <summary>
/// R3-W5 internal per-axis STOP primitive — the
/// <see cref="StopInterpretedMotion(uint,MovementParameters,IInterpretedMotionSink?)"/>
/// analogue of <see cref="DispatchInterpretedMotion"/>, used by
/// <see cref="ApplyInterpretedMovement"/>'s sidestep-stop/turn-stop tail
/// calls (raw 305739/305748/305770 — retail's own
/// <c>CPhysicsObj::StopInterpretedMotion</c> calls with a fresh local
/// <c>MovementParameters</c>).
/// </summary>
private WeenieError DispatchStopInterpretedMotion(uint motion, IInterpretedMotionSink? sink)
=> StopInterpretedMotion(motion, new MovementParameters(), sink);
}