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>
48 KiB
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):
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:
- Read object guid from offset 4.
- Read 2-byte
instance_timestampfrom payload start (offset 8). - Look up the entity. If we don't know it, queue the blob and return.
- If
update_times[8](last seen instance ts) is newer than the wire's, drop the packet (return 2). This is the staleness gate. - Otherwise hand off the rest of the payload to
CPhysics::SetObjectMovement.
2. CPhysics::SetObjectMovement
Function: CPhysics::SetObjectMovement
Address: 0x00509690
Lines: 271370–271431.
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 ANDforceTeleport == 0, returns 1 — the dispatcher then callsLoseControlToServer(). 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.
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):
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_statebulk-applies the unpacked state (see §7). standing_longjumpflag 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):
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:
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:
-
copy_movement_fromis 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. -
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). -
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.
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.
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:
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.
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:
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.
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/sRunAnimSpeed = 4.0 m/sSidestepAnimSpeed = 1.25 m/sMaxSidestepAnimRate = 3.0(clamp inside apply_run_to_command)BackwardsFactor = 0.65RunTurnFactor = 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.
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.
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 whereset_local_velocity(get_state_velocity(...))happens. InterpretedMotionState::ApplyMotionupdates the InterpretedState ONLY when the params flag 0x40 (ModifyInterpretedState) is set. Inapply_interpreted_movement, params is a freshMovementParameters()with default flags — and the default has ModifyInterpretedState=0. So per-axis re-application is purely a PHYSICS push; the state itself stays ascopy_movement_frombulk-loaded it.
15. InterpretedMotionState::ApplyMotion
Function: InterpretedMotionState::ApplyMotion
Address: 0x0051ea40
Lines: 293531–293564.
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.
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 ownapply_raw_movement→adjust_motionchain. 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 = WalkForwardraw_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 CLEAREDraw_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.
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:
InterpretedMotionState::copy_movement_frombulk-copies the defaults into the body's interpreted state.apply_current_movement→apply_interpreted_movement(§8) firesDoInterpretedMotion(Ready, ...)for forward,StopInterpretedMotion(SideStepRight)for sidestep, andStopInterpretedMotion(TurnRight)for turn.get_state_velocityreturns (0, 0, 0) because forward_command is Ready (matches neither WalkForward nor RunForward).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):
InterpretedMotionState::UnPackreadsforward_command = 0x45000005 (WalkForward)andforward_speed = -1.0fverbatim from the wire.move_to_interpreted_state→copy_movement_fromwrites those into InterpretedState unchanged.apply_interpreted_movementcallsDoInterpretedMotion(WalkForward, params{speed=-1.0}).DoInterpretedMotion→CPhysicsObj::DoInterpretedMotion→get_state_velocity:- WalkForward branch hits,
velocity.Y = 3.12 × -1.0 = -3.12 m/s.
- WalkForward branch hits,
set_local_velocitypushes 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):
adjust_motionflips: motion → WalkForward, speed → -0.65 × BackwardsFactor.- If HoldKey == Run,
apply_run_to_commandchecksspeed > 0— FALSE — so the motion stays WalkForward (no promotion to RunForward), but speed gets multiplied by speedMod (runRate). - Final: motion=WalkForward, speed = -0.65 × runRate.
RawMotionState::ApplyMotionwrites that back (when ModifyRawState bit set), so the next outbound MoveToState carriesforward_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:
- Pull
update.MotionState.Stance,ForwardCommand,ForwardSpeedetc. directly from the parsed wire packet. - For non-self entities, directly mutate
remoteMot.Motion.InterpretedState.ForwardCommand = fullMotionand.ForwardSpeed = speedMod(l.2860, 2868). This is acdream's equivalent ofcopy_movement_from— but only for ForwardCommand+ForwardSpeed, NOT a full bulk copy. - SideStep + Turn axes are handled separately via
remoteMot.Motion.DoInterpretedMotion(...)/StopInterpretedMotion(...)(l.3073, 3079, 3109, 3121). - The animation sequencer is driven by a separately computed
animCyclewith its own priority logic (Forward → Sidestep → Turn → Ready) at l.2918–2952.
Divergences from retail:
-
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 auditWorldSession.EntityMotionUpdate.MotionState— does it default on absence the same wayInterpretedMotionState::UnPackdoes? Per CLAUDE.md memory entry on Phase L.X, the wire parser had bits wrong before — flag mapping is now correct.) -
No per-axis
apply_interpreted_movementre-fire. Retail'sapply_interpreted_movementre-runsDoInterpretedMotionfor each axis on every state change, which callsCPhysicsObj::DoInterpretedMotionand ultimatelyget_state_velocity→set_local_velocity. acdream's port skips this re-fire — it relies on per-tick logic inTickAnimationsto pick up the InterpretedState change next frame. This is the "staircase" issue noted in CLAUDE.md. -
MotionInterpreter.cs
DoMotiondoes not match retail. acdream'sMotionInterpreter.DoMotion(l.381–395) just records RawState and forwards toDoInterpretedMotion(motion, speed, modifyInterpretedState:true). Retail'sDoMotioncallsadjust_motionfirst, thenDoInterpretedMotion, then conditionallyRawMotionState::ApplyMotion. The acdream version skips theadjust_motioncall — meaning a localDoMotion(WalkBackward, +1.0)would NOT get sign-flipped toWalkForward + -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.) -
get_state_velocity(MotionInterpreter.cs l.587) is faithful to retail except it adds an Option-B path that reads fromGetCycleVelocity(the sequencer's MotionData.Velocity) when available — overriding the hardcodedRunAnimSpeed=4.0constant 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 usesRunAnimSpeed × runRate. -
StopInterpretedMotion(MotionInterpreter.cs l.460) does NOT re-runapply_interpreted_movement. It only edits InterpretedState then callsapply_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 executorsrc/AcDream.App/Rendering/GameWindow.cs::OnLiveMotionUpdated— the receiver gluesrc/AcDream.Core/Net/WorldSession.cs(search forEntityMotionUpdate) — the wire parserreferences/ACE/Source/ACE.Server/Physics/Animation/MotionInterp.cs— ACE's mirrordocs/research/named-retail/acclient_2013_pseudo_c.txt:305062–306268 — retail source of truth.