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>
305 lines
14 KiB
Markdown
305 lines
14 KiB
Markdown
# 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: ~1–2 hours
|
||
- Implementation + iteration: ~2–4 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.
|