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