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

233 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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](../../../.claude/worktrees/jovial-blackburn-773942/motion_trace_v1_walk_and_turn_2026-05-01.log)
and [motion_trace_v2_run_jump_turn_2026-05-01.log](../../../.claude/worktrees/jovial-blackburn-773942/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](../../../src/AcDream.Core.Net/Messages/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:
```c
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](../../../src/AcDream.App/Rendering/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
- [motion_discovery.cdb](../../../.claude/worktrees/jovial-blackburn-773942/motion_discovery.cdb)
— symbol resolution + prologue dump (one-shot, kept for reference)
- [motion_trace.cdb](../../../.claude/worktrees/jovial-blackburn-773942/motion_trace.cdb)
— current motion-trace script (v4, with `.detach`)
- [run_motion_trace.ps1](../../../.claude/worktrees/jovial-blackburn-773942/run_motion_trace.ps1)
— launcher with timer-based fallback
- v1 log: walk + turn, no jump
- v2 log: run + jump + turn
## 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.