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>
44 KiB
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:
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.apply_current_movement(cancelMoveTo=1, allowJump=…)(305838), which routes to eitherapply_raw_movement(autonomous local player) orapply_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.
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.
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.
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).
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:
DoInterpretedMotion(current_style, default_params)— applies stance (Combat/Peace/Magic/etc.).- If airborne:
DoInterpretedMotion(Falling)— forward axis is skipped, body animates as Falling. - Else if longjump:
DoInterpretedMotion(Ready)thenStopInterpretedMotion(SideStepRight)— forward pinned, sidestep killed. - Else (normal grounded path):
a.
DoInterpretedMotion(forward_command, params{speed=fwd_speed})b. Ifsidestep_command == 0:StopInterpretedMotion(SideStepRight)Else:DoInterpretedMotion(sidestep_command, params{speed=side_speed}) - If
turn_command != 0:DoInterpretedMotion(turn_command, params{speed=turn_speed})and RETURN. - If
turn_command == 0:CPhysicsObj::StopInterpretedMotion(TurnRight, default_params)directly on physics_obj (skipping the CMotionInterp wrapper), plusadd_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.
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.
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: 305635–305670.
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.
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.
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.
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.
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 insideInterpretedMotionState(5-bit action count starting at flag 0x80). Each action is dispatched inmove_to_interpreted_state's for-loop viaDoInterpretedMotion(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_movementandcancel_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:
- STYLE (
current_style— stance change) - FORWARD (or Falling / Ready+SideStop)
- SIDESTEP (or explicit Stop)
- 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_movefor Action commands always returns 1 (handled by(motion & 0x10000000) == 0check in the false-branch — Action airborne returns YouCantJumpWhileInTheAir).- Action commands have no
get_state_velocitycontribution (the switch inget_state_velocityonly 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
Stancebeing 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_movementonce, which only does ONEset_local_velocitycall.
- Sidestep: directly calls
- ❌ Single
set_local_velocityper UM rather than per-axis. CRITICAL. Effect: after-Forward velocity overwrites after-Sidestep velocity rather than building up. (Mitigated byget_state_velocityreading ALL three axis fields in one call — so the final velocity is correct. But the per-axis sequencer cycle slots may not all update becauseMotionInterpreter.apply_current_movementdoesn'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 callsStopInterpretedMotion(SideStepRight)ANDStopInterpretedMotion(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
remoteIsAirbornechecks scattered throughOnLiveMotionUpdated, NOT inapply_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 chain03-up-routing.md— UpdatePosition (0xF748) — separate path04-interp-manager.md— MovementManager + MoveToManager06-acdream-audit.md— full acdream audit
- Acdream code:
src/AcDream.Core/Physics/MotionInterpreter.cssrc/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::PerformMovement0x00528e80 (306221)CMotionInterp::DoMotion0x00528d20 (306159)CMotionInterp::DoInterpretedMotion0x00528360 (305575)CMotionInterp::StopMotion0x00528530 (305674)CMotionInterp::StopInterpretedMotion0x00528470 (305635)CMotionInterp::StopCompletely0x00527e40 (305208)CMotionInterp::apply_current_movement0x00528870 (305838)CMotionInterp::apply_interpreted_movement0x00528600 (305713)CMotionInterp::apply_raw_movement0x005287e0 (305817)CMotionInterp::move_to_interpreted_state0x005289c0 (305936)CMotionInterp::adjust_motion0x00528010 (305343)CMotionInterp::apply_run_to_command0x00527be0 (305062)CMotionInterp::get_state_velocity0x00527d50 (305160)CMotionInterp::get_max_speed0x00527cb0 (305127)CMotionInterp::get_adjusted_max_speed0x00527d00 (305145)CMotionInterp::contact_allows_move0x00528240 (305471)CMotionInterp::enter_default_state0x00528c80 (306124)CMotionInterp::HandleExitWorld0x00527f30 (305275)MovementManager::PerformMovement0x005240d0 (300194)MovementManager::unpack_movement0x00524440 (300563)MovementManager::move_to_interpreted_state0x00524170 (300259)InterpretedMotionState::UnPack0x0051f400 (294360)InterpretedMotionState::ApplyMotion0x0051ea40 (293531)InterpretedMotionState::copy_movement_from(293301-293311)