acdream/docs/research/2026-05-04-l3-port/02-um-handling.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

1206 lines
48 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# L.3 port — UpdateMotion (0xF74C) handling pipeline
**Source:** `docs/research/named-retail/acclient_2013_pseudo_c.txt`
(Sept 2013 EoR build, 18,366 named functions). All line numbers are
into that file unless otherwise noted.
This document traces the full inbound path from the wire (the 0xF74C
packet hitting the network layer) down to body velocity and animation
state. It also covers the OUTBOUND path the local player uses (so we
know what acdream's `+Acdream` sends, and what an observing retail
client receives via the SAME entry point but for a different guid).
---
## 0. Top-level flow (one-line summary)
```
0xF74C wire packet
└── CM_Physics::DispatchSB_* (357214) — picks branch by opcode
└── CPhysics::SetObjectMovement (271370) — staleness check, exit-29-style timestamps
└── CPhysicsObj::unpack_movement (280179) — lazy-creates MovementManager
└── MovementManager::unpack_movement (300563) — reads MovementType byte + params
├── case 0 (RawCommand) → move_to_interpreted_state
├── case 6 (MoveToObject) → MoveToManager.MoveToObject
├── case 7 (MoveToPosition) → MoveToManager.MoveToPosition
├── case 8 (TurnToObject) → MoveToManager.TurnToObject
└── case 9 (TurnToHeading) → MoveToManager.TurnToHeading
InterpretedMotionState delivered by case 0 then drives:
CMotionInterp::move_to_interpreted_state (305936)
↓ copy_movement_from + apply_current_movement
↓ apply_interpreted_movement (305713) — re-runs DoInterpretedMotion for each axis
↓ DoInterpretedMotion → contact_allows_move? + ApplyMotion → add_to_queue
↓ get_state_velocity (305160) → CPhysicsObj.set_local_velocity
```
Outbound (when local player presses W or releases W):
```
W press (or release)
└── CommandInterpreter::SendMovementEvent (700274)
└── MoveToStatePack ctor (RawMotionState snapshot of player)
└── ACCmdInterp::SendMoveToStateEvent → 0xF61C MoveToState packet
```
That single 0xF61C goes to the server. ACE relays the player's wire
state to nearby observers as 0xF74C UpdateMotion. So the observer
side is the same code path described in §1§7 below, just with the
remote player's guid.
---
## 1. The 0xF74C dispatcher
**Function:** `CM_Physics::DispatchSB_<...>` — opcode switch.
**Address:** `0x005595d0` (containing line 357211 reference).
Verbatim retail (357211357240):
```c
00559605 }
005595ff }
005595ff else if (((char*)ecx - 0xf74c) <= 0x8f)
005597b8 switch (ecx)
005597b8 {
00559850 case 0xf74c:
00559850 {
00559850 uint32_t ebp_2 = *(uint32_t*)(buf_ + 4); // object guid
00559852 class CObjectMaint* m_pObjMaint = this->m_pObjMaint;
0055985c arg2 = &buf_[8]; // payload start
00559860 class CPhysicsObj* eax_25 = CObjectMaint::GetObjectA(m_pObjMaint, ebp_2);
00559867 class NetBlob* eax_26 = arg2;
0055986d uint16_t ecx_15 = eax_26->vtable; // instance_timestamp
00559875 arg2 = (&eax_26->vtable + 2); // skip 2 bytes
00559875
0055987d if ((eax_25 != 0 && CPhysicsObj::is_newer(eax_25->update_times[8], ecx_15) == 0))
0055987d {
00559896 int32_t eax_29;
00559896 eax_29 = eax_25->update_times[8];
005598a2 if (eax_29 != ecx_15)
005598e8 return 2; // STALE → drop
005598bc if (CPhysics::SetObjectMovement(this->physics, eax_25, arg2, bufSize_) != 0)
005598be this->cmdinterp->vtable->LoseControlToServer();
005598d7 return 1;
0055987d }
0055987d
005598ef SmartBox::QueueBlobForObject(this, ebp_2, ebx); // entity not yet known → queue
00559902 return 4;
```
**Behavior:**
1. Read object guid from offset 4.
2. Read 2-byte `instance_timestamp` from payload start (offset 8).
3. Look up the entity. If we don't know it, queue the blob and return.
4. If `update_times[8]` (last seen instance ts) is newer than the
wire's, **drop the packet (return 2)**. This is the staleness gate.
5. Otherwise hand off the rest of the payload to
`CPhysics::SetObjectMovement`.
---
## 2. CPhysics::SetObjectMovement
**Function:** `CPhysics::SetObjectMovement`
**Address:** `0x00509690`
**Lines:** 271370271431.
```c
00509690 int32_t __stdcall CPhysics::SetObjectMovement(class CPhysics* this @ ecx,
class CPhysicsObj* arg2, // entity
void* arg3, // payload pointer
uint32_t arg4, // remaining size
uint16_t arg5, // instance_timestamp (already read)
uint16_t arg6, // server_control_timestamp
int32_t arg7) // forceTeleport flag
{
int32_t ebx = 0;
if (weenie_obj != 0)
ebx = weenie_obj->vtable->IsThePlayer(); // is this the local player?
weenie_obj = arg2->update_times[1]; // last instance_timestamp
int32_t edi = arg5;
// ... unsigned 16-bit "is wire newer?" comparison via 0x7fff-wrap ...
if (-((eax_7 - eax_7)) != 0) // i.e. newer
{
arg2->update_times[1] = edi; // record new instance ts
weenie_obj = arg2->update_times[5]; // last server_control_ts
edi = arg6;
// ... same wrap compare on server_control_ts ...
if (-((eax_14 - eax_14)) != 0)
return 0; // stale on server_control_ts
arg2->update_times[5] = edi;
if ((arg7 == 0 || ebx == 0)) // not "force teleport on player"
{
arg2->last_move_was_autonomous = arg7;
CPhysicsObj::unpack_movement(arg2, &arg3, arg4);
if (ebx != 0) return 1; // local player echo: ask cmdinterp to LoseControl
}
}
return 0;
}
```
**Key behaviors:**
- Two timestamp gates (instance_ts and server_control_ts) before any
state change — both use 16-bit wrap-aware ordering.
- For the **local player** (`IsThePlayer != 0`), if the timestamps are
newer AND `forceTeleport == 0`, returns 1 — the dispatcher then
calls `LoseControlToServer()`. This is how a server overrides the
local player's prediction (e.g. teleport, frozen, etc).
- For everyone else (remote players, NPCs, monsters), returns 0 and
proceeds to `unpack_movement`.
---
## 3. CPhysicsObj::unpack_movement — lazy creates MovementManager
**Function:** `CPhysicsObj::unpack_movement`
**Address:** `0x00512040`
**Lines:** 280179280203.
```c
00512040 void __thiscall CPhysicsObj::unpack_movement(this, arg2, arg3) {
if (this->movement_manager == 0) {
this->movement_manager = MovementManager::Create(this, this->weenie_obj);
// first creation also touches transient_state (sets bit 0x80)
}
MovementManager::unpack_movement(this->movement_manager, arg2, arg3);
}
```
Pure dispatch. The interesting work happens in MovementManager.
---
## 4. MovementManager::unpack_movement — the actual wire reader
**Function:** `MovementManager::unpack_movement`
**Address:** `0x00524440`
**Lines:** 300563300704.
This is the **real entry point for UM payload parsing**. It reads a
2-byte `MovementType` discriminator and a 2-byte initial style, then
branches.
Verbatim core (300563300668):
```c
00524440 int32_t __thiscall MovementManager::unpack_movement(this, arg2, arg3)
{
if (this->motion_interpreter != 0)
{
if (physics_obj != 0)
{
CPhysicsObj::interrupt_current_movement(physics_obj);
CPhysicsObj::unstick_from_object(this->physics_obj);
// ... Frame::cache(local_origin) ...
// var_9c = MovementParameters() with defaults
// var_28 = InterpretedMotionState() with defaults
void* eax_1 = *arg2;
int16_t ecx_4 = *(uint16_t*)eax_1; // (a) movement_type byte
*arg2 = eax_1 + 2;
uint32_t ebp_1 = (uint32_t)ecx_4; // movement_type
ecx_4 = *(uint16_t*)(eax_1 + 2); // (b) style 16-bit MotionCommand low
*arg2 = eax_1 + 4;
uint32_t ecx_5 = command_ids[(uint32_t)ecx_4]; // expand to full uint32 cmd
// If the new style differs from current, fire DoMotion(style, default_params)
// — that switches the body's currentStyle (combat→peace etc).
if (CBaseFilter::GetPinVersion(this->motion_interpreter) != ecx_5)
CMotionInterp::DoMotion(this->motion_interpreter, ecx_5, &var_9c);
switch (ebp_1) {
case 0: // RawCommand (the bulk of UMs)
InterpretedMotionState::UnPack(&var_28, arg2, arg3);
uint32_t ebx_3 = 0;
if ((var_a4_1 & 0x100) != 0) // bit indicates "stick to object" guid present
{
uint32_t* eax_8 = *arg2;
ebx_3 = *eax_8; // guid to stick to
*arg2 = &eax_8[1];
}
MovementManager::move_to_interpreted_state(this, &var_28);
if (ebx_3 != 0)
CPhysicsObj::stick_to_object(this->physics_obj, ebx_3);
this->motion_interpreter->standing_longjump = (ebp_1 & 0x200);
return 1;
case 6: /* MoveToObject — guid + Position + MovementParameters + runRate */
case 7: /* MoveToPosition — Position + MovementParameters + runRate */
case 8: /* TurnToObject — guid + heading + MovementParameters */
case 9: /* TurnToHeading — MovementParameters */
// each delegates to MoveToManager::* (out of scope here)
}
}
}
return 0;
}
```
**Reads from wire (case 0 only):**
- 2 bytes — `movement_type` (0=RawCommand, 6/7/8/9=MoveTo variants).
- 2 bytes — initial currentStyle (16-bit MotionCommand low → expanded
to full uint32 via `command_ids[]` lookup).
- `InterpretedMotionState::UnPack` (see §5) consumes the rest.
**Writes to MotionInterp:**
- If the new style differs, `DoMotion(style, default_params)` runs
immediately — this is how stance changes (Combat ↔ Peace) occur.
- Then `move_to_interpreted_state` bulk-applies the unpacked state
(see §7).
- `standing_longjump` flag set from the high bit (0x200) of
movement_type.
**Note:** Type 0 is what the ACE relay produces for nearly every
locomotion event. Types 69 are server-controlled MoveTo's (e.g.
"NPC walks to point X"). The MoveTo branches end up calling
`MoveToManager::MoveToObject`/`...Position`/`TurnToHeading` which is
its own state machine — out of scope for this doc.
---
## 5. InterpretedMotionState::UnPack — flag-driven field reader
**Function:** `InterpretedMotionState::UnPack`
**Address:** `0x0051f400`
**Lines:** 294360294523.
This reads a single `uint32_t` flag word and conditionally unpacks 13
fields. **This is exactly the format ACE writes when relaying.**
Verbatim core (294360294492):
```c
0051f400 int32_t __thiscall InterpretedMotionState::UnPack(this, arg2, arg3)
{
InterpretedMotionState::Destroy(this); // clear actions list
uint32_t edx;
if (arg3 < 4) edx = arg3;
else {
edx = *(uint32_t*)(*arg2); // FLAGS uint32
*arg2 += 4;
}
if ((edx & 0x01) == 0) this->current_style = 0x8000003d; // NonCombat
else { read uint16, expand via command_ids[] current_style }
if ((edx & 0x02) == 0) this->forward_command = 0x41000003; // Ready
else { read uint16, expand forward_command }
if ((edx & 0x08) == 0) this->sidestep_command = 0;
else { read uint16, expand sidestep_command }
if ((edx & 0x20) == 0) this->turn_command = 0;
else { read uint16, expand turn_command }
if ((edx & 0x04) == 0) this->forward_speed = 1.0f;
else { this->forward_speed = *(float*)(*arg2); *arg2 += 4; }
if ((edx & 0x10) == 0) this->sidestep_speed = 1.0f;
else { this->sidestep_speed = *(float*)(*arg2); *arg2 += 4; }
if ((edx & 0x40) == 0) this->turn_speed = 1.0f;
else { this->turn_speed = *(float*)(*arg2); *arg2 += 4; }
int32_t i_4 = (edx >> 7) & 0x1f; // action count (5 bits)
while (i_4-- > 0) {
// each action: uint16 motion → command_ids[], uint32 speed,
// uint16 stamp+autonomous bit (0x7fff stamp; 0x8000 autonomous)
InterpretedMotionState::AddAction(this, motion, speed, stamp, autonomous);
}
align_ptr_to_4();
return 1;
}
```
**Key facts (this is THE definitive flag layout):**
| Bit | Field | When CLEAR |
|----|----|----|
| 0x01 | current_style | defaults to NonCombat (0x8000003d) |
| 0x02 | forward_command | defaults to Ready (0x41000003) |
| 0x04 | forward_speed | defaults to 1.0f |
| 0x08 | sidestep_command | defaults to 0 |
| 0x10 | sidestep_speed | defaults to 1.0f |
| 0x20 | turn_command | defaults to 0 |
| 0x40 | turn_speed | defaults to 1.0f |
| 0x800x800 | action count (5 bits) | 0 |
**Crucial corollary for the L.3 port:** when ACE omits a field on the
wire (e.g. doesn't set bit 0x02 because forward_command was
"Invalid" — its idle), the decompiled UnPack DEFAULTS that field to
the table-default value (Ready / 0 / 1.0f). This is **NOT** "preserve
previous." It's "reset to the per-axis default." That's why a stop
broadcast looks like an UM with all command bits cleared.
The wire's `forward_command = 0` (clear bit 0x02) IS the stop signal.
The unpacker maps it to Ready.
---
## 6. command_ids[] — 16-bit → 32-bit motion expansion
`command_ids[]` is a static lookup table that takes the 16-bit
MotionCommand low word and returns the full uint32 (with class byte
reattached). This is how a wire `0x0007` (RunForward low) becomes
`0x44000007` (RunForward full). acdream's
`MotionCommandResolver.ReconstructFullCommand` is the equivalent.
---
## 7. CMotionInterp::move_to_interpreted_state
**Function:** `CMotionInterp::move_to_interpreted_state`
**Address:** `0x005289c0`
**Lines:** 305936305992.
Verbatim:
```c
005289c0 int32_t __thiscall CMotionInterp::move_to_interpreted_state(this, arg2)
{
if (physics_obj == 0) return 0;
this->raw_state.current_style = arg2->current_style;
CPhysicsObj::interrupt_current_movement(physics_obj);
uint32_t eax_2 = motion_allows_jump(this, this->interpreted_state.forward_command);
int32_t esi_1 = -eax_2;
InterpretedMotionState::copy_movement_from(&this->interpreted_state, arg2); // ← bulk copy
apply_current_movement(this, 1, -((esi_1 - esi_1))); // cancelMoveTo=1, allowJump=stillAllowed
MovementParameters var_2c;
MovementParameters::MovementParameters(&var_2c);
for (LListData* i = arg2->actions.head_; i != 0; i = i->llist_next)
{
// 15-bit action stamp comparison (wrap-aware via 0x7fff)
int32_t actStamp = *(int32_t*)((char*)i + 0xc) & 0x7fff;
int32_t serverStamp = this->server_action_stamp & 0x7fff;
int32_t delta = abs(actStamp - serverStamp);
bool isNewer = (delta <= 0x3fff) ? (serverStamp < actStamp) : (actStamp < serverStamp);
if (isNewer)
{
// gate: only fire actions that came from the network (autonomous=0)
// when this is a player; for NPCs always fire.
if (weenie_obj == 0 || weenie_obj->vtable->IsThePlayer() == 0
|| *(int32_t*)((char*)i + 0x10) == 0 /*autonomous bit*/)
{
this->server_action_stamp = *(int32_t*)((char*)i + 0xc);
var_2c.action_stamp = *(int32_t*)((char*)i + 8);
// var_28 |= 0x1000 = ModifyInterpretedState
CMotionInterp::DoInterpretedMotion(this, *(int32_t*)((char*)i + 4), &var_2c);
}
}
}
return 1;
}
```
**Critical facts:**
1. **`copy_movement_from` is UNCONDITIONAL bulk copy** (lines
293301293311) — every field of InterpretedState is overwritten:
`current_style`, `forward_command`, `forward_speed`,
`sidestep_command`, `sidestep_speed`, `turn_command`, `turn_speed`.
No filter by stance change, no diff, no per-axis gate. Whatever
the wire said (post-defaults from UnPack) is now the body's state.
2. After the copy, **`apply_current_movement(cancelMoveTo=true, allowJump)`**
re-runs the full state machine. This is where the body's velocity
gets re-derived from the new InterpretedState (see §8).
3. The actions list is iterated separately with stamp-wrap protection
so we don't replay actions we already saw, and we skip player-self
echoes that we ourselves originated (autonomous=true).
---
## 8. apply_current_movement → apply_interpreted_movement
**Function:** `CMotionInterp::apply_interpreted_movement`
**Address:** `0x00528600`
**Lines:** 305713305788.
```c
00528600 void apply_interpreted_movement(this, arg2 /*cancelMoveTo*/, arg3 /*allowJump*/)
{
if (physics_obj != 0)
{
MovementParameters var_2c;
MovementParameters::MovementParameters(&var_2c);
// If forward is RunForward, cache the speed as MyRunRate
if (this->interpreted_state.forward_command == 0x44000007)
this->my_run_rate = this->interpreted_state.forward_speed;
// Re-fire DoInterpretedMotion(currentStyle) — re-applies stance
DoInterpretedMotion(this, this->interpreted_state.current_style, &var_2c);
if (contact_allows_move(this->interpreted_state.forward_command) == 0)
{
// Body is airborne / dead — force Falling
DoInterpretedMotion(this, 0x40000015 /*Falling*/, &var_2c);
}
else
{
if (this->standing_longjump != 0)
{
DoInterpretedMotion(this, 0x41000003 /*Ready*/, &var_2c);
StopInterpretedMotion(this, 0x6500000f /*SideStepRight*/, &var_2c);
}
else
{
// FORWARD axis
var_2c.speed = this->interpreted_state.forward_speed;
DoInterpretedMotion(this, this->interpreted_state.forward_command, &var_2c);
// SIDESTEP axis
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);
}
}
}
// TURN axis
if (this->interpreted_state.turn_command != 0) {
var_2c.speed = this->interpreted_state.turn_speed;
DoInterpretedMotion(this, this->interpreted_state.turn_command, &var_2c);
return;
}
// No turn — explicit stop
uint32_t eax_10 = CPhysicsObj::StopInterpretedMotion(physics_obj, 0x6500000d /*TurnRight*/, &var_2c);
if (eax_10 == 0) {
add_to_queue(this, var_c, 0x41000003, 0);
// remove TurnRight from action queue
}
}
}
```
**This is the per-tick re-apply.** Every time the wire delivers a new
state (or we land, or we leave ground), this function fires
`DoInterpretedMotion` for each axis (forward, sidestep, turn) so the
physics body re-derives velocity. Velocity comes from
`get_state_velocity` which lives inside `DoInterpretedMotion`
`CPhysicsObj::DoInterpretedMotion` (not shown in detail here, but
the chain runs through `set_local_velocity`).
---
## 9. CMotionInterp::DoMotion (raw command path)
**Function:** `CMotionInterp::DoMotion`
**Address:** `0x00528d20`
**Lines:** 306159306217.
```c
00528d20 uint32_t DoMotion(this, arg2 /*motion*/, arg3 /*MovementParameters*/)
{
if (physics_obj == 0) return 8;
uint32_t ebp = arg2;
// ... copy struct fields locally ...
if (params->__inner0.byte1 < 0) // CancelMoveTo bit
CPhysicsObj::interrupt_current_movement(physics_obj);
if ((params->__inner0.byte1 & 8) != 0) // SetHoldKey bit
SetHoldKey(this, params->hold_key_to_apply, ((__inner0 >> 0xf) & 1));
adjust_motion(this, &arg2, &speed, params->hold_key_to_apply);
if (this->interpreted_state.current_style != 0x8000003d /*NonCombat*/) {
if (ebp == 0x41000012 /*Crouch*/) return 0x3f; // CantCrouchInCombat
if (ebp == 0x41000013 /*Sit*/) return 0x40;
if (ebp == 0x41000014 /*Sleep*/) return 0x41;
if ((ebp & 0x2000000) != 0) return 0x42; // CantChatEmoteInCombat
}
if ((ebp & 0x10000000 /*Action*/) != 0
&& InterpretedMotionState::GetNumActions(&this->interpreted_state) >= 6)
return 0x45; // TooManyActions
uint32_t result = DoInterpretedMotion(this, arg2, &var_2c);
if (result == 0 && (params->__inner0.byte1 & 0x20 /*ModifyRawState*/) != 0)
RawMotionState::ApplyMotion(&this->raw_state, ebp, arg3);
return result;
}
```
**Behavior:**
- `adjust_motion` (see §10) folds HoldKey + sign-flipped commands.
- Combat-style guards reject Crouch/Sit/Sleep/ChatEmote.
- Action-class commands are queued, max 6 outstanding.
- All work delegates to `DoInterpretedMotion` (§11).
- If caller asked, the raw state is also updated via
`RawMotionState::ApplyMotion`.
---
## 10. CMotionInterp::adjust_motion
**Function:** `CMotionInterp::adjust_motion`
**Address:** `0x00528010`
**Lines:** 305343305400.
This is **the canonical sign-flipping / hold-key application function**.
Verbatim core:
```c
00528010 void adjust_motion(this, uint32_t* motion, float* speed, HoldKey holdKey)
{
if (weenie_obj != 0 && weenie_obj->IsCreature() == 0) return;
uint32_t cmd = *motion;
if (cmd == 0x6500000e /*SideStepLeft*/) {
*motion = 0x6500000f /*SideStepRight*/;
*speed *= -1.0f;
}
else if (cmd == 0x65000010 /*TurnLeft???*/) { // really an alias
*motion = 0x6500000f;
*speed *= -1.0f;
}
else if (cmd == 0x45000006 /*WalkBackward*/) {
*motion = 0x45000005 /*WalkForward*/;
*speed *= -0.65f; // BackwardsFactor
}
// (RunForward 0x44000007 falls through unchanged)
// Sidestep gets its own scale factor
if (*motion == 0x6500000f /*SideStepRight*/) {
*speed = (3.12f / 1.25f) * 0.5f * (*speed); // = 1.248
}
if (holdKey == HoldKey_Invalid)
holdKey = this->raw_state.current_holdkey;
if (holdKey == HoldKey_Run)
apply_run_to_command(this, motion, speed);
}
```
**Mappings produced by adjust_motion:**
| Input motion | Input speed | Output motion | Output speed |
|---|---|---|---|
| WalkForward | s | WalkForward | s |
| WalkBackward | s | WalkForward | -0.65 × s |
| TurnLeft | s | TurnRight | -s |
| SideStepLeft | s | SideStepRight | -s |
| SideStepRight (final) | s | SideStepRight | 1.248 × s |
| RunForward | s | RunForward | s |
Then if HoldKey == Run, `apply_run_to_command` fires.
---
## 11. CMotionInterp::apply_run_to_command
**Function:** `CMotionInterp::apply_run_to_command`
**Address:** `0x00527be0`
**Lines:** 305062305123.
```c
00527be0 void apply_run_to_command(this, uint32_t* motion, float* speed)
{
long double speedMod;
if (weenie_obj != 0) {
if (weenie_obj->InqRunRate(&speedMod) != 0) {
// speedMod taken from weenie InqRunRate output
} else {
speedMod = (long double)this->my_run_rate;
}
} else {
speedMod = 1.0L;
}
uint32_t cmd = *motion;
if (cmd == 0x45000005 /*WalkForward*/) {
if (*speed > 0.0f)
*motion = 0x44000007 /*RunForward*/; // PROMOTION
*speed = (float)(speedMod * (*speed));
return;
}
if (cmd == 0x6500000d /*TurnRight*/) {
*speed = (float)(1.5f * (*speed)); // RunTurnFactor
return;
}
if (cmd == 0x6500000f /*SideStepRight*/) {
speedMod *= (long double)*speed;
*speed = (float)speedMod;
if (fabsl(speedMod) > 3.0L) { // MaxSidestepAnimRate
*speed = (speedMod > 0) ? 3.0f : -3.0f;
}
}
}
```
**Critical asymmetry — the speed > 0.0 gate:**
```c
if (*speed > 0.0f)
*motion = 0x44000007 /*RunForward*/;
```
This is the line that prevents `WalkBackward + HoldKey.Run` from
becoming `RunBackward`. After `adjust_motion` flips WalkBackward →
WalkForward with negative speed, this gate keeps the motion as
WalkForward (because speed ≤ 0) and the speed multiplication still
applies the runRate.
So sign-flipped backward arrives at `get_state_velocity` as:
- `forward_command = WalkForward (0x45000005)`
- `forward_speed = -0.65 × runRate` (negative)
Then `get_state_velocity` (next section) hits the WalkForward branch
and produces a NEGATIVE `velocity.Y` — the body moves backward at
walk-pace × 65% × runRate.
---
## 12. CMotionInterp::get_state_velocity
**Function:** `CMotionInterp::get_state_velocity`
**Address:** `0x00527d50`
**Lines:** 305160305204.
```c
00527d50 void get_state_velocity(this, AC1Legacy::Vector3* out)
{
long double vx;
if (this->interpreted_state.sidestep_command != 0x6500000f)
vx = 0.0L;
else
vx = 1.25L * (long double)this->interpreted_state.sidestep_speed;
out->x = (float)vx;
long double vy;
uint32_t fwd = this->interpreted_state.forward_command;
if (fwd == 0x45000005 /*WalkForward*/)
vy = 3.12L * (long double)this->interpreted_state.forward_speed;
else if (fwd == 0x44000007 /*RunForward*/)
vy = 4.0L * (long double)this->interpreted_state.forward_speed;
else
vy = 0.0L;
out->y = (float)vy;
out->z = 0.0f;
// Cap to maxSpeed = 4.0 * runRate
long double rate = this->my_run_rate; /* or InqRunRate */
long double len = sqrtl(vx*vx + vy*vy + 0.0L);
if (len > 4.0L * rate) {
long double scale = (4.0L * rate) / len;
out->x *= (float)scale;
out->y *= (float)scale;
}
}
```
**Hard-coded constants (these match ACE 1:1):**
- `WalkAnimSpeed = 3.12 m/s`
- `RunAnimSpeed = 4.0 m/s`
- `SidestepAnimSpeed = 1.25 m/s`
- `MaxSidestepAnimRate = 3.0` (clamp inside apply_run_to_command)
- `BackwardsFactor = 0.65`
- `RunTurnFactor = 1.5`
Velocity output is body-local: X = strafe (right positive),
Y = forward (forward positive), Z = 0. Z gets composed by gravity /
LeaveGround in CPhysicsObj.
---
## 13. CMotionInterp::contact_allows_move
**Function:** `CMotionInterp::contact_allows_move`
**Address:** `0x00528240`
**Lines:** 305471305505.
```c
00528240 int32_t contact_allows_move(this, uint32_t motion)
{
if (physics_obj != 0) {
if (motion > 0x40000015) {
if (motion >= 0x6500000d && motion <= 0x6500000e) // TurnRight..TurnLeft
return 1; // turns always allowed
} else if (motion == 0x40000015 /*Falling*/ || motion == 0x40000011 /*Dead*/) {
return 1;
}
if (weenie_obj != 0 && weenie_obj->IsCreature() == 0) // non-creatures (chess pieces, etc)
return 1;
if (physics_obj == 0 || (physics_obj->state & 0x4 /*Gravity*/) == 0)
return 1; // no gravity → always
uint8_t ts = physics_obj->transient_state;
if ((ts & 1 /*Contact*/) != 0 && (ts & 2 /*OnWalkable*/) != 0)
return 1; // grounded
}
return 0; // airborne creature on gravity-affected body
}
```
Used by `DoInterpretedMotion` to decide whether a motion can take
effect right now or must be deferred.
---
## 14. CMotionInterp::DoInterpretedMotion (the leaf)
**Function:** `CMotionInterp::DoInterpretedMotion`
**Address:** `0x00528360`
**Lines:** 305575305631.
```c
00528360 uint32_t DoInterpretedMotion(this, motion, params)
{
if (physics_obj == 0) return 8;
uint32_t result;
if (contact_allows_move(this, motion) != 0) {
if (this->standing_longjump != 0
&& (motion == 0x45000005 /*Walk*/ || motion == 0x44000007 /*Run*/ || motion == 0x6500000f /*SideStep*/))
{
// skip the 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);
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 /*Action*/) == 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 /*ModifyInterpretedState*/)
InterpretedMotionState::ApplyMotion(&this->interpreted_state, motion, params);
}
}
}
else if ((motion & 0x10000000 /*Action*/) == 0) {
if (params->__inner0.byte1 & 0x40 /*ModifyInterpretedState*/)
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). Note:
- The actual physics velocity push is inside
`CPhysicsObj::DoInterpretedMotion` — that's where
`set_local_velocity(get_state_velocity(...))` happens.
- `InterpretedMotionState::ApplyMotion` updates the InterpretedState
ONLY when the params flag 0x40 (ModifyInterpretedState) is set.
In `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.
---
## 15. InterpretedMotionState::ApplyMotion
**Function:** `InterpretedMotionState::ApplyMotion`
**Address:** `0x0051ea40`
**Lines:** 293531293564.
```c
0051ea40 void ApplyMotion(this, uint32_t motion, params)
{
if (motion == 0x6500000d /*TurnRight*/) {
this->turn_command = 0x6500000d;
this->turn_speed = params->speed;
return;
}
if (motion == 0x6500000f /*SideStepRight*/) {
this->sidestep_command = 0x6500000f;
this->sidestep_speed = params->speed;
return;
}
if ((motion & 0x40000000) != 0) { // any 0x4xxxxxxx (Walk/Run/Stand/Falling/Dead/etc)
this->forward_command = motion;
this->forward_speed = params->speed;
return;
}
if (motion < 0) { // 0x8xxxxxxx — style change
this->forward_command = 0x41000003 /*Ready*/;
this->current_style = motion;
return;
}
if ((motion & 0x10000000 /*Action*/) != 0) {
AddAction(this, motion, params->speed, params->action_stamp,
(params->__inner0 >> 0xc) & 1 /*autonomous bit*/);
}
}
```
**This drives the state machine when the engine is *generating*
motion locally (e.g. local player, MoveTo manager).** It is NOT what
the wire-driven path uses — the wire path uses
`copy_movement_from` (§7).
This is the function ACE's `InterpretedMotionState.ApplyMotion`
mirrors verbatim.
---
## 16. Outbound: CommandInterpreter::SendMovementEvent
**Function:** `CommandInterpreter::SendMovementEvent`
**Address:** `0x006b4680`
**Lines:** 700274700312.
```c
006b4680 void SendMovementEvent(this)
{
CPhysicsObj* player = this->player;
if (player != 0 && this->smartbox != 0
&& CPhysicsObj::InqRawMotionState(player) != 0)
{
if (this->autonomy_level != 0) // CLIENT IS IN CONTROL
{
uint16_t instTs = player->update_times[8];
int32_t ctlTs = player->update_times[4];
int32_t teleTs = player->update_times[5];
int32_t forceTs= player->update_times[6];
CMotionInterp* mi = CPhysicsObj::get_minterp(player);
// contact = (Contact && OnWalkable)
int32_t contact = (player->transient_state & 1) && (player->transient_state & 2);
MoveToStatePack pkt;
MoveToStatePack::MoveToStatePack(&pkt,
CPhysicsObj::InqRawMotionState(player), // RAW state, not interpreted
&player->m_position,
contact,
mi->standing_longjump,
instTs, teleTs, ctlTs, forceTs);
this->vtable->SendMoveToStateEvent(&pkt); // → 0xF61C wire
this->last_sent_position_time = Timer::cur_time;
}
}
}
```
**Critical facts:**
- The **OUTBOUND packet (0xF61C MoveToState) uses the RawMotionState**,
not the InterpretedMotionState. RawState carries the player's
literal input (e.g. `forward_command = WalkForward`,
`forward_holdkey = Run`) — the server (or observer) does the
promotion via its own `apply_raw_movement``adjust_motion` chain.
- `RawMotionState::Pack` (`0x0051ed10`, lines 293761293980) packs a
flag word with bits matching different fields than the interpreted
one: 0x01=holdkey, 0x02=style, 0x04=fwd_cmd, 0x08=fwd_holdkey,
0x10=fwd_speed≠1.0, 0x20=side_cmd, 0x40=side_holdkey, 0x80=side_speed≠1,
0x100=turn_cmd, 0x200=turn_holdkey, 0x400=turn_speed≠1, then 5-bit
action count starting at 0x800.
This means the outbound flag layout is **different** from the
inbound InterpretedMotionState layout (different bit positions, plus
holdkey fields). When the local player presses W:
- `raw_state.forward_command = WalkForward`
- `raw_state.forward_holdkey = Run` (because shift not held)
- `raw_state.forward_speed = 1.0` (so flag 0x10 is CLEAR)
When the local player **releases W** (stops walking forward):
- `raw_state.forward_command = Ready (0x41000003)` — the Ready
default → **flag 0x04 is CLEARED**
- `raw_state.forward_speed = 1.0` → flag 0x10 CLEARED
- (HoldKey may still be Run from the toggle, so flag 0x08 may be set)
So a STOP is **the absence of forward_command on the wire**, plus
the absence of forward_speed. It's encoded as "both flag bits clear,
implicit defaults Ready/1.0."
---
## 17. SendDoMovementEvent — slash-command only
**Function:** `ACCmdInterp::SendDoMovementEvent`
**Address:** `0x0058b230`
**Lines:** 405442405455.
```c
0058b230 int32_t SendDoMovementEvent(this, motion, speed, holdKey) {
return CM_Movement::Event_DoMovementCommand(motion, speed, holdKey);
}
0058b250 int32_t SendStopMovementEvent(this, motion, holdKey) {
return CM_Movement::Event_StopMovementCommand(motion, holdKey);
}
```
These are the **single-action** outbound messages used by slash
commands and macros (e.g. `/say`, `/use`). The cdb live trace from
2026-05-01 confirmed `SendDoMovementEvent` is NOT in the WASD path —
WASD always goes through `SendMovementEvent` → MoveToState
(§16). The DoMovement / StopMovement events are for one-shot motion
commands only.
---
## Answers to the critical questions
### Q: When the local actor stops (releases W), what UM does retail SEND outbound?
**A retail-format 0xF61C MoveToState packet.** The packet's
RawMotionState has `forward_command = Ready (0x41000003)` and
`forward_speed = 1.0`. Both flag bits 0x04 and 0x10 in the
RawMotionState's flag word are CLEARED. HoldKey may remain set.
**There is no separate "stop motion" packet on the WASD path.** The
release of W simply produces another full MoveToState whose raw
state shows Ready+1.0. ACE's relay then re-emits this as a 0xF74C
UpdateMotion to nearby observers, with the InterpretedMotionState's
flag 0x02 cleared (no forward_command field).
### Q: When observer receives that UM, what does CMotionInterp::DoMotion do?
The observer's `MovementManager::unpack_movement` reads movement_type=0
(RawCommand), then `InterpretedMotionState::UnPack` runs (§5). With
the wire's flag 0x02 clear, **forward_command defaults to Ready**.
Flag 0x04 clear → forward_speed defaults to 1.0. Flag 0x08 clear →
sidestep_command = 0. Flag 0x20 clear → turn_command = 0.
Then `MovementManager::move_to_interpreted_state`
`CMotionInterp::move_to_interpreted_state` (§7) runs:
1. `InterpretedMotionState::copy_movement_from` bulk-copies the
defaults into the body's interpreted state.
2. `apply_current_movement``apply_interpreted_movement` (§8) fires
`DoInterpretedMotion(Ready, ...)` for forward,
`StopInterpretedMotion(SideStepRight)` for sidestep, and
`StopInterpretedMotion(TurnRight)` for turn.
3. `get_state_velocity` returns (0, 0, 0) because forward_command is
Ready (matches neither WalkForward nor RunForward).
4. `set_local_velocity(0, 0, 0)` — body stops moving.
**Note `DoMotion` itself is NOT called here.** The wire-driven
relay path uses `move_to_interpreted_state`, not `DoMotion`.
`DoMotion` is the LOCAL command path (e.g. `MoveToManager`,
slash commands, animation hooks).
### Q: Does retail observer also have a "stop signal" path via UpdatePosition (separate from UM)?
**No, not for the stop semantics.** UpdatePosition (0xF748) is for
position teleports / heartbeat re-syncs and goes through
`SmartBox::UnpackPositionEvent` (357185, line 357181 case 0xf748).
It does NOT touch InterpretedMotionState. Position can move the body
in space, but the locomotion command (Walk/Run/Ready) is purely
UM-driven.
That said, if a player is moving and stops, the next AutonomousPosition
(0xF749/0xF75A) heartbeat from the server will keep the position
matching, but it's the UM that delivers the Ready transition.
The local prediction layer (`SmartBox::QueueBlobForObject`) holds an
inbound UM if the entity is not yet known — but once known, every
inbound 0xF74C is processed by UnPack → move_to_interpreted_state in
order.
### Q: How does sign-flipped backward (WalkForward + ForwardSpeed = -1) get processed?
**Receiver side (UM observer / DoMotion local):**
1. `InterpretedMotionState::UnPack` reads
`forward_command = 0x45000005 (WalkForward)` and
`forward_speed = -1.0f` verbatim from the wire.
2. `move_to_interpreted_state``copy_movement_from` writes those
into InterpretedState unchanged.
3. `apply_interpreted_movement` calls
`DoInterpretedMotion(WalkForward, params{speed=-1.0})`.
4. `DoInterpretedMotion``CPhysicsObj::DoInterpretedMotion`
`get_state_velocity`:
- WalkForward branch hits, `velocity.Y = 3.12 × -1.0 = -3.12 m/s`.
5. `set_local_velocity` pushes a NEGATIVE Y velocity → body translates
backward in body-local frame.
Critically: `adjust_motion` is **NOT** called on the receive path for
sign-flipped backward. It was already called at the SENDER (typically
the originating client's local `DoMotion`). Once the wire has
`WalkForward + speed=-1.0`, that's the canonical form. ApplyMotion
and copy_movement_from simply copy it.
**On the SEND side**, the local `DoMotion(WalkBackward, +1.0)`:
1. `adjust_motion` flips: motion → WalkForward, speed → -0.65 ×
BackwardsFactor.
2. If HoldKey == Run, `apply_run_to_command` checks `speed > 0`
FALSE — so the motion stays WalkForward (no promotion to
RunForward), but speed gets multiplied by speedMod (runRate).
3. Final: motion=WalkForward, speed = -0.65 × runRate.
4. `RawMotionState::ApplyMotion` writes that back (when ModifyRawState
bit set), so the next outbound MoveToState carries
`forward_command=WalkForward, forward_speed=-0.65×runRate`.
### Q: What's the difference between apply_run_to_command and DoInterpretedMotion?
| Aspect | apply_run_to_command (305062) | DoInterpretedMotion (305575) |
|---|---|---|
| Purpose | **Modifier**: rewrite motion+speed for HoldKey.Run | **Action**: fire physics velocity push + queue + state update |
| Inputs | `motion*, speed*` (in/out), uses my_run_rate | `motion, MovementParameters` |
| Side effects | NONE (pure rewrite via ref params) | Updates InterpretedState (if flag set), enqueues motion, calls `CPhysicsObj::DoInterpretedMotion` (which calls `set_local_velocity`) |
| Promotes WalkForward → RunForward | YES (when speed > 0) | NO |
| Applies speedMod | YES (multiplies speed by runRate) | NO |
| Called from | `adjust_motion` (305388) when holdKey == Run | `DoMotion` (306211), `move_to_interpreted_state` (305983), `apply_interpreted_movement` (305744) |
`apply_run_to_command` is a **command-rewriter** that runs once at
input time. `DoInterpretedMotion` is the **executor** that fires
many times per UM (once per forward/sidestep/turn axis).
---
## Cross-reference with ACE's MotionInterp
ACE's `references/ACE/Source/ACE.Server/Physics/Animation/MotionInterp.cs`
mirrors retail with high fidelity:
| ACE method | Retail equivalent | Lines (retail) | Differences |
|---|---|---|---|
| `DoMotion` (l.112) | `CMotionInterp::DoMotion` | 306159 | Identical structure. Uses `ModifyRawState` flag from `MovementParameters`. |
| `DoInterpretedMotion` (l.51) | same name | 305575 | Identical. |
| `StopMotion` (l.367) | same | 305674 | Identical. |
| `StopInterpretedMotion` (l.329) | same | 305635 | Identical. |
| `StopCompletely` (l.301) | same | 305208 | Identical including `forward_command=Ready, speed=1, side=0, turn=0`. |
| `adjust_motion` (l.394) | same | 305343 | Identical. ACE uses `BackwardsFactor=-1` (?? check) — retail `-0.65`. ACE source shows `speed *= -BackwardsFactor` with a separate constant declaration; need to confirm value. |
| `apply_run_to_command` (l.525) | same | 305062 | Identical mappings. ACE `MaxSidestepAnimRate=3.0f` matches retail. |
| `contact_allows_move` (l.584) | same | 305471 | Identical. |
| `get_state_velocity` (l.678) | same | 305160 | Identical. ACE uses `WalkAnimSpeed=3.12, RunAnimSpeed=4.0, SidestepAnimSpeed=1.25` matching retail. |
| `apply_interpreted_movement` (l.440) | same | 305713 | Identical re-application of forward → sidestep → turn. |
| `move_to_interpreted_state` (l.789) | same | 305936 | Identical. ACE's stamp comparison logic matches the 15-bit wrap. |
ACE's port is faithful. The only deviations seen so far are in the
`apply_raw_movement` path which ACE uses for autonomous (player-self)
echoes, but this isn't on the L.3 critical path.
---
## Cross-reference with acdream
### `src/AcDream.App/Rendering/GameWindow.cs::OnLiveMotionUpdated` (l.2591)
**This is acdream's UM handler. It DIVERGES from retail in important
structural ways.**
acdream's path:
1. Pull `update.MotionState.Stance`, `ForwardCommand`, `ForwardSpeed`
etc. directly from the parsed wire packet.
2. For non-self entities, **directly mutate**
`remoteMot.Motion.InterpretedState.ForwardCommand = fullMotion`
and `.ForwardSpeed = speedMod` (l.2860, 2868). This is
acdream's equivalent of `copy_movement_from` — but only for
ForwardCommand+ForwardSpeed, NOT a full bulk copy.
3. SideStep + Turn axes are handled separately via
`remoteMot.Motion.DoInterpretedMotion(...)` /
`StopInterpretedMotion(...)` (l.3073, 3079, 3109, 3121).
4. The animation sequencer is driven by a separately computed
`animCycle` with its own priority logic
(Forward → Sidestep → Turn → Ready) at l.29182952.
### Divergences from retail:
1. **No bulk `copy_movement_from`.** acdream only copies
ForwardCommand+ForwardSpeed when those wire bits change. Retail
always copies all 7 fields. Consequence: on a stop UM (no command
bits set), acdream's parser produces command=null and speed=null;
the assignment at l.2860 only fires when ForwardCommand changed.
**It's possible for InterpretedState fields to retain stale
values across stop UMs** if the parser logic doesn't normalize
absence to "Ready/1.0." (Need to audit
`WorldSession.EntityMotionUpdate.MotionState` — does it default
on absence the same way `InterpretedMotionState::UnPack` does?
Per CLAUDE.md memory entry on Phase L.X, the wire parser had
bits wrong before — flag mapping is now correct.)
2. **No per-axis `apply_interpreted_movement` re-fire.** Retail's
`apply_interpreted_movement` re-runs `DoInterpretedMotion` for
each axis on every state change, which calls
`CPhysicsObj::DoInterpretedMotion` and ultimately
`get_state_velocity``set_local_velocity`. acdream's port skips
this re-fire — it relies on per-tick logic in `TickAnimations` to
pick up the InterpretedState change next frame. This is the
"staircase" issue noted in CLAUDE.md.
3. **MotionInterpreter.cs `DoMotion` does not match retail.**
acdream's `MotionInterpreter.DoMotion` (l.381395) just records
RawState and forwards to `DoInterpretedMotion(motion, speed,
modifyInterpretedState:true)`. Retail's `DoMotion` calls
`adjust_motion` first, then `DoInterpretedMotion`, then
conditionally `RawMotionState::ApplyMotion`. The acdream version
skips the `adjust_motion` call — meaning a local
`DoMotion(WalkBackward, +1.0)` would NOT get sign-flipped to
`WalkForward + -0.65`. (For the L.3 receiver path this doesn't
matter because the wire already carries the post-adjust form;
for the local-player command path it does matter and is a separate
bug.)
4. **`get_state_velocity` (MotionInterpreter.cs l.587)** is faithful
to retail except it adds an Option-B path that reads from
`GetCycleVelocity` (the sequencer's MotionData.Velocity) when
available — overriding the hardcoded `RunAnimSpeed=4.0` constant
with the dat-baked velocity. This is a deliberate enhancement
for non-humanoid creatures (different MotionData scales) and is
noted in the comment block. It's safe because the max-speed clamp
below still uses `RunAnimSpeed × runRate`.
5. **`StopInterpretedMotion` (MotionInterpreter.cs l.460)** does NOT
re-run `apply_interpreted_movement`. It only edits InterpretedState
then calls `apply_current_movement(false, false)` — which itself
doesn't re-fire per-axis like retail does. This matches the
retail single-stop semantics, but combined with #2 above it means
a single sidestep-clear UM doesn't immediately push zero X velocity
to the body.
### Files to compare side-by-side during the L.3 port:
- `src/AcDream.Core/Physics/MotionInterpreter.cs` — the executor
- `src/AcDream.App/Rendering/GameWindow.cs::OnLiveMotionUpdated` — the
receiver glue
- `src/AcDream.Core/Net/WorldSession.cs` (search for
`EntityMotionUpdate`) — the wire parser
- `references/ACE/Source/ACE.Server/Physics/Animation/MotionInterp.cs`
— ACE's mirror
- `docs/research/named-retail/acclient_2013_pseudo_c.txt`:305062306268
— retail source of truth.