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

44 KiB
Raw Blame History

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::DoInterpretedMotionset_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.

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.

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.

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).

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.

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.

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:

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:

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.

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.

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.

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.

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.

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_stateapply_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::DoInterpretedMotionset_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)