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>
1206 lines
48 KiB
Markdown
1206 lines
48 KiB
Markdown
# 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.
|