acdream/docs/research/2026-05-06-locomotion-cycle-transitions/investigation-prompt.md
Erik 8fa04af4c7 fix(motion): #39 candidate — un-gate UP velocity-cycle for player remotes (forward only)
Adds a player-remote velocity-fallback path to ApplyServerControlledVelocityCycle
so that when retail (the actor) toggles Shift while holding W and acdream is
the observer, the visible leg cycle switches Run↔Walk within ~200–500 ms even
though no fresh UM arrives. Static analysis (ACE GameActionMoveToState +
MovementData.cs auto-upgrade + acdream's prior diag traces) suggests retail
does NOT broadcast a fresh MoveToState on HoldKey-only changes — acdream's
UMs handle direction-key changes and our local +Acdream's transitions, but
retail-driven actors leave the cycle stuck.

Changes (all in src/AcDream.App/Rendering/GameWindow.cs):
- New RemoteMotion.LastUMTime field, stamped in OnLiveMotionUpdated
- ApplyServerControlledVelocityCycle: removed inner IsPlayerGuid gate;
  routes player remotes to new ApplyPlayerLocomotionRefinement
- ApplyPlayerLocomotionRefinement (forward-direction only):
  - 500 ms UM grace window (UMs win when fresh)
  - Forward-direction-only (low byte 0x05 / 0x07)
  - Hysteresis: Run → Walk demote at < 4.5 m/s; Walk → Run promote > 5.5 m/s
  - Skip SetCycle when neither motion ID nor speedMod changed meaningfully
  - [UPCYCLE_PLAYER] diag gated on ACDREAM_REMOTE_VEL_DIAG=1
- Outer call site in OnLivePositionUpdated un-gated (!IsPlayerGuid removed);
  per-remote routing now lives inside the function

Scope: case #1 (Run↔Walk forward) only. Cases #2–#7 (backward, sidestep
speed-buckets, direction-flips) remain deferred — PlanFromVelocity is
forward-only and its NPC-tuned thresholds (RunThreshold=1.25) do not
separate player Walk (~2.5 m/s) from player Run (~9 m/s); a TTD trace
of retail's per-direction algorithm should ground the wider fix.

ISSUES.md #39 updated with progress; investigation-prompt.md and a new
findings-static.md committed under
docs/research/2026-05-06-locomotion-cycle-transitions/ (the prompt was
authored on a parallel branch in commit 7a38da3 and is brought into this
worktree here so the next session can find it without branch-hopping).

Build clean. The 8 pre-existing test failures on this branch
(BSPStepUpTests.C3_Path6_AirborneMoverHitsSteepSlope, MotionInterpreter
WalkBackward GetMaxSpeed, etc.) are unrelated to this change — verified
by running them with the diff stashed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 06:34:20 +02:00

305 lines
14 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.

# Locomotion-cycle transitions on observed remotes — investigation prompt
**Hand-off date:** 2026-05-06
**Status:** open. ISSUES.md #39 captured the Run↔Walk-forward variant; this
prompt expands to the full locomotion transition matrix and lays out a
TTD-driven investigation against retail to ground the fix.
This document is a self-contained briefing for an agent (or fresh session)
picking this up. The TTD toolchain landed in commit `e3e5bf5` (#44) and
is the primary investigative tool here.
---
## What problem are we trying to solve?
When acdream observes a remote-driven player character (typically a parallel
retail acclient.exe connected to the same local ACE), the visible leg-cycle
animation does not always switch when the actor changes locomotion mode.
The body translates at the right speed (server-driven velocity is fine), but
the legs keep playing whichever cycle was active before.
The full set of transitions where the bug may surface:
| # | Transition | Wire change | Likely cause |
|---|---|---|---|
| 1 | Run forward ↔ Walk forward (Shift toggle while W held) | HoldKey + ForwardSpeed only | ACE doesn't broadcast UM (no ForwardCommand change); cycle refinement must come from UP-derived velocity |
| 2 | Run backward ↔ Walk backward (Shift toggle while S held) | HoldKey + ForwardSpeed only | Same as #1, backward axis |
| 3 | Forward ↔ Backward (W→S or S→W direct flip) | ForwardCommand changes (e.g. `WalkForward``WalkBackward`) | ACE broadcasts a UM; cycle should update from UM directly |
| 4 | Fast strafe-left ↔ Slow strafe-left (Shift toggle while A held) | HoldKey + SideSpeed only | Same as #1 / #2 — speed-bucket only |
| 5 | Fast strafe-right ↔ Slow strafe-right (Shift toggle while D held) | HoldKey + SideSpeed only | Same |
| 6 | Strafe-Left ↔ Strafe-Right (A↔D direct flip) | SideCommand changes | ACE broadcasts UM |
| 7 | Forward ↔ Strafe (W↔A, W↔D, etc.) | ForwardCommand or SideCommand changes | UM broadcast |
#39 in ISSUES.md focused on #1. This investigation widens scope to the
whole matrix because the underlying wire pattern is the same shape: half
the transitions broadcast a UM, the other half rely on UP-derived velocity.
A correct fix should handle both classes uniformly.
---
## What we already know (don't re-discover)
From the prior 2026-05-03 investigation (`docs/research/2026-05-03-remote-anim-cycle/`)
and #39 in `docs/ISSUES.md`:
- **ACE only broadcasts a fresh UpdateMotion when the wire's `ForwardCommand`
byte changes** — i.e. on direction-key state changes. Toggling Shift while
W held changes `ForwardSpeed` and `HoldKey` but NOT `ForwardCommand`, so
ACE does NOT broadcast a UM for the demote/promote.
- **Speed changes still propagate via UpdatePosition (UP).** Position-delta
velocity changes between Run-pace and Walk-pace, confirmed via
`[VEL_DIAG]` serverSpeed varying ~2.5 m/s (walk) ↔ ~9 m/s (run).
- **Retail's inbound code uses UP-derived velocity to refine the visible
cycle when no UM tells it.** Acdream has the equivalent function —
`ApplyServerControlledVelocityCycle` in `GameWindow.cs:3274` — but
it's gated `if (IsPlayerGuid(serverGuid)) return;` for player remotes,
exactly the case where the gap matters.
- **The fix sketch is small (~10 lines):** un-gate
`ApplyServerControlledVelocityCycle` for player remotes when
`currentMotion` is a locomotion cycle. Add a `LastUMUpdateTime` grace
window so fresh UMs win over UP-velocity refinement.
- **Outbound from acdream is fine** — the matrix in #39 confirms retail
observers see acdream's transitions correctly. The bug is purely in
acdream's RECEIVE path.
What's NOT yet confirmed (and is the primary goal of this investigation):
- **Which exact retail function** does the cycle refinement when no UM
arrives? Hypothesis: `CPhysicsObj::unpack_movement`
`MovementManager::unpack_movement` → some velocity-aware cycle picker.
We need to identify the function name and what state it reads.
- **What grace window** does retail use between UM and UP-derived
refinement? Our hypothesis is ~500 ms but it may be different.
- **Does retail use SideSpeed or SideCommand changes** the same way for
strafe transitions?
---
## TTD-driven workflow
The toolchain shipped in #44 (commit `e3e5bf5`). Bootstrap is one-time
per machine — see CLAUDE.md "TTD recordings (offline replay)" or
`tools/ttd-record.ps1` header.
### Recording scenario
You need **two retail acclient.exe instances** running on the same local
ACE — one as the recorded process, one as the actor. Acdream can be
involved as a third client for cross-checking but is not strictly required
for the recording itself.
Setup:
1. Launch retail #1 on `testaccount` / `+Acdream` (or any character).
2. Launch retail #2 on a second account / character. It must be on the
same landblock so retail #1 can see it.
3. Position retail #2 in retail #1's view, ~10 m away.
4. In an **elevated** PowerShell, attach TTD to retail #1 (the observer):
```powershell
tools\ttd-record.ps1 -RingMaxMB 512
```
5. Wait for `Recording is now active`. Retail #1 will be ~10× slower —
that's expected.
Scenario (drive on retail #2, ~60 seconds total):
```
Phase 1 — Forward speed: Hold W (2 s) → +Shift → -Shift → +Shift → -Shift → release W (idle 1 s)
Phase 2 — Backward speed: Hold S (2 s) → +Shift → -Shift → +Shift → -Shift → release S (idle 1 s)
Phase 3 — Forward↔Back flip: Hold W (2 s) → release W → Hold S (2 s) → release S
Phase 4 — Strafe-Left speed: Hold A (2 s) → +Shift → -Shift → +Shift → -Shift → release A
Phase 5 — Strafe-Right speed: Hold D (2 s) → +Shift → -Shift → +Shift → -Shift → release D
Phase 6 — Strafe L↔R flip: Hold A (2 s) → release A → Hold D (2 s) → release D
Phase 7 — Forward↔Strafe: Hold W (2 s) → release W → Hold D (2 s) → release D
```
After phase 7, Ctrl+C the TTD recording. Trace lands at
`~/.ttd/traces/acclientNN.run`.
### Query strategy
The recording captured ~7 phases in ~60 sec. Each phase produced specific
UM and/or UP traffic. Goal: for each phase identify what retail's receive
code did to update the visible cycle.
Start with a `.cdb` script under `tools/ttd-queries/locomotion.cdb`:
```
.echo === Top motion-receive entry points ===
dx -r2 @$cursession.TTD.Calls("acclient!CPhysicsObj::unpack_movement").Take(20)
dx -r2 @$cursession.TTD.Calls("acclient!MovementManager::unpack_movement").Take(20)
.echo === Cycle-update functions called ===
dx -r2 @$cursession.TTD.Calls("acclient!*set_animation*").GroupBy(c => c.Function).Select(g => new { Name = g.First().Function, Count = g.Count() }).OrderByDescending(x => x.Count)
dx -r2 @$cursession.TTD.Calls("acclient!*SetCycle*").GroupBy(c => c.Function).Select(g => new { Name = g.First().Function, Count = g.Count() }).OrderByDescending(x => x.Count)
dx -r2 @$cursession.TTD.Calls("acclient!*play_*").GroupBy(c => c.Function).Select(g => new { Name = g.First().Function, Count = g.Count() }).OrderByDescending(x => x.Count)
.echo === Velocity / position-update handlers ===
dx -r2 @$cursession.TTD.Calls("acclient!*UpdatePosition*").GroupBy(c => c.Function).Select(g => new { Name = g.First().Function, Count = g.Count() }).OrderByDescending(x => x.Count)
dx -r2 @$cursession.TTD.Calls("acclient!CPhysicsObj::set_velocity").Take(15)
.echo === DONE ===
q
```
Run it:
```powershell
tools\ttd-query.ps1 -Script tools\ttd-queries\locomotion.cdb
```
**Then iterate.** Use the function-name hits to guide the next query —
inspect call args (`Take(N)` instead of `Count()`), navigate to specific
TTD positions and dump struct fields with `dt acclient!ClassName <addr>`.
The named-retail decomp at `docs/research/named-retail/acclient_2013_pseudo_c.txt`
has the source for every retail function. Grep it by class::method to
read what the function does, then use TTD to confirm what it does at
runtime.
### Specifically what to look for
For each phase in the recording, answer:
1. **Did a UM arrive?** Look for calls to the receive entry points around
that phase's time window. UM dispatch should fire on direction-key
changes (phases 3, 6, 7) but NOT on Shift-only toggles (phases 1, 2, 4, 5).
2. **What function refined the cycle when no UM arrived?** Hypothesis is
something in the velocity-aware path. Find the symbol, read the
decomp, document the conditions.
3. **What threshold/grace logic is in place?** Retail must have some way
to prevent UP-velocity refinement from fighting a fresh UM. Usually
a timestamp comparison.
---
## The fix
The fix lives in `src/AcDream.App/Rendering/GameWindow.cs:3274`
(`ApplyServerControlledVelocityCycle`). Current code returns early for
player remotes. The fix is to:
1. **Un-gate the early return** when the current motion is a locomotion
cycle. Locomotion cycles to detect: `0x44000007` (Run forward),
`0x45000005` (Walk forward), backward equivalents, sidestep variants.
Keep the gate when the motion is non-locomotion (e.g. emotes, attacks).
2. **Add a `LastUMUpdateTime` per remote.** Touch it in the UM handler
path. In `ApplyServerControlledVelocityCycle`, skip refinement if
`(now - LastUMUpdateTime) < graceMs`. Start with `graceMs = 500`
and tune against the TTD findings.
3. **Use UP-derived velocity to pick the speed bucket.** Existing logic
in `ServerControlledLocomotion.PlanFromVelocity` already does this —
verify thresholds match retail's bucketing (TTD will tell you the
exact boundaries retail uses).
For direction-flip transitions (phases 3, 6, 7), the UM handler path
should already work — the visible cycle should update from the UM
directly. Confirm in-client that those transitions are clean BEFORE
shipping the velocity-fallback fix; if they're broken too, that's a
separate UM-handler bug that needs its own investigation.
---
## Acceptance criteria
In acdream observing a retail-driven character:
- **Phases 1, 2, 4, 5 (Shift toggles):** visible leg cycle switches
Run↔Walk / Fast↔Slow within ~200 ms of the wire change.
- **Phases 3, 6, 7 (direction flips):** visible cycle updates within
~100 ms (UM is direct-path, faster than UP).
- **No regression** on already-working cases:
- acdream-on-acdream (matrix row in #39)
- retail observers viewing acdream (works today)
- Idle ↔ Run transitions
- Idle ↔ Walk transitions
- Combat-mode locomotion (if testable)
- **No spurious cycle thrashing during turning while running** —
ObservedOmega-driven body rotation must not trigger
velocity-bucket changes mid-cycle.
---
## Files to read first
In this order:
1. `docs/ISSUES.md` — issue #39 (Run↔Walk specific) and #44 (TTD toolchain)
2. `docs/research/2026-05-03-remote-anim-cycle/investigation-prompt.md` —
prior investigation, what's been tried, what works
3. `CLAUDE.md` — "Retail debugger toolchain" section (live cdb +
"TTD recordings (offline replay)" subsection)
4. `memory/project_retail_debugger.md` — durable lessons + TTD addendum
5. `memory/project_retail_motion_outbound.md` — what retail's outbound
motion path looks like (cdb live trace from 2026-05-01)
Then:
6. `src/AcDream.App/Rendering/GameWindow.cs` — `OnLiveMotionUpdated`
(~line 3203) and `ApplyServerControlledVelocityCycle` (~line 3274)
7. `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` — speed
bucket thresholds in `PlanFromVelocity`
8. `docs/research/named-retail/acclient_2013_pseudo_c.txt` — grep by
the symbol names you discover from the TTD trace
9. `references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageUpdateMotion.cs`
— what ACE actually sends for each transition (ground truth for
wire content)
---
## Watchouts
- **The named-retail leaf `MoveToStatePack::UnPack` returned 0 hits in
TTD.** Don't query it directly; start at `CPhysicsObj::unpack_movement`
or `MovementManager::unpack_movement` and walk down the call chain.
Release-build inlining + virtual dispatch hide leaf decoders from
TTD's static call counting (#44 lesson).
- **Verify positioning IN-CLIENT before recording.** A trace where
retail #1 can't see retail #2 is wasted bytes. Walk both characters
to within visible range, confirm the actor is rendered on the
observer's screen, THEN attach TTD. The 30-min validation experiment
on 2026-05-06 wasted its main probe because of this — don't repeat.
- **Outbound encoding quirk in our wire path** (CLAUDE.md "Outbound
motion wire format"): acdream sends `WalkForward (0x05) + HoldKey.Run`
for run, which ACE auto-upgrades to `RunForward (0x07)` when relaying
to remote observers. So the inbound parser sees `fwd=0x07` for
"remote is running." Don't get confused if outbound code shows 0x05
but inbound shows 0x07 — that's normal ACE behavior.
- **TTD ring buffer with 512 MB max + ~60 sec recording** should fit
the whole scenario without rollover. If you record longer, consider
bumping to 1024 MB or chopping the scenario into multiple recordings.
- **Don't kill the TTD process** with Stop-Process — it kills retail
too. Use Ctrl+C in the TTD window or `TTD.exe -stop <pid>` from
another elevated shell.
---
## Definition of done
This investigation is done when:
1. The TTD trace + queries identify the exact retail function(s) that
refine the cycle from UP-derived velocity. Function name(s) +
address(es) documented in `docs/research/2026-05-06-locomotion-cycle-transitions/findings.md`.
2. The threshold/grace logic retail uses is documented (timing values,
conditions).
3. `ApplyServerControlledVelocityCycle` un-gating shipped in acdream
with the corresponding test and visual verification on all 7 phases.
4. ISSUES.md #39 closed with the commit SHA.
5. Memory `project_retail_motion_outbound.md` (or a new note for the
inbound side) gets the durable lessons appended.
---
## Time estimate
- Recording: ~30 min (setup, two retail clients, scenario execution)
- Initial query passes + decomp cross-reference: ~12 hours
- Implementation + iteration: ~24 hours
- Visual verification + commit: ~30 min
Total: half a day to a full day depending on how clean retail's path is.
If after the first TTD pass the retail receive code looks fundamentally
different from what `ApplyServerControlledVelocityCycle` is doing, stop
and reassess — the fix shape may need to change. Don't try to ram a
mismatched approach through.