# L.3 port — per-axis UM dispatch DEEP DIVE **Source:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR build). All line numbers cite that file. This document picks up where `02-um-handling.md` left off: it goes DEEPER into the per-axis dispatch chain that fires on each UM (and on every per-tick re-application). It is the canonical reference for "when an UpdateMotion arrives with forward+sidestep+turn populated, what does retail call, in what order, and what does it write?" --- ## 0. Ten-second summary `MovementManager::unpack_movement` (300563) reads movement_type=0, calls `InterpretedMotionState::UnPack` (294360, see §5 of doc 02), then calls `MovementManager::move_to_interpreted_state` (300259) which delegates to **`CMotionInterp::move_to_interpreted_state`** (305936). That function does TWO things: 1. **`InterpretedMotionState::copy_movement_from`** — bulk-overwrite all 7 fields of the body's InterpretedState with the wire values (current_style, fwd_cmd, fwd_speed, side_cmd, side_speed, turn_cmd, turn_speed). Unconditional. No diffing. 2. **`apply_current_movement(cancelMoveTo=1, allowJump=…)`** (305838), which routes to either `apply_raw_movement` (autonomous local player) or **`apply_interpreted_movement`** (305713) — the latter is what fires for remote observers. `apply_interpreted_movement` is the **per-axis dispatcher**. It calls `DoInterpretedMotion` (305575) for each of: current_style, forward axis, sidestep axis (or stop), turn axis (or stop). Each call ends in `CPhysicsObj::DoInterpretedMotion` → `set_local_velocity(get_state_velocity())`. **The "staircase" bug acdream's env-var path showed = absence of this per-axis dispatch on the per-tick remote driver.** The default acdream path runs `apply_current_movement` (correct) on UM intake, but the env-var experimental path bypassed it and only ran the manager's position lerp — so velocity was never re-derived, and Z stayed flat between UMs even when the wire said "running up a slope." --- ## 1. CMotionInterp::PerformMovement (the top-level command pump) **Function:** `CMotionInterp::PerformMovement` **Address:** `0x00528e80` **Lines:** 306221–306268. ```c 00528e80 uint32_t PerformMovement(this, MovementStruct const* arg2) { int32_t ecx_1 = (arg2->type - 1); if (ecx_1 > 4) return 0x47; // BadMovementType switch (ecx_1) { case 0: /* DoMotion */ uint32_t eax = DoMotion(this, arg2->motion, arg2->params); CPhysicsObj::CheckForCompletedMotions(this->physics_obj); return eax; case 1: /* DoInterpretedMotion */ uint32_t eax_2 = DoInterpretedMotion(this, arg2->motion, arg2->params); CPhysicsObj::CheckForCompletedMotions(this->physics_obj); return eax_2; case 2: /* StopMotion */ uint32_t eax_4 = StopMotion(this, arg2->motion, arg2->params); CPhysicsObj::CheckForCompletedMotions(this->physics_obj); return eax_4; case 3: /* StopInterpretedMotion */ uint32_t eax_6 = StopInterpretedMotion(this, arg2->motion, arg2->params); CPhysicsObj::CheckForCompletedMotions(this->physics_obj); return eax_6; case 4: /* StopCompletely */ StopCompletely(this); CPhysicsObj::CheckForCompletedMotions(this->physics_obj); return 0; } } ``` **Behavior:** dispatch by `MovementStruct::type` (1..5): | type | meaning | handler | |---|---|---| | 1 | DoMotion (raw, with adjust_motion) | DoMotion (306159) | | 2 | DoInterpretedMotion (already adjusted) | DoInterpretedMotion (305575) | | 3 | StopMotion (raw stop) | StopMotion (305674) | | 4 | StopInterpretedMotion (interpreted stop) | StopInterpretedMotion (305635) | | 5 | StopCompletely | StopCompletely (305208) | The wrapping `MovementManager::PerformMovement` (300194) chooses between this command pump (types 1–5 → `CMotionInterp`) and the MoveTo manager (types 6–9 → `MoveToManager`). **Inbound 0xF74C RawCommand packets do NOT call PerformMovement** — they call `move_to_interpreted_state` directly. PerformMovement is the API for LOCAL command sources (CommandInterpreter, MoveToManager re-emits, slash commands). --- ## 2. CMotionInterp::DoMotion (the raw command path) **Function:** `CMotionInterp::DoMotion` **Address:** `0x00528d20` **Lines:** 306159–306217. ```c 00528d20 uint32_t DoMotion(this, uint32_t motion, MovementParameters const* params) { if (physics_obj == 0) return 8; uint32_t ebp = motion; // saved unmodified if ((params->__inner0.byte1 & 0x80) != 0) // CancelMoveTo bit CPhysicsObj::interrupt_current_movement(physics_obj); if ((params->__inner0.byte1 & 0x08) != 0) // SetHoldKey bit SetHoldKey(this, params->hold_key_to_apply, ((params->__inner0 >> 0xf) & 1)); adjust_motion(this, &motion, &speed, params->hold_key_to_apply); // Combat-style guards on RAW (pre-adjust) motion if (this->interpreted_state.current_style != 0x8000003d /*NonCombat*/) { if (ebp == 0x41000012) return 0x3f; // CantCrouchInCombat if (ebp == 0x41000013) return 0x40; // CantSitInCombat if (ebp == 0x41000014) return 0x41; // CantSleepInCombat if ((ebp & 0x02000000) != 0) return 0x42; // CantChatEmoteInCombat } // Action quota check on RAW (pre-adjust) motion if ((ebp & 0x10000000) != 0 && InterpretedMotionState::GetNumActions(&this->interpreted_state) >= 6) return 0x45; // TooManyActions uint32_t result = DoInterpretedMotion(this, motion /*adjusted*/, &var_2c); if (result == 0 && (params->__inner0.byte1 & 0x20) != 0) // ModifyRawState bit RawMotionState::ApplyMotion(&this->raw_state, ebp /*pre-adjust*/, params); return result; } ``` **Per-axis behavior:** DoMotion is a **single-axis** entry. It dispatches ONE motion (passed as `arg2`) through `adjust_motion` → DoInterpretedMotion. For multi-axis local input, the caller fires DoMotion three times (once for forward, once for side, once for turn). Acdream's `MotionInterpreter.DoMotion` (l.381) is structurally close BUT lacks the `adjust_motion` call — meaning local `DoMotion(WalkBackward, +1.0)` never gets sign-flipped to `WalkForward + -0.65`. (See §13 below.) The **RawState.ApplyMotion uses the PRE-adjust `ebp`**, the InterpretedState path inside DoInterpretedMotion uses the post-adjust `motion`. This is what lets the wire carry both "user said walk backward" (raw) and "physics ran walk forward at -0.65" (interpreted) on the same packet. --- ## 3. CMotionInterp::adjust_motion (the canonical sign-flipper) **Function:** `CMotionInterp::adjust_motion` **Address:** `0x00528010` **Lines:** 305343–305400. Already documented in detail in `02-um-handling.md` §10. Repeating the mappings table here for reference: | Input | Speed | Output | Speed | |---|---|---|---| | WalkForward (0x45000005) | s | WalkForward | s | | WalkBackward (0x45000006) | s | WalkForward | -0.65 × s | | SideStepLeft (0x6500000e) | s | SideStepRight | -s | | ??? (0x65000010) alias | s | SideStepRight | -s | | SideStepRight (0x6500000f) | s | SideStepRight | 1.248 × s | | RunForward (0x44000007) | s | RunForward | s (no change here) | Then if `holdKey == HoldKey_Run`: **`apply_run_to_command(this, &motion, &speed)`** runs. --- ## 4. CMotionInterp::apply_run_to_command (HoldKey rewriter) **Function:** `CMotionInterp::apply_run_to_command` **Address:** `0x00527be0` **Lines:** 305062–305123. Documented in `02-um-handling.md` §11. Key facts: - WalkForward + speed > 0 → **promotes to RunForward**, multiplies speed by speedMod (runRate). - WalkForward + speed ≤ 0 → stays WalkForward, multiplies speed by speedMod (so backward stays backward but at run-pace × 0.65). - TurnRight → speed *= 1.5 (RunTurnFactor). - SideStepRight → speed *= speedMod, clamp |result| ≤ 3.0 (MaxSidestepAnimRate). --- ## 5. CMotionInterp::DoInterpretedMotion (the leaf executor) **Function:** `CMotionInterp::DoInterpretedMotion` **Address:** `0x00528360` **Lines:** 305575–305631. ```c 00528360 uint32_t DoInterpretedMotion(this, motion, params) { if (physics_obj == 0) return 8; uint32_t result; if (contact_allows_move(this, motion) != 0) { if (this->standing_longjump != 0 && (motion == 0x45000005 || motion == 0x44000007 || motion == 0x6500000f)) { // mid-longjump: skip engine-side action, just touch InterpretedState if asked if (params->__inner0.byte1 & 0x40 /*ModifyInterpretedState*/) InterpretedMotionState::ApplyMotion(&this->interpreted_state, motion, params); result = 0; } else { if (motion == 0x40000011 /*Dead*/) CPhysicsObj::RemoveLinkAnimations(this->physics_obj); // The HOT call: pushes velocity through CPhysicsObj result = CPhysicsObj::DoInterpretedMotion(this->physics_obj, motion, params); if (result == 0) { uint32_t jumpErr; if ((params->__inner0 & 0x20000) == 0) { jumpErr = motion_allows_jump(this, motion); if (jumpErr == 0 && (motion & 0x10000000) == 0) jumpErr = motion_allows_jump(this, this->interpreted_state.forward_command); } else { jumpErr = 0x48; /*disable*/ } add_to_queue(this, params->context_id, motion, jumpErr); if (params->__inner0.byte1 & 0x40) InterpretedMotionState::ApplyMotion(&this->interpreted_state, motion, params); } } } else if ((motion & 0x10000000 /*Action*/) == 0) { // Airborne but motion isn't an Action — just touch InterpretedState if asked if (params->__inner0.byte1 & 0x40) InterpretedMotionState::ApplyMotion(&this->interpreted_state, motion, params); result = 0; } else result = 0x24 /*YouCantJumpWhileInTheAir*/; if (physics_obj != 0 && physics_obj->cell == 0) CPhysicsObj::RemoveLinkAnimations(physics_obj); return result; } ``` **This is the function `apply_interpreted_movement` calls 3× per UM** (once each for forward/sidestep/turn axes). The actual physics velocity push is inside `CPhysicsObj::DoInterpretedMotion` — that's where `set_local_velocity(get_state_velocity(...))` happens, AND that's where the animation sequencer is driven by `set_motion_table_data`. **Critical:** when called from `apply_interpreted_movement`, `params` is a fresh `MovementParameters()` with default flags — and the default has **`ModifyInterpretedState = 0`**. So per-axis re-application is purely a PHYSICS push; the state itself stays as `copy_movement_from` bulk-loaded it. --- ## 6. CMotionInterp::apply_interpreted_movement (THE per-axis dispatcher) **Function:** `CMotionInterp::apply_interpreted_movement` **Address:** `0x00528600` **Lines:** 305713–305788. This is the heart of the per-axis dispatch. **Every UM that reaches `move_to_interpreted_state` ends here**, and so does every state-change that goes through `apply_current_movement` (e.g. landing, leave-ground, SetWeenieObject, SetPhysicsObject, ReportExhaustion). ```c 00528600 void apply_interpreted_movement(this, int32_t arg2 /*cancelMoveTo*/, int32_t arg3 /*allowJump*/) { if (physics_obj == 0) return; MovementParameters var_2c; MovementParameters::MovementParameters(&var_2c); // default flags: NO ModifyState, NO ModifyRaw // (1) Cache MyRunRate from forward_speed if we are in run state if (this->interpreted_state.forward_command == 0x44000007 /*RunForward*/) this->my_run_rate = this->interpreted_state.forward_speed; // (2) STYLE axis — re-apply current_style as a motion (combat ↔ peace, etc) DoInterpretedMotion(this, this->interpreted_state.current_style, &var_2c); // (3) FORWARD axis (with airborne / longjump branches) if (contact_allows_move(this, this->interpreted_state.forward_command) == 0) { // Airborne / dead — force Falling animation DoInterpretedMotion(this, 0x40000015 /*Falling*/, &var_2c); } else if (this->standing_longjump != 0) { // In a charged longjump — pin forward to Ready, kill any sidestep DoInterpretedMotion(this, 0x41000003 /*Ready*/, &var_2c); StopInterpretedMotion(this, 0x6500000f /*SideStepRight*/, &var_2c); } else { // (3a) FORWARD: dispatch the wire-supplied forward command verbatim var_2c.speed = this->interpreted_state.forward_speed; DoInterpretedMotion(this, this->interpreted_state.forward_command, &var_2c); // (3b) SIDESTEP: dispatch OR explicit-stop if (this->interpreted_state.sidestep_command == 0) StopInterpretedMotion(this, 0x6500000f /*SideStepRight*/, &var_2c); else { var_2c.speed = this->interpreted_state.sidestep_speed; DoInterpretedMotion(this, this->interpreted_state.sidestep_command, &var_2c); } } // (4) TURN axis: dispatch OR explicit-stop uint32_t turn_command = this->interpreted_state.turn_command; if (turn_command != 0) { var_2c.speed = this->interpreted_state.turn_speed; DoInterpretedMotion(this, turn_command, &var_2c); return; } // turn_command == 0 → explicit stop on TurnRight, plus a Ready add_to_queue uint32_t eax_10 = CPhysicsObj::StopInterpretedMotion(physics_obj, 0x6500000d /*TurnRight*/, &var_2c); if (eax_10 == 0) { add_to_queue(this, var_c, 0x41000003 /*Ready*/, eax_10); if (params.byte1 & 0x40) InterpretedMotionState::RemoveMotion(&this->interpreted_state, 0x6500000d); } } ``` ### Per-axis dispatch order (CANONICAL) When a UM with all three axes populated arrives, retail does: 1. `DoInterpretedMotion(current_style, default_params)` — applies stance (Combat/Peace/Magic/etc.). 2. **If airborne**: `DoInterpretedMotion(Falling)` — forward axis is skipped, body animates as Falling. 3. **Else if longjump**: `DoInterpretedMotion(Ready)` then `StopInterpretedMotion(SideStepRight)` — forward pinned, sidestep killed. 4. **Else (normal grounded path)**: a. `DoInterpretedMotion(forward_command, params{speed=fwd_speed})` b. If `sidestep_command == 0`: `StopInterpretedMotion(SideStepRight)` Else: `DoInterpretedMotion(sidestep_command, params{speed=side_speed})` 5. If `turn_command != 0`: `DoInterpretedMotion(turn_command, params{speed=turn_speed})` and **RETURN**. 6. If `turn_command == 0`: `CPhysicsObj::StopInterpretedMotion(TurnRight, default_params)` directly on physics_obj (skipping the CMotionInterp wrapper), plus `add_to_queue(Ready)`. **Note the asymmetry:** the turn-stop bypasses CMotionInterp's StopInterpretedMotion and goes straight to CPhysicsObj. This is because the wrapper would re-enter the contact_allows_move check (turns are always allowed per `contact_allows_move`'s special case), and the direct call ensures the StopInterpretedMotion fires regardless of state. --- ## 7. CMotionInterp::apply_current_movement (the dispatcher chooser) **Function:** `CMotionInterp::apply_current_movement` **Address:** `0x00528870` **Lines:** 305838–305857. ```c 00528870 void apply_current_movement(this, int32_t arg2, int32_t arg3) { if (physics_obj == 0 || initted == 0) return; int32_t isPlayer = (weenie_obj != 0) ? weenie_obj->vtable->IsThePlayer() : 0; // Local player + autonomous → use RAW path (so HoldKey gets re-applied via adjust_motion) if ((weenie_obj == 0 || isPlayer != 0) && CPhysicsObj::movement_is_autonomous(this->physics_obj) != 0) { apply_raw_movement(this, arg2, arg3); return; } // Everyone else → INTERPRETED path apply_interpreted_movement(this, arg2, arg3); } ``` **This is the bifurcation point**: local autonomous player runs `apply_raw_movement`; remote observer runs `apply_interpreted_movement`. Acdream's port (`MotionInterpreter.apply_current_movement`, l.653) is **a heavily-simplified single-shot** that does NOT branch and does NOT re-fire per-axis — it just calls `set_local_velocity(get_state_velocity())` once, gated on `OnWalkable`. **This is the structural divergence.** --- ## 8. CMotionInterp::apply_raw_movement (LOCAL player path) **Function:** `CMotionInterp::apply_raw_movement` **Address:** `0x005287e0` **Lines:** 305817–305834. ```c 005287e0 void apply_raw_movement(this, arg2, arg3) { if (physics_obj == 0) return; // Bulk-copy 7 fields from RAW → INTERPRETED this->interpreted_state.current_style = this->raw_state.current_style; this->interpreted_state.forward_command = this->raw_state.forward_command; this->interpreted_state.forward_speed = this->raw_state.forward_speed; this->interpreted_state.sidestep_command = this->raw_state.sidestep_command; this->interpreted_state.sidestep_speed = this->raw_state.sidestep_speed; this->interpreted_state.turn_command = this->raw_state.turn_command; this->interpreted_state.turn_speed = this->raw_state.turn_speed; // Re-run adjust_motion ONCE PER AXIS (so HoldKey.Run promotes Walk→Run, etc.) adjust_motion(this, &interpreted_state.forward_command, &interpreted_state.forward_speed, raw_state.forward_holdkey); adjust_motion(this, &interpreted_state.sidestep_command, &interpreted_state.sidestep_speed, raw_state.sidestep_holdkey); adjust_motion(this, &interpreted_state.turn_command, &interpreted_state.turn_speed, raw_state.turn_holdkey); // Then dispatch each axis through DoInterpretedMotion (per-axis re-fire) apply_interpreted_movement(this, arg2, arg3); } ``` **Crucial:** the local player path **also** ends in `apply_interpreted_movement`. The only difference is that it first copies RAW → INTERPRETED and runs `adjust_motion` per axis. The per-axis dispatcher is the same. This means there is **exactly ONE per-axis dispatch path** in retail: `apply_interpreted_movement`. Both local autonomous and remote observer go through it. --- ## 9. CMotionInterp::move_to_interpreted_state (the UM entry) Already covered in `02-um-handling.md` §7. Quick recap: ```c 005289c0 int32_t move_to_interpreted_state(this, InterpretedMotionState const* arg2) { if (physics_obj == 0) return 0; this->raw_state.current_style = arg2->current_style; // raw mirrors style CPhysicsObj::interrupt_current_movement(physics_obj); uint32_t allowJump = motion_allows_jump(this, this->interpreted_state.forward_command); InterpretedMotionState::copy_movement_from(&this->interpreted_state, arg2); // BULK COPY apply_current_movement(this, 1 /*cancelMoveTo*/, !!allowJump); // PER-AXIS REFIRE // Then iterate actions[] with stamp-wrap protection for each action in arg2->actions where stamp_is_newer: DoInterpretedMotion(this, action.motion, params{action_stamp, autonomous}); return 1; } ``` **copy_movement_from** is unconditional — overwrites all 7 fields: ```c InterpretedMotionState::copy_movement_from (lines 293301-293311): this->current_style = src->current_style; this->forward_command = src->forward_command; this->forward_speed = src->forward_speed; this->sidestep_command = src->sidestep_command; this->sidestep_speed = src->sidestep_speed; this->turn_command = src->turn_command; this->turn_speed = src->turn_speed; ``` No diffing. No filter. **If the wire said it, it's in InterpretedState now.** --- ## 10. CMotionInterp::StopInterpretedMotion **Function:** `CMotionInterp::StopInterpretedMotion` **Address:** `0x00528470` **Lines:** 305635–305670. ```c 00528470 uint32_t StopInterpretedMotion(this, motion, params) { if (physics_obj == 0) return 8; uint32_t result; bool airborne_skip = (contact_allows_move(this, motion) == 0) || (standing_longjump != 0 && (motion == 0x45000005 || motion == 0x44000007 || motion == 0x6500000f)); if (airborne_skip) { if (params.byte1 & 0x40 /*ModifyInterpretedState*/) InterpretedMotionState::RemoveMotion(&this->interpreted_state, motion); result = 0; } else { result = CPhysicsObj::StopInterpretedMotion(this->physics_obj, motion, params); if (result == 0) { add_to_queue(this, params->context_id, 0x41000003 /*Ready*/, result); if (params.byte1 & 0x40) InterpretedMotionState::RemoveMotion(&this->interpreted_state, motion); } } if (physics_obj != 0 && physics_obj->cell == 0) CPhysicsObj::RemoveLinkAnimations(physics_obj); return result; } ``` --- ## 11. CMotionInterp::StopMotion **Function:** `CMotionInterp::StopMotion` **Address:** `0x00528530` **Lines:** 305674–305708. ```c 00528530 uint32_t StopMotion(this, motion, params) { if (physics_obj == 0) return 8; if (params.byte1 & 0x80) CPhysicsObj::interrupt_current_movement(physics_obj); // Capture all params fields locally (because adjust_motion will mutate motion+speed) float speed = params.speed; HoldKey hk = params.hold_key_to_apply; arg2 = motion; int32_t var_2c = 0x7c83f8; // some default flags adjust_motion(this, &arg2, &speed, hk); // sign-flip + run-promote uint32_t result = StopInterpretedMotion(this, arg2, &var_2c); if (result == 0 && (params.byte1 & 0x20 /*ModifyRawState*/) != 0) RawMotionState::RemoveMotion(&this->raw_state, motion); // PRE-adjust motion return result; } ``` Same pattern as DoMotion: adjust_motion the motion+speed, dispatch the adjusted form to StopInterpretedMotion, then optionally update RawState with the PRE-adjust form. --- ## 12. CMotionInterp::StopCompletely **Function:** `CMotionInterp::StopCompletely` **Address:** `0x00527e40` **Lines:** 305208–305234. ```c 00527e40 uint32_t StopCompletely(this) { if (physics_obj == 0) return 8; CPhysicsObj::interrupt_current_movement(physics_obj); uint32_t allowJump = motion_allows_jump(this, this->interpreted_state.forward_command); // Reset RAW (5 fields) this->raw_state.forward_command = 0x41000003 /*Ready*/; this->raw_state.forward_speed = 1.0f; this->raw_state.sidestep_command = 0; this->raw_state.turn_command = 0; // Reset INTERPRETED (5 fields — note: side_speed and turn_speed NOT reset here!) this->interpreted_state.forward_command = 0x41000003; this->interpreted_state.forward_speed = 1.0f; this->interpreted_state.sidestep_command = 0; this->interpreted_state.turn_command = 0; CPhysicsObj::StopCompletely_Internal(this->physics_obj); add_to_queue(this, 0, 0x41000003 /*Ready*/, allowJump); if (physics_obj != 0 && physics_obj->cell == 0) CPhysicsObj::RemoveLinkAnimations(physics_obj); return 0; } ``` **Note:** `sidestep_speed` and `turn_speed` are NOT reset to 1.0f (neither in raw nor interpreted). Only the commands and forward_speed are touched. Acdream's port (l.510) goes further and resets all six speeds — slightly more aggressive than retail but harmless because the speeds are only consumed when the matching command is non-zero. --- ## 13. CMotionInterp::get_max_speed and get_adjusted_max_speed **Function:** `CMotionInterp::get_max_speed` **Address:** `0x00527cb0` **Lines:** 305127–305141. ```c 00527cb0 void get_max_speed(this) { CMotionInterp* this_1 = this; // probably an out-param CWeenieObject* weenie = this->weenie_obj; this_1 = nullptr; if (weenie == 0) return; if (weenie->vtable->InqRunRate(&this_1) != 0) return; this->my_run_rate; // (compiler artifact — load only) } ``` The decompile is hard to read because of x87 return-value handling, but semantically: **return InqRunRate(weenie) if available, else my_run_rate**. Used by `set_target_movement` (`0x00509ed5` references in 352395, 353112). **Function:** `CMotionInterp::get_adjusted_max_speed` **Address:** `0x00527d00` **Lines:** 305145–305156. ```c 00527d00 void get_adjusted_max_speed(this) { CWeenieObject* weenie = this->weenie_obj; if (weenie != 0 && weenie->vtable->InqRunRate(&this_1) == 0) this->my_run_rate; // load if (this->interpreted_state.forward_command == 0x44000007 /*RunForward*/) ((long double)this->interpreted_state.forward_speed) / ((long double)this->current_speed_factor); } ``` Adjusts the cap by the **current_speed_factor** (an internal multiplier e.g. for encumbrance / spell effects) when the body is in run state. Both functions return a float used as the speed cap inside `get_state_velocity` (305160) — `len > 4.0L * rate` clamp scale. --- ## 14. InterpretedMotionState::UnPack — flag bits in detail (Documented in `02-um-handling.md` §5; restating the bit table here since this doc is the dispatch reference.) | Bit | Field | When CLEAR | |---|---|---| | 0x01 | `current_style` | NonCombat (0x8000003d) | | 0x02 | `forward_command` | Ready (0x41000003) | | 0x04 | `forward_speed` | 1.0f | | 0x08 | `sidestep_command` | 0 | | 0x10 | `sidestep_speed` | 1.0f | | 0x20 | `turn_command` | 0 | | 0x40 | `turn_speed` | 1.0f | | 0x80–0x800 | action count (5 bits) | 0 | **Default-on-absence is the per-axis SEMANTIC that drives the stop signal.** When ACE relays a stop, it omits the forward_command field (bit 0x02 clear), and UnPack sets `forward_command = Ready`. The copy_movement_from then writes Ready into InterpretedState. The next `apply_interpreted_movement` calls `DoInterpretedMotion(Ready, speed=1.0)`. `get_state_velocity` returns (0,0,0) because Ready is neither WalkForward nor RunForward. --- ## 15. The complete dispatch sequence for an inbound UM (annotated) For a UM with all three axes populated (forward + sidestep + turn) on a remote observer, **the full call chain** is: ``` 0xF74C wire packet CM_Physics::DispatchSB_* (357214) CPhysics::SetObjectMovement (271370) CPhysicsObj::unpack_movement (280179) MovementManager::unpack_movement (300563) // read 2 bytes movement_type=0 // read 2 bytes initial_style if (style != current) DoMotion(style) (306159) <- one DoMotion for STANCE InterpretedMotionState::UnPack (294360) // read flags uint32 + each conditional field MovementManager::move_to_interpreted_state(300259) CMotionInterp::move_to_interpreted_state(305936) raw_state.current_style = src.current_style interrupt_current_movement motion_allows_jump(prev forward_command) InterpretedMotionState::copy_movement_from // BULK COPY 7 FIELDS apply_current_movement(cancelMoveTo=1, allowJump) (305838) if (local autonomous): apply_raw_movement (305817) bulk-copy raw → interpreted adjust_motion × 3 (forward, sidestep, turn) apply_interpreted_movement (305713) else: // remote observer apply_interpreted_movement (305713) cache MyRunRate if RunForward DoInterpretedMotion(current_style) // STYLE CPhysicsObj::DoInterpretedMotion (varies) if !contact_allows_move: DoInterpretedMotion(Falling) // FORWARD branch (airborne) elif standing_longjump: DoInterpretedMotion(Ready) StopInterpretedMotion(SideStepRight) else: DoInterpretedMotion(forward_command) // FORWARD axis CPhysicsObj::DoInterpretedMotion set_local_velocity(get_state_velocity) // VELOCITY PUSH FOR FORWARD if (sidestep_command == 0): StopInterpretedMotion(SideStepRight) else: DoInterpretedMotion(sidestep_command) // SIDESTEP axis CPhysicsObj::DoInterpretedMotion set_local_velocity(get_state_velocity) // VELOCITY PUSH FOR SIDESTEP (additive over forward) if (turn_command != 0): DoInterpretedMotion(turn_command) // TURN axis CPhysicsObj::DoInterpretedMotion (drives angular velocity / animation omega) else: CPhysicsObj::StopInterpretedMotion(TurnRight) add_to_queue(Ready) // Then for each action in src.actions: for each action with newer stamp: DoInterpretedMotion(action.motion, params{action_stamp, autonomous}) // OVERLAY axis (Twitch / ChatEmote / etc — not a velocity push) ``` **Five DoInterpretedMotion calls per UM in the typical case (style + forward + sidestep + turn + maybe one action).** Each call into `CPhysicsObj::DoInterpretedMotion` pushes velocity via `set_local_velocity(get_state_velocity())`. The fact that the velocity is pushed **MULTIPLE TIMES PER UM** (once after each axis) is what makes the body's local velocity in retail respond instantly to a multi-axis state change. Acdream's `apply_current_movement` only pushes velocity once. --- ## 16. Overlay (Action / ChatEmote / Modifier) dispatch Action commands (bit 0x10000000) and ChatEmote commands (bit 0x02000000) are overlay motions. They flow through: - **Inbound UM**: the `actions[]` list inside `InterpretedMotionState` (5-bit action count starting at flag 0x80). Each action is dispatched in `move_to_interpreted_state`'s for-loop via `DoInterpretedMotion(action.motion, params{action_stamp, autonomous})`. - **Locally generated**: `DoMotion(motion, params)` with the action bit set. `DoMotion`'s combat-style guards reject ChatEmote in combat (`return 0x42`). Actions over the 6-action quota return 0x45. Within `DoInterpretedMotion`, an Action command **does** call `CPhysicsObj::DoInterpretedMotion` (which routes the command to the animation sequencer's overlay channel) but `get_state_velocity` does NOT consume Action commands — they have no velocity contribution. This is what makes "swing weapon while running" work: the forward axis still produces RunForward velocity (because `forward_command` in InterpretedState is still RunForward), and the action just plays on the overlay channel. **Acdream's `AnimationCommandRouter.RouteFullCommand`** is the analog of the overlay-channel routing inside `CPhysicsObj::DoInterpretedMotion`. Note that acdream's GameWindow (l.2887–2892) calls RouteFullCommand **only** when `forwardIsOverlay` and `!remoteIsAirborne`. This ONLY handles the "first-class" overlay flag in the wire (the special-case where the wire sets `forward_command` to an Action command directly, which retail handles via `move_to_interpreted_state`'s bulk copy followed by `apply_interpreted_movement`'s forward-axis dispatch). --- ## 17. Cross-check: acdream `MotionInterpreter.cs` divergences | Retail | Acdream (`MotionInterpreter.cs`) | Verdict | |---|---|---| | `apply_interpreted_movement` (305713) — fires DoInterpretedMotion for STYLE + FORWARD + SIDESTEP + TURN | **MISSING.** `apply_current_movement` (l.653) just calls `set_local_velocity(get_state_velocity())` once, gated on OnWalkable. | **CRITICAL GAP** | | `apply_raw_movement` (305817) — bulk-copy RAW→INTERPRETED, then adjust_motion × 3, then apply_interpreted_movement | **MISSING.** Acdream's local autonomous player path is improvised in `PlayerMovementController` — does not run adjust_motion per axis. | Bug for local-input WalkBackward + HoldKey.Run interaction | | `DoMotion` (306159) — adjust_motion → DoInterpretedMotion → optional RawState.ApplyMotion | `DoMotion` (l.381) just sets RawState.ForwardCommand+Speed and forwards to `DoInterpretedMotion(modifyInterpretedState:true)`. **No adjust_motion call.** | Bug for local backward/sidestep input | | `move_to_interpreted_state` (305936) — bulk-copy InterpretedState (7 fields), apply_current_movement, iterate actions | **PARTIAL.** GameWindow.cs:2847 sets only ForwardCommand+ForwardSpeed (NOT current_style, NOT all 7 fields). Sidestep/Turn handled via separate `DoInterpretedMotion`/`StopInterpretedMotion` calls (l.3060, 3066, 3096, 3108). | Functional but structurally divergent | | `StopMotion` (305674) — adjust_motion the motion, StopInterpretedMotion(adjusted), optional RawMotionState::RemoveMotion(pre-adjust) | `StopMotion` (l.431) — early-rewrites RawState, then forwards. **No adjust_motion call.** | Functional for sign-aligned cases only | | `apply_run_to_command` (305062) — speed > 0 gate for promote, multiplies speed by speedMod | `MotionInterpreter` doesn't have this method. Wire-arrival gives the post-adjust form so it's not strictly needed for L.3 receive path; but the local SEND path is missing it. | Required for outbound bug parity | | `ApplyMotion` (293531) — switch by command class (TurnRight / SideStepRight / 0x4xxxxxxx) | `ApplyMotionToInterpretedState` (l.993) — `switch` over **specific commands** (Walk/Run/WalkBackward/SideStepRight/SideStepLeft/TurnRight/TurnLeft/Ready). Other 0x4xxxxxxx commands (Stand, Falling, Crouch, Sit, Sleep) **fall through silently.** | Bug for non-locomotion forward commands | --- ## 18. Answers to the critical questions ### Q1: Does retail call DoInterpretedMotion separately for forward, sidestep, and turn axes EACH UM AND EACH TICK? **EACH UM: YES.** `apply_interpreted_movement` (305713) makes 3–5 DoInterpretedMotion calls in sequence: style, forward (or Falling / Ready+SideStop for longjump), sidestep (or stop), turn (or stop). **EACH TICK: NO.** `apply_interpreted_movement` does NOT auto-fire on every physics tick. It fires: - On every UM intake (via `move_to_interpreted_state` → `apply_current_movement`). - On `LeaveGround` / `LandingHandler` / `enter_default_state` / `SetWeenieObject` / `SetPhysicsObject` / `ReportExhaustion`. - On `set_target_movement` and `cancel_moveto`. Per-tick, the body's velocity is preserved by the physics solver (`PhysicsBody::update`) using the velocity that was last set via `set_local_velocity` from the most recent `apply_interpreted_movement`. **The body integrates with the SAME velocity until a new state event fires another `apply_interpreted_movement`.** This is why retail does NOT have a "staircase" issue on slopes: the last `set_local_velocity` from the last UM stays in effect, and the collision sweep (`ResolveWithTransition`) handles the slope component naturally. Acdream's env-var path bypassed this by skipping the `set_local_velocity` re-push at UM time. ### Q2: When UM arrives with all 3 axes populated, does retail dispatch all 3? In what order? **Yes.** Order is fixed in `apply_interpreted_movement`: 1. STYLE (`current_style` — stance change) 2. FORWARD (or Falling / Ready+SideStop) 3. SIDESTEP (or explicit Stop) 4. TURN (or explicit Stop) Then iterate `actions[]` for overlays in stamp order. ### Q3: What's the canonical "play this cycle now" decision tree based on InterpretedState? This is **NOT in CMotionInterp**. The cycle decision lives inside `CPhysicsObj::DoInterpretedMotion` → `set_motion_table_data`. Each call to `DoInterpretedMotion(motion, params)` from `apply_interpreted_movement` ends in a `set_motion_table_data(motion, speed)` that updates the corresponding cycle slot in the animation sequencer. **The sequencer maintains one cycle per "axis class"** roughly: - Forward locomotion cycle: WalkForward / RunForward / Falling / Ready - Sidestep cycle: SideStepRight (with sign-flip for left) - Turn cycle: TurnRight (with sign-flip for left) - Style: stance-class affects which (style, command) pair the motion table looks up Multiple cycles play SIMULTANEOUSLY on different bone subsets, layered by the motion table's part definitions. This is why "run forward AND sidestep" produces a strafe-run animation: both cycles play, the parts each owns are updated by their own cycle. The "priority" acdream's `OnLiveMotionUpdated` uses (l.2905-2939: forward → sidestep → turn → ready, single SetCycle) is **a simplification** that picks ONE cycle. Retail plays multiple in parallel via the motion-table layering. ### Q4: For overlay (Action / Modifier / ChatEmote) packets, does the dispatch chain differ? **Slightly.** Overlays (Action bit 0x10000000, ChatEmote bit 0x02000000) ride in the `actions[]` list inside InterpretedMotionState. After `copy_movement_from` and `apply_interpreted_movement`, the action loop iterates with stamp-wrap protection and fires `DoInterpretedMotion(action.motion, params{action_stamp, autonomous_bit_via_0x1000})`. Inside `DoInterpretedMotion`: - `contact_allows_move` for Action commands always returns 1 (handled by `(motion & 0x10000000) == 0` check in the false-branch — Action airborne returns YouCantJumpWhileInTheAir). - Action commands have no `get_state_velocity` contribution (the switch in `get_state_velocity` only matches WalkForward / RunForward / SideStepRight as side-step gate). Net effect: Action overlay does NOT change body velocity (forward axis keeps producing whatever it was producing), but it DOES drive an animation overlay channel. **HOWEVER**: when a UM's `forward_command` IS an Action (e.g. retail sometimes encodes "swing this attack and stop running" by setting forward_command = AttackHigh1 directly, bit 0x02 set, no separate action entry), the bulk-copy lands AttackHigh1 in `interpreted_state.forward_command`. `apply_interpreted_movement` fires `DoInterpretedMotion(AttackHigh1, params{speed=fwd_speed})`, and `get_state_velocity` returns 0 (no match for WalkForward / RunForward) — body stops moving forward, attack animation plays on the Action channel via DoInterpretedMotion's set_motion_table_data. This is why GameWindow.cs:2802-2855 (acdream's "lifted bulk-copy" block) is correctly doing `InterpretedState.ForwardCommand = fullMotion` unconditionally — to match retail's bulk-copy semantics. --- ## 19. What would the "correct" UM handler do that acdream is missing? Given an inbound 0xF74C UM with movement_type=0 (RawCommand), retail's end-to-end flow is: ``` 1. STALENESS — check 16-bit instance_ts via SetObjectMovement; drop if older. 2. STYLE PRE-DISPATCH — if (new_style != current), DoMotion(new_style) to swap stance. 3. UNPACK — InterpretedMotionState::UnPack reads flags + each conditional field. 4. BULK COPY — copy_movement_from writes all 7 fields into the body's InterpretedState. 5. PER-AXIS RE-FIRE — apply_interpreted_movement runs: a. DoInterpretedMotion(current_style) b. DoInterpretedMotion(forward_command, params{speed=fwd_speed}) OR Falling-branch c. DoInterpretedMotion(sidestep_command, params{speed=side_speed}) OR StopInterpretedMotion(SideStepRight) d. DoInterpretedMotion(turn_command, params{speed=turn_speed}) OR StopInterpretedMotion(TurnRight) + Ready add_to_queue Each of these fires CPhysicsObj::DoInterpretedMotion, which calls set_local_velocity(get_state_velocity()) AND drives the animation sequencer's per-axis cycle slot. 6. ACTIONS LOOP — iterate actions[] with stamp-wrap protection; DoInterpretedMotion each newer action. ``` **Acdream is missing #5 (the per-axis re-fire) at the granularity retail has.** What acdream does on UM (`OnLiveMotionUpdated` + `MotionInterpreter.apply_current_movement`): - ✅ Staleness (timestamp check in WorldSession parser). - ✅ Style change (stance update). - ✅ Unpack (parser handles flag bits). - ✅ Bulk-copy ForwardCommand+ForwardSpeed (l.2847, 2855). **PARTIAL** — doesn't bulk-copy current_style separately (relies on `Stance` being lifted up by parser). - ❌ Per-axis re-fire of `apply_interpreted_movement`. **MISSING.** Instead: - Sidestep: directly calls `DoInterpretedMotion(sideFull, sideSpd, modifyInterpretedState:true)` (l.3060). - Turn: directly calls `DoInterpretedMotion(turnFull, turnSpd, modifyInterpretedState:true)` (l.3096). - These each fire `MotionInterpreter.apply_current_movement` once, which only does ONE `set_local_velocity` call. - ❌ Single `set_local_velocity` per UM rather than per-axis. **CRITICAL.** Effect: after-Forward velocity overwrites after-Sidestep velocity rather than building up. (Mitigated by `get_state_velocity` reading ALL three axis fields in one call — so the final velocity is correct. But the per-axis sequencer cycle slots may not all update because `MotionInterpreter.apply_current_movement` doesn't drive the sequencer.) - ❌ `apply_interpreted_movement`'s STYLE pre-fire. **MISSING.** Stance changes don't get a DoInterpretedMotion call — this is why "draw weapon while running" sometimes shows wrong stance pose. - ❌ `apply_interpreted_movement`'s longjump branch. **MISSING.** StandingLongJump-charged forward gets pinned to Ready in retail; in acdream the forward command is whatever the wire said. - ❌ `apply_interpreted_movement`'s explicit StopInterpretedMotion for zero-axis. **PARTIAL.** Acdream's GameWindow.cs:3066-3070 calls `StopInterpretedMotion(SideStepRight)` AND `StopInterpretedMotion(SideStepLeft)` when sidestep is 0; same for turn at 3108-3111. Retail only stops SideStepRight and TurnRight (relies on adjust_motion having normalized Left → Right + sign). - ❌ Falling-when-airborne fallback. **WORKAROUND.** acdream handles this via `remoteIsAirborne` checks scattered through `OnLiveMotionUpdated`, NOT in `apply_current_movement`. ### Concrete fix proposal Port `apply_interpreted_movement` faithfully into MotionInterpreter.cs and have `apply_current_movement` (l.653) **delegate** to it for the remote-observer path. The single `set_local_velocity` call should move INSIDE `CPhysicsObj.DoInterpretedMotion` (or a new method `MotionInterpreter.DispatchAxis(motion, speed)`) so each axis call both updates InterpretedState AND pushes velocity AND drives the sequencer. Then `OnLiveMotionUpdated` becomes much shorter: ``` OnLiveMotionUpdated: remoteMot.Motion.move_to_interpreted_state(wireInterpretedState) // That single call does bulk-copy + per-axis dispatch + sequencer drive ``` This eliminates the divergence between "what retail does on UM" and "what acdream does on UM" and removes the need for the heavy ad-hoc logic at GameWindow.cs:2774–3300. --- ## 20. Cross-references - Retail decomp source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` - Companion docs: - `02-um-handling.md` — UM intake top-level chain - `03-up-routing.md` — UpdatePosition (0xF748) — separate path - `04-interp-manager.md` — MovementManager + MoveToManager - `06-acdream-audit.md` — full acdream audit - Acdream code: - `src/AcDream.Core/Physics/MotionInterpreter.cs` - `src/AcDream.App/Rendering/GameWindow.cs::OnLiveMotionUpdated` (l.2579-3300) - `src/AcDream.Core/Physics/AnimationCommandRouter.cs` (overlay channel routing) - Retail named symbols (all addresses 0x005xxxxx in acclient.exe v11.4186): - `CMotionInterp::PerformMovement` 0x00528e80 (306221) - `CMotionInterp::DoMotion` 0x00528d20 (306159) - `CMotionInterp::DoInterpretedMotion` 0x00528360 (305575) - `CMotionInterp::StopMotion` 0x00528530 (305674) - `CMotionInterp::StopInterpretedMotion` 0x00528470 (305635) - `CMotionInterp::StopCompletely` 0x00527e40 (305208) - `CMotionInterp::apply_current_movement` 0x00528870 (305838) - `CMotionInterp::apply_interpreted_movement` 0x00528600 (305713) - `CMotionInterp::apply_raw_movement` 0x005287e0 (305817) - `CMotionInterp::move_to_interpreted_state` 0x005289c0 (305936) - `CMotionInterp::adjust_motion` 0x00528010 (305343) - `CMotionInterp::apply_run_to_command` 0x00527be0 (305062) - `CMotionInterp::get_state_velocity` 0x00527d50 (305160) - `CMotionInterp::get_max_speed` 0x00527cb0 (305127) - `CMotionInterp::get_adjusted_max_speed` 0x00527d00 (305145) - `CMotionInterp::contact_allows_move` 0x00528240 (305471) - `CMotionInterp::enter_default_state` 0x00528c80 (306124) - `CMotionInterp::HandleExitWorld` 0x00527f30 (305275) - `MovementManager::PerformMovement` 0x005240d0 (300194) - `MovementManager::unpack_movement` 0x00524440 (300563) - `MovementManager::move_to_interpreted_state` 0x00524170 (300259) - `InterpretedMotionState::UnPack` 0x0051f400 (294360) - `InterpretedMotionState::ApplyMotion` 0x0051ea40 (293531) - `InterpretedMotionState::copy_movement_from` (293301-293311)