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

14 KiB
Raw Permalink Blame History

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. WalkForwardWalkBackward) 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_movementMovementManager::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):
    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:

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:

  1. src/AcDream.App/Rendering/GameWindow.csOnLiveMotionUpdated (~line 3203) and ApplyServerControlledVelocityCycle (~line 3274)
  2. src/AcDream.Core/Physics/ServerControlledLocomotion.cs — speed bucket thresholds in PlanFromVelocity
  3. docs/research/named-retail/acclient_2013_pseudo_c.txt — grep by the symbol names you discover from the TTD trace
  4. 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.