Closes a multi-bug knot in player motion outbound + remote inbound,
discovered via cdb live trace of retail (2026-05-01) and follow-up
visual verification.
Outbound (acdream → ACE):
- JumpAction velocity is BODY-LOCAL, not world (per retail
CPhysicsObj::get_local_physics_velocity at 0x00512140 + ACE
Player.HandleActionJump's set_local_velocity call). Was sending
world; observers saw jump rotated by player yaw.
- Capture get_jump_v_z BEFORE LeaveGround() — the latter resets
JumpExtent to 0, after which get_jump_v_z returned 0. Was sending
Z=0 in every JumpAction.
- Backward/strafe-left jumps lost their horizontal velocity because
LeaveGround → get_state_velocity returns zero for non-canonical
motion (faithful to retail's FUN_00528960; retail papers over via
adjust_motion translation, not yet ported). Compute the correct
body-local launch velocity from input directly and push it back
into the body so local prediction matches what we send.
- IsRunning HoldKey was gated on `input.Run && input.Forward`, so
strafe-run and backward-run incorrectly broadcast as walk to
observers — ACE then animated walk + dead-reckoned at walk speed
while server position moved at run speed (visible as observer
lag). Fixed: gate on any active directional axis.
- AutonomousPosition heartbeat 0.2s → 1.0s to match holtburger's
AUTONOMOUS_POSITION_HEARTBEAT_INTERVAL and the ~1Hz observed in
retail trace.
- Heartbeat now fires while in-world regardless of motion state
(matches holtburger + retail's transient_state-based gate, not
motion-based). Pre-fix the at-rest heartbeat was suppressed.
Inbound (ACE → acdream, remote retail player):
- Remote backward walk arrives as cmd=WalkForward + speed=-1.91
(retail's adjust_motion'd form). Two bugs were stacking:
1. AnimationSequencer fast-path returned without updating when
sign(speedMod) flipped while motion stayed equal — kept playing
forward at old positive framerate. Fixed: bypass fast-path on
sign change so the full re-setup runs.
2. GameWindow clamped negative speedMod to 1.0 when stuffing
InterpretedState.ForwardSpeed, making get_state_velocity
produce forward velocity. Fixed: pass speedMod through verbatim
so the dead-reckoning body translates backward.
Issue #38 filed: 30Hz physics tick produces a chase-camera smoothness
regression at 60+ FPS render. Standard render-time interpolation is
the recommended fix (separate phase).
Findings + comparison vs retail/holtburger:
docs/research/2026-05-01-retail-motion-trace/findings.md
docs/research/2026-05-01-retail-motion-trace/fixes.md
TODO: port retail's adjust_motion (FUN_00528010) properly so
get_state_velocity works for all directions natively — would let us
drop the workaround in PlayerMovementController jump path and the
clamp in GameWindow.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
11 KiB
Retail motion outbound trace — findings
Date: 2026-05-01
Tool: cdb 10.0.28000.1839 attached non-invasively to live retail
acclient.exe v11.4186 (Sept 2013 EoR build, GUID
9e847e2f-777c-4bd9-886c-22256bb87f32).
Symbols: refs/acclient.pdb — 18,366 named functions, 5,371 named
struct/class types.
Server: local ACE on 127.0.0.1:9000. Character: +Acdream,
spawned in Holtburg.
TL;DR
- WASD movement does NOT go through
ACCmdInterp::SendDoMovementEvent. Across two captures totalling ~5 minutes of running, jumping, and turning, that breakpoint fired zero times.SendDoMovementEventis apparently slash-command-only (/run,/walk,/sneak, etc.). The per-frame movement send path isCommandInterpreter::SendMovementEvent(which we couldn't trace stably — see "Open" below). - Jump goes through
CM_Movement::Event_Jump. It fires per-frame while the spacebar is held (charge phase), not just once per jump. 44 hits in ~30 sec of jumping. SameJumpPackstack pointer every time — function probably packs every frame but conditionally sends. - AutoPos heartbeat fires often during sustained motion (~5 Hz at
rest after gating, much higher under motion). It's gated by
transient_state & 1 && transient_state & 2 && Position::IsValidso does not fire when the player is standing still. set_headingis for player rotation only, not camera mouse-look. Camera pan does not fire it; turn keys / strafe / character autoface do.set_stateXOR mask shows which physics-state bits change on each call. Distinct values observed: 11 different new-state bitmasks across two captures (see "set_state bitmask atlas" below).
What we ran
| Run | Duration | Activity | Hits |
|---|---|---|---|
| v1 | ~60 sec | Run forward + turn | 127 |
| v2 | ~3 min | Run + jump + turn (holding W, repeated jumps) | 247 |
Both with the same six-breakpoint set:
SendDoMovementEvent, SendStopMovementEvent, set_state,
set_heading, Event_Jump, Event_AutonomousPosition. Logs at
motion_trace_v1_walk_and_turn_2026-05-01.log
and motion_trace_v2_run_jump_turn_2026-05-01.log.
A v3 attempt added CommandInterpreter::SendMovementEvent (the
per-frame MoveToState gateway). The cdb attach + breakpoint setup with
that 7th breakpoint added enough overhead that retail's network thread
starved; ACE timed out the session and retail crashed within seconds of
attach. That bp is too high-frequency to instrument with a printf in
the action — needs a separate, minimal-overhead trace (counter only,
no print).
Hit distribution (v2)
| BP | Hits | Notes |
|---|---|---|
set_state |
159 | All 11 distinct new-masks; clusters around motion transitions |
Event_Jump |
44 | Bursts during sustained spacebar-hold |
Event_AutonomousPosition (printed) |
38 | Throttle 1/10 → ~370 actual hits |
set_heading (printed) |
6 | Throttle 1/10 → ~60 actual; only fires on player rotate |
SendDoMovementEvent |
0 | Confirmed unused for keyboard motion |
SendStopMovementEvent |
0 | Confirmed unused |
set_state bitmask atlas
Captured new masks (current state was 0x00400c08 baseline, sometimes
0x00410c08). Distinct values across both runs:
new |
bits set | Likely meaning (cross-checked vs PHYSICS_STATE enum candidates) |
|---|---|---|
0x00000408 |
3, 10 | Base "in world, animating" |
0x00000410 |
4, 10 | Walk / sidestep variant |
0x00000414 |
2, 4, 10 | Walking + something |
0x00000418 |
3, 4, 10 | Walking + alt |
0x00000c0c |
2, 3, 10, 11 | Multi-state combo |
0x00000c14 |
2, 4, 10, 11 | Combo near jump |
0x00010008 |
3, 16 | Bit 16 likely "scripted/cinematic"? |
0x00020414 |
2, 4, 10, 17 | Bit 17 likely something high |
0x00200418 |
3, 4, 10, 21 | Bit 21 likely "frozen/inert" |
0x00600418 |
3, 4, 10, 21, 22 | Bits 21+22 — common during motion |
0x0060041c |
2, 3, 4, 10, 21, 22 | Same + bit 2 |
Action item: decode the PhysicsState enum bits from
docs/research/named-retail/acclient.h and label this table
authoritatively. The cur value 0x00400c08 (bits 3, 10, 11, 22)
should be the "neutral, in-world, alive" base.
set_heading angle samples
Angles captured (4-byte float bit pattern, decoded):
| Hex | Float (degrees) |
|---|---|
0x00000000 |
0.0 (north) |
0x41c8af72 |
25.09 |
0x43070000 |
135.0 |
0x43083c22 |
136.23 |
0x430d7596 |
141.46 |
0x43340000 |
180.0 |
0x433a1741 |
186.09 |
0x43a9d6c7 |
339.68 |
Confirms heading is degrees, float, 0–360 wrap. Matches the acdream-side encoding in the outbound builders.
Event_Jump pattern
44 hits in v2 — way more than user keypresses. Pattern:
[70..92] burst of 9 hits (one charged jump session)
[97..138] burst of 11 hits (another charged jump)
[251..408] continuous hits (multiple chained jumps)
Same JumpPack pointer (001af5f8) on every hit — stack-allocated
local in the caller. Consistent with Event_Jump being called
per-frame while the jump button is held to charge, with the
function itself deciding whether to actually send the pack on the wire.
We did not break inside the function so we don't have the send/no-send
ratio.
Action item: trace one jump in isolation — set a bp on
Event_Jump AND on the BSTREAM-write deeper in the function. Capture
how many of the per-frame Event_Jump calls actually emit a 0xF61B
JumpAction packet. acdream's JumpAction.cs
sends one JumpAction per spacebar release — if retail sends one per
charge-frame, our outbound is wrong (or vice-versa).
AutoPos heartbeat behaviour
CM_Movement::Event_AutonomousPosition is called from
CommandInterpreter::SendPositionEvent (006b4770), which is
guarded by:
if (this->smartbox != 0 && this->player != 0 &&
(transient_state & 1) != 0 && (transient_state & 2) != 0 &&
Position::IsValid(&player->m_position))
transient_state bits 0 and 1 are required — this is why AutoPos
doesn't fire when the player is fully at rest. During sustained
motion it fires ~5 Hz at the throttle interval we saw (1 print every
10 hits = ~1 line/sec → ~50 hits in 5 sec).
Action item: acdream's outbound heartbeat in GameWindow.cs is a 200ms periodic pulse. Verify whether retail's cadence matches our fixed 200ms or is event-driven (e.g. fires per-frame while moving and not at all at rest). If event-driven, we may be sending stale heartbeats while standing still that retail would suppress.
Confirmed correct vs acdream-side
| Behaviour | Retail observed | acdream | Match |
|---|---|---|---|
| Heading encoding | float degrees 0–360 | float degrees 0–360 in MoveToState | ✓ |
| Heartbeat at rest | does not send | sends every 200ms | MISMATCH — ours sends extras |
| Slash-command motion path | SendDoMovementEvent (unused for WASD) |
n/a (acdream doesn't send slash motions) | ✓ |
| Jump per-frame charge | Event_Jump fires every frame while spacebar held |
acdream sends 1 JumpAction on release | POSSIBLE MISMATCH — needs deeper trace |
Open / what we still don't have
- The actual 0xF61C MoveToState send path — retail's per-frame
movement message. Our breakpoint
on
CommandInterpreter::SendMovementEvent(0x006B4680) was the right entry but adding it to the trace caused retail to crash (cumulative bp-action overhead). Needs a SEPARATE focused trace with just that bp + a counter (noprintf). - The exact wire bytes of MoveToState / AutonomousPosition / Jump.
Was planned for the v3 wire-trace; not run. Would require
RawMotionState::PackandAutonomousPositionPack::Packbreakpoints withdtstruct dump +dbbyte dump. Same overhead concerns; do one bp at a time. - Sequence-counter behaviour. Our trace captured nothing about
how retail bumps the four
Instance/ServerControl/Teleport/ForcePositionsequence counters across messages. Needs taps insideMoveToStatePack/AutonomousPositionPackconstructors. - The
PhysicsStateenum decode. Names map for the 0x408 / 0x418 / 0x600418 / 0x10008 etc. bitmasks lives indocs/research/named-retail/acclient.h. Pull thePHYSICS_STATEenum and label this trace's set_state column authoritatively. - DoMov / StopMov context. Confirmed unused for WASD, but
SendDoMovementEvent(0x85000001, 0x3f800000, 0)was found in the pseudo-C as a real call site — find what triggers it (slash command? autorun toggle?) and document so our IS_SLASH_COMMAND test paths know what to expect.
Toolchain notes
qdis not allowed inside breakpoint command actions in cdb. Empirically silently ignored (no parse error). Quoting the docs: "you cannot use commands that wait for input or end the debugger session in breakpoint command lists."qd,q,qqall fall under this. Use.detach(a meta-command) instead when you need a bp-driven detach.cdb -pddoes NOT surviveStop-Process -Force.-pdmakes the graceful exit not terminate the debuggee.TerminateProcess(which is whatStop-Process -Forcedoes) bypasses cleanup and the OS treats debugger termination as debuggee termination. Need eitherqd/.detachfrom inside, or sendCTRL_BREAK_EVENTto cdb (via P/Invoke) and feedqdto its stdin.- Per-frame breakpoints with
printfactions are too expensive. AddingCommandInterpreter::SendMovementEvent(called every motion tick, ~30 Hz) with aprintfaction lagged retail enough that the ACE session timed out within a few seconds. For per-frame breakpoints, use counter-only actions (r $tN = @$tN + 1; gc) — no.printf, nopoireads.
Files
- motion_discovery.cdb — symbol resolution + prologue dump (one-shot, kept for reference)
- motion_trace.cdb
— current motion-trace script (v4, with
.detach) - run_motion_trace.ps1 — launcher with timer-based fallback
- v1 log: walk + turn, no jump
- v2 log: run + jump + turn
Next session
- Decode
PHYSICS_STATEenum fromacclient.h. 30 minutes. - Single-bp focused trace of
Event_Jumpwithdbbyte dump on the pack, to determine send-rate vs charge-frame-rate. - Single-bp focused trace of
CommandInterpreter::SendMovementEvent(counter only) to confirm per-frame fire and measure rate. - Wire-bytes trace using
RawMotionState::Pack+ post-Packbyte dump for one each of: idle, run-forward, run+turn, jump.