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>
This commit is contained in:
parent
09e013b7bd
commit
17a9ff1158
6 changed files with 691 additions and 22 deletions
233
docs/research/2026-05-01-retail-motion-trace/findings.md
Normal file
233
docs/research/2026-05-01-retail-motion-trace/findings.md
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
# 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, 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](../../../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 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
|
||||
|
||||
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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue