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