# Remote-entity animation-cycle bug — investigation prompt

**Hand-off date:** 2026-05-03
**Status:** open. Multiple shipped fixes today reduced the remote-entity motion problem to a single residual symptom — the **leg-cycle on observed remotes does not visibly switch between Walk / Run / Ready** even though every signal says it should. Plus minor blippiness in steady motion.

This document is a self-contained briefing for an agent (or fresh session) picking this up.

---

## What problem are we trying to solve?

When acdream observes another player driven by a parallel **retail** acclient.exe (connected to the same local ACE server), the remote character's **leg animation cycle** does not visibly change when that retail player switches between Run / Walk / Idle. The remote's **body** moves at the right speed (translation works), but the **legs keep playing whatever cycle was active before**.

User test: drive `+Acdream` (or any retail char) through `Press W (run) → release → Press shift+W (walk) → release` while observing in acdream's window. The body moves correctly but the leg cycle stays in idle pose / walk pose / whatever it was.

User-confirmed working perspectives:
- Local +Acdream's transitions in acdream **work** ✓
- +Acdream observed FROM a parallel retail client **work** ✓ (proves our outbound is fine)

So the bug is **specifically** in how acdream renders the visual cycle for an observed remote-driven character.

---

## What we shipped today (commits in chronological order)

```
0997f96  fix(motion): landing fallback + TurnLeft omega sign + vel diagnostic (L.3.2)
9960ce3  fix(motion): preserve signed TurnSpeed for remote turn animations
842dfcd  fix(motion): retail-faithful per-frame remote tick (L.3.2 follow-up)
b1d8e12  research(motion): cdb live trace of retail walk-to-run transition
a45c21e  fix(motion): retail-faithful remote tick — clear body.Velocity, drive via seqVel
c06b6c5  fix(motion): full queue reset on locomotion-cycle direct transitions  [partly reverted]
a2ae2ae  revert: AnimationSequencer locomotion-cycle full-reset and link-skip
357dcc0  fix(motion): SetCycle forces _currNode onto first newly-enqueued node;
         skip SubState commands in UM Commands list iteration
```

**User-confirmed wins from the above:**
- Body translation no longer races (was 2× server pace; now matches)
- Run-in-circles smooth (rectangle-effect gone — body rotates properly between UPs)
- Jump landing position correct (no mid-air force-land)
- Jump landing animation works (Falling → Ready visible)
- Turn-left visibly turns left (was animating right with snap-back)
- Signed TurnSpeed preserved (ACE encodes TurnLeft as `TurnCommand=TurnRight, Speed=negative`)

**User-confirmed remaining bugs:**
1. **Walk↔Run leg cycle on observed remotes does not visibly switch.** Body advances at correct new speed but legs continue playing previous cycle.
2. **Residual small "blip" corrections during steady-state motion** (run, walk, strafe). User describes this as a periodic micro-jitter — small but visible.
3. **(Possible)** ~20% steady-state walk overshoot (`maxSeqSpeed=3.120, serverSpeed≈2.6`) per VEL_DIAG measurements — not yet root-caused. May or may not be related to (2).

---

## What we proved about bug 1 (the cycle-doesn't-switch)

Per the diagnostic infrastructure built today:

| Signal | Result |
|---|---|
| `[FWD_WIRE]` — wire-arrival ForwardCommand transitions | ✅ ACE delivers `WalkForward → RunForward` (and direct walk↔run) correctly |
| `[CMD_LIST]` — Commands list at receive time | Empty for walk/run UMs; contains Ready/Action class for some others |
| `[HASCYCLE]` — does the dat have the requested cycle | ✅ True for both `0x44000007` (Run) and `0x45000005` (Walk) on style `0x8000003D` (NonCombat Humanoid) |
| `[SETCYCLE]` — animCycle picker calls into AnimationSequencer.SetCycle | ✅ Fires with correct (style, motion, speed) |
| `[SEQSTATE]` — per-tick `ae.Sequencer.CurrentMotion` for the observed remote | ✅ Holds the new motion correctly (e.g. shows `0x44000007 speed=2.939` after Run press, then `0x41000003 speed=1.000` after release) |

So:
- ACE wire data is correct.
- Our parser updates `InterpretedState` correctly.
- `OnLiveMotionUpdated` calls `SetCycle` with correct args.
- `SetCycle` updates the sequencer's `CurrentMotion` correctly.
- The cycle data the sequencer would play exists in the dat.

**But the visible leg cycle does NOT update.** Therefore the bug is **downstream of `ae.Sequencer.CurrentMotion`** — somewhere between the sequencer's internal state and the rendered MeshRefs:
- `AnimationSequencer.Advance(dt)` returning frames from the wrong node
- `BuildBlendedFrame()` reading from a stale `_currNode`
- `_currNode` advancing through stale link/head frames before reaching the new cycle
- Or how the per-part transforms returned by Advance get applied to the entity's `MeshRefs` for remote entities

We attempted a fix in `357dcc0` that forces `_currNode` onto the first newly-enqueued node in SetCycle — user reports **no visible change** after this fix.

---

## What's different between local (works) and remote (doesn't)

Both call **the same `AnimationSequencer.SetCycle` method** in `src/AcDream.Core/Physics/AnimationSequencer.cs:360`. So the sequencer code itself is shared.

Local +Acdream path:
- `PlayerMovementController` → `UpdatePlayerAnimation` (in `GameWindow.cs:6664`) → resolves cycle → `ae.Sequencer.SetCycle(...)`
- Fast-path early-return when cmd+speed unchanged (line 6713-6714)
- `OnLiveMotionUpdated` skips wire-echo SetCycle for the local player guid (line 2707)

Remote (observed retail char) path:
- Wire arrives → `OnLiveMotionUpdated` (`GameWindow.cs:3203`)
- "animCycle picker" at line 2842-2867 chooses the cycle based on Forward / Sidestep / Turn priority
- HasCycle fallback chain at line 2939
- `ae.Sequencer.SetCycle(fullStyle, cycleToPlay, animSpeed)` at line 2988
- Then iterates `update.MotionState.Commands` and routes each through `AnimationCommandRouter` (357dcc0 made this skip SubState class)
- Then ALSO updates `remoteMot.Motion.InterpretedState.ForwardCommand/ForwardSpeed` for body.Velocity computation
- Then ALSO calls `remoteMot.Motion.DoInterpretedMotion(...)` for sidestep/turn axes

**Hypotheses to investigate:**

A) After `SetCycle` fires, some other call in `OnLiveMotionUpdated` re-cycles the sequencer back. We've eliminated the `Commands` list (357dcc0 skip-SubState). Other candidates: `PlayAction` calls inside `RouteWireCommand`, the spawn-time SetCycle at line 2313, or something in `ApplyServerControlledVelocityCycle` (line 3238).

B) `_currNode` actually IS in the right place after SetCycle but `Advance(dt)` doesn't read from it correctly. Maybe a thread-safety issue (SetCycle on net thread, Advance on render thread, partial state visible).

C) `Advance` returns the right frames but `seqFrames` are not applied to the entity's `MeshRefs` for the remote entity specifically. Look at `GameWindow.cs:6510-6589` — the per-part transform application loop. There's no obvious local-vs-remote branch but worth tracing.

D) The MeshRefs themselves get rebuilt each frame and the rebuild reads from a different source for remotes. The `newMeshRefs` list is built per-frame at line 6567.

E) Local player's `ae.Sequencer.SetCycle` is called at a higher rate than remote's (per-input vs per-UM). Maybe the queue stays cleaner with frequent calls, and the bug is exposed only when SetCycle is sparse.

F) **Most likely** based on what we've seen: `Advance` plays through stale link frames before reaching the cycle. Our 357dcc0 fix forces `_currNode` onto the first newly-enqueued node — but for `Ready→Run`, the newly-enqueued sequence is `[Ready→Run link, Run cycle]`. `_currNode` lands on the **link**, the link plays for ~0.5–1 second, then the run cycle starts. User perceives the link's "transition pose" as "still walking / still idle."

---

## Diagnostic infrastructure available

All env-var gated on `ACDREAM_REMOTE_VEL_DIAG=1`:

| Diag | Where | What it shows |
|---|---|---|
| `[FWD_WIRE]` | `GameWindow.cs:2793-2800` | Each ForwardCommand transition received per remote |
| `[CMD_LIST]` | `GameWindow.cs:3119-3133` | Commands list contents at UM receive time |
| `[HASCYCLE]` | `GameWindow.cs:2939-2947` | HasCycle result for the requested cycle |
| `[SETCYCLE]` | `GameWindow.cs:2972-2986` | Each animCycle picker → SetCycle call |
| `[SEQSTATE]` | `GameWindow.cs:6520-6532` | Per-tick `ae.Sequencer.CurrentMotion` (1Hz throttled) |
| `[TURN_WIRE]` | `GameWindow.cs:3050-3057` | TurnCommand wire arrivals with signed speed |
| `[OMEGA_DIAG]` | `GameWindow.cs:5901-5912` | Per-tick omega being applied to body |
| `[VEL_DIAG]` | `GameWindow.cs:3327-3343` | Server-broadcast speed vs maxSeqSpeed per UP |

Also gated on `ACDREAM_INTERP_MANAGER=1` is the entire retail-faithful per-tick remote motion path. Set both env vars when reproducing.

The repo has `tools/cdb-scripts/` set up for live tracing of retail acclient.exe via cdb.exe. Two trace scripts already proven working:
- `walk_run_motion_trace.cdb` + `walk_run_motion_trace.log` — captured the exact retail walk→run sequence and proved retail uses `MotionTableManager::add_to_queue` without `truncate_animation_list`.

To launch retail tracing: have user start retail and connect, then in PowerShell:
```
& "C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe" `
  -pn acclient.exe -cf "tools\cdb-scripts\walk_run_motion_trace.cdb" *>&1 |
  Tee-Object -FilePath "tools\cdb-scripts\walk_run_motion_trace.log.console"
```
Auto-detaches at 200 hits via `.detach` (do NOT use `qd` per CLAUDE.md gotcha — silently ignored). NEVER `Stop-Process` cdb — takes retail down with it.

---

## What retail actually does (from cdb live trace)

For a walk→run direct transition retail's call sequence is:

```
[79]  CPhysicsObj::DoInterpretedMotion: motion=45000005   walk start (shift+W)
[82]  CMotionTable::DoObjectMotion: motion=45000005
[83]  MotionTableManager::add_to_queue: arg1=45000005 arg2=00000001  ← walk added looping

[89]  CPhysicsObj::DoInterpretedMotion: motion=44000007   run start (release shift)
[92]  CMotionTable::DoObjectMotion: motion=44000007
[93]  MotionTableManager::add_to_queue: arg1=44000007 arg2=00000001  ← run added looping

[104] CMotionTable::StopObjectMotion: motion=44000007    run end (release W)
```

`MotionTableManager::truncate_animation_list` was on bp the entire trace and **never fired**. Retail just appends new motions to the queue and lets `MotionTableManager::CheckForCompletedMotions` (`0x0051BE00`) and `MotionTableManager::remove_redundant_links` (`0x0051BF20`) handle the natural progression — neither of which we have ported.

This suggests our `AnimationSequencer.SetCycle` rebuild semantics (ClearCyclicTail + enqueue link + enqueue cycle) is fundamentally different from retail's "append-only" `MotionTableManager`. May not matter for visual output as long as our queue manipulations land in the same end state, but it's a structural mismatch worth exploring if the tactical fixes don't pan out.

---

## File locations

- **`src/AcDream.Core/Physics/AnimationSequencer.cs`** — SetCycle (line 360), Advance (690), BuildBlendedFrame (1254), ClearCyclicTail (1117), AdvanceToNextAnimation (1150), EnqueueMotionData (1101), LoadAnimNode (1037)
- **`src/AcDream.App/Rendering/GameWindow.cs`** — OnLiveMotionUpdated (3203), TickAnimations (5851), animCycle picker block (2842-2988), the seqFrames-to-MeshRefs application loop (6510-6635), UpdatePlayerAnimation (6664)
- **`src/AcDream.Core/Physics/AnimationCommandRouter.cs`** — RouteWireCommand (53), Classify (29)
- **`src/AcDream.Core/Physics/MotionInterpreter.cs`** — get_state_velocity (587), GetMaxSpeed (968), apply_current_movement (653), HitGround (924)
- **`src/AcDream.Core/Physics/PositionManager.cs`** — ComputeOffset (37) (the per-tick combiner)
- **`src/AcDream.Core/Physics/InterpolationManager.cs`** — Enqueue, AdjustOffset (224), stall detection
- **Reference decomp:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` (1.4M-line pseudo-C with full PDB names)
- **Symbols index:** `docs/research/named-retail/symbols.json` (greppable name → address)
- **Verbatim retail headers:** `docs/research/named-retail/acclient.h` (struct field offsets)

---

## Concrete next steps for the bug

1. **Add a per-tick diag that prints `_currNode.Anim.Id` + `_framePosition` for the observed remote.** This will conclusively answer whether `_currNode` is on the new cycle, on a stale link, or somewhere else. Implement near the existing SEQSTATE diag in `GameWindow.cs:6520`. Ask user to do the precise test sequence (W only, then shift+W only, no turns/no mouse) and read the log.

2. **Add a diag that prints `seqFrames[0].Origin` and `seqFrames[0].Orientation`** (the result of Advance) before applying to MeshRefs. If the values change meaningfully between cycles → bug is in MeshRefs application. If they're stuck → bug is in Advance/BuildBlendedFrame.

3. **Compare the call ORDER of SetCycle for local vs remote.** Maybe local's UpdatePlayerAnimation calls SetCycle then immediately also re-resolves cycle data and passes it through. Or local has frame-resolution state we lack for remotes.

4. **Try the retail-faithful additive `add_to_queue` semantics:** modify SetCycle to skip ClearCyclicTail and just append new motion data. The `MotionTableManager::CheckForCompletedMotions` cleanup we don't port might be needed — but a primitive version (drop nodes whose `IsLooping=true` count exceeds 1, keeping the newest) might suffice as a starting point.

5. **Trace retail's CSequence::update / update_internal calls live** with cdb to see what frames ARE returned per tick for a remote running and transitioning. We have the cdb toolchain set up; pattern existing scripts in `tools/cdb-scripts/`.

6. **If all else fails, dispatch a research agent** with the prompt below.

---

## For the next research agent — exact assignment

> Read this entire document.
>
> Read `src/AcDream.Core/Physics/AnimationSequencer.cs` end-to-end, focusing on:
> - `SetCycle` (line 360-560) — what state it mutates and in what order
> - `Advance` (line 690-784) — how it consumes the queue and what it returns
> - `BuildBlendedFrame` (line 1254-1313) — how the visible per-part transforms are computed
> - `ClearCyclicTail` (line 1117-1140) and `AdvanceToNextAnimation` (line 1150-1166) — node lifecycle in the queue
>
> Then read `src/AcDream.App/Rendering/GameWindow.cs:5851-6635` — the `TickAnimations` method including the dead-reckoning blocks, sequencer Advance call, and the seqFrames-to-MeshRefs application loop.
>
> Answer:
>
> 1. After `SetCycle` is called for `RunForward` (with `linkData != null` and `cycleData != null`), what is the precise queue state, the value of `_currNode`, and the value of `_framePosition` immediately after SetCycle returns? Trace step by step including ClearCyclicTail's effect on `_currNode`. Cite line numbers.
>
> 2. On the next render tick when `Advance(dt=0.0167)` is called, what does it do? Specifically, does it advance through the link frames first, or skip them, or play them and stop at the cycle? What pose does `BuildBlendedFrame` return at the end?
>
> 3. Is there any code path between `SetCycle` returning and the next `Advance` call that could RESET `_currNode` back to a stale node? List every SetCycle call site (there are ~12 in GameWindow.cs) and identify any that fire on the per-tick path (not just on UM receive).
>
> 4. Is there any difference in how `seqFrames` is consumed for the local player vs a remote-observed entity in the loop at lines 6566-6635? Both use `if (seqFrames is not null) { origin = seqFrames[i].Origin; ... }`. Find any conditional branch that bypasses seqFrames for remotes.
>
> Output: a concise (<800 word) report with line citations and a clear hypothesis for the root cause of the visible-cycle-doesn't-switch bug. Do NOT modify any code.

---

## Quick reproduction recipe

1. Start local ACE server (user has this running on `127.0.0.1:9000`).
2. Start a parallel **retail** acclient.exe and connect with a different character (NOT `+Acdream`).
3. Build acdream: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
4. Launch acdream from the main repo dir with both env vars:
   ```powershell
   $env:ACDREAM_INTERP_MANAGER = "1"
   $env:ACDREAM_REMOTE_VEL_DIAG = "1"
   $env:ACDREAM_DAT_DIR   = "$env:USERPROFILE\Documents\Asheron's Call"
   $env:ACDREAM_LIVE      = "1"
   $env:ACDREAM_TEST_HOST = "127.0.0.1"
   $env:ACDREAM_TEST_PORT = "9000"
   $env:ACDREAM_TEST_USER = "testaccount"
   $env:ACDREAM_TEST_PASS = "testpassword"
   dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
     Tee-Object -FilePath launch.log
   ```
5. From the retail client, drive the test character: stand 2s, press W (run) 4s, release, press shift+W (walk) 4s, release.
6. Observe the test character in the acdream window. Bug: leg cycle does NOT visibly switch between idle / run / walk poses.
7. Read diags from `launch.log` (UTF-16 — use `Get-Content -Encoding Unicode`).

---

## Notes on what NOT to do

- **Do not pass `skipTransitionLink: true` unconditionally to SetCycle** — tried in commit `c06b6c5` (link skip), broke landing-from-jump, sit-down, and every other transition that needs its dat link to play. Reverted in `a2ae2ae`.
- **Do not full-reset the queue on every motion change** — same commit, also reverted. Side effect: removed end-animations everywhere.
- **Do not "scale body.Velocity by observed serverSpeed/predictedSpeed"** — tried during the day, user explicitly rejected as a hack. Always use predicted velocity from `get_state_velocity` (= `RunAnimSpeed × ForwardSpeed`).
- **Do not `Stop-Process cdb`** while it's attached to retail — takes retail down with it (CLAUDE.md). Use `.detach` inside bp actions for graceful exit.
