acdream/docs/research/2026-05-01-retail-motion-trace/findings.md
Erik 17a9ff1158 fix(motion): jump direction, AutoPos cadence, backward/strafe wire & anim
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>
2026-05-02 16:11:15 +02:00

11 KiB
Raw Permalink Blame History

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. SendDoMovementEvent is apparently slash-command-only (/run, /walk, /sneak, etc.). The per-frame movement send path is CommandInterpreter::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. Same JumpPack stack 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::IsValid so does not fire when the player is standing still.
  • set_heading is for player rotation only, not camera mouse-look. Camera pan does not fire it; turn keys / strafe / character autoface do.
  • set_state XOR 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, 0360 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 0360 float degrees 0360 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

  1. 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 (no printf).
  2. The exact wire bytes of MoveToState / AutonomousPosition / Jump. Was planned for the v3 wire-trace; not run. Would require RawMotionState::Pack and AutonomousPositionPack::Pack breakpoints with dt struct dump + db byte dump. Same overhead concerns; do one bp at a time.
  3. Sequence-counter behaviour. Our trace captured nothing about how retail bumps the four Instance/ServerControl/Teleport/ForcePosition sequence counters across messages. Needs taps inside MoveToStatePack / AutonomousPositionPack constructors.
  4. The PhysicsState enum decode. Names map for the 0x408 / 0x418 / 0x600418 / 0x10008 etc. bitmasks lives in docs/research/named-retail/acclient.h. Pull the PHYSICS_STATE enum and label this trace's set_state column authoritatively.
  5. 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

  • qd is 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, qq all fall under this. Use .detach (a meta-command) instead when you need a bp-driven detach.
  • cdb -pd does NOT survive Stop-Process -Force. -pd makes the graceful exit not terminate the debuggee. TerminateProcess (which is what Stop-Process -Force does) bypasses cleanup and the OS treats debugger termination as debuggee termination. Need either qd/.detach from inside, or send CTRL_BREAK_EVENT to cdb (via P/Invoke) and feed qd to its stdin.
  • Per-frame breakpoints with printf actions are too expensive. Adding CommandInterpreter::SendMovementEvent (called every motion tick, ~30 Hz) with a printf action 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, no poi reads.

Files

Next session

  1. Decode PHYSICS_STATE enum from acclient.h. 30 minutes.
  2. Single-bp focused trace of Event_Jump with db byte dump on the pack, to determine send-rate vs charge-frame-rate.
  3. Single-bp focused trace of CommandInterpreter::SendMovementEvent (counter only) to confirm per-frame fire and measure rate.
  4. Wire-bytes trace using RawMotionState::Pack + post-Pack byte dump for one each of: idle, run-forward, run+turn, jump.