acdream/docs/research/2026-05-04-l3-port/11-um-dispatch-deep.md
Erik de129bc164 feat(motion): L.3 M1 — fresh InterpolationManager port + retail spec
Rewrites src/AcDream.Core/Physics/InterpolationManager.cs from the spec
in docs/research/2026-05-04-l3-port/04-interp-manager.md. Public API
preserved (Vector3-returning AdjustOffset, Enqueue, Clear, IsActive,
Count) so PositionManager + GameWindow callers continue to compile;
internals are full retail spec.

Bug fixes vs prior port (audit 04-interp-manager.md § 7):

  #1  progress_quantum accumulates dt (sum of frame deltas), not step
      magnitude. Retail line 353140; the prior port's `+= step` made
      the secondary stall ratio meaningless.

  #3  Far-branch Enqueue (dist > AutonomyBlipDistance = 100m) sets
      _failCount = StallFailCountThreshold + 1 = 4, so the next
      AdjustOffset call's post-stall check fires an immediate blip-to-
      tail snap. Retail line 352944. Prior port silently drifted
      toward far targets at catch-up speed instead of teleporting.

  #4  Secondary stall test ports the retail formula verbatim:
      cumulative / progress_quantum / dt < CREATURE_FAILED_INTERPOLATION_PERCENTAGE.
      Audit notes the units are 1/sec (likely Turbine bug or x87 FPU
      misread by Binary Ninja) — mirrored byte-for-byte regardless.

  #5  Tail-prune is a tail-walking loop, not a single-tail compare.
      Multiple consecutive stale tail entries within DesiredDistance
      (0.05 m) of the new target collapse together. Retail line 352977.

  #6  Cap-eviction at the HEAD when count reaches 20 (already correct
      in the prior port; verified).

New API: Enqueue gains an optional `currentBodyPosition` parameter so
the far-branch detection can reference the body when the queue is
empty. Backward-compatible (default null = pre-far-branch behavior).

UseTime collapsed into AdjustOffset's tail (post-stall blip check)
since acdream has no per-tick UseTime call separate from
adjust_offset; identical semantic outcome.

State fields renamed to retail names with sentinel values:
  _frameCounter, _progressQuantum, _originalDistance (init = 999999f
  sentinel per retail line 0x00555D30 ctor), _failCount.

Tests:
- 17/17 InterpolationManagerTests green.
- New test Enqueue_FarBranch_PrearmsImmediateBlipOnNextAdjustOffset
  pins the bug #3 fix: enqueueing 150 m away triggers a same-tick
  blip (delta length ≈ 150 m), and the queue clears.

Spec tree: 17 research docs (00–14) under docs/research/2026-05-04-l3-port/.
00-master-plan + 00-port-plan describe the 8-phase rollout. 01-per-tick,
03-up-routing, 04-interp-manager, 05-position-manager-and-partarray,
06-acdream-audit, 14-local-player-audit are the L.3 spec used by this
commit and the M2 follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:56:42 +02:00

1029 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# 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:** 306221306268.
```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 15 → `CMotionInterp`) and the
MoveTo manager (types 69 → `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:** 306159306217.
```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:** 305343305400.
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:** 305062305123.
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:** 305575305631.
```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:** 305713305788.
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:** 305838305857.
```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:** 305817305834.
```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:** 305635305670.
```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:** 305674305708.
```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:** 305208305234.
```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:** 305127305141.
```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:** 305145305156.
```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 |
| 0x800x800 | 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.28872892) 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 35
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:27743300.
---
## 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)