# 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 (357211–357240): ```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:** 271370–271431. ```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:** 280179–280203. ```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:** 300563–300704. 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 (300563–300668): ```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 6–9 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:** 294360–294523. This reads a single `uint32_t` flag word and conditionally unpacks 13 fields. **This is exactly the format ACE writes when relaying.** Verbatim core (294360–294492): ```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 | | 0x80–0x800 | 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:** 305936–305992. 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 293301–293311) — 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:** 305713–305788. ```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:** 306159–306217. ```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:** 305343–305400. 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:** 305062–305123. ```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:** 305160–305204. ```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:** 305471–305505. ```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:** 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 /*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:** 293531–293564. ```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:** 700274–700312. ```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 293761–293980) 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:** 405442–405455. ```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.2918–2952. ### 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.381–395) 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`:305062–306268 — retail source of truth.