# Locomotion-cycle transitions — static-analysis findings

**Date:** 2026-05-06 (follow-up to `investigation-prompt.md`)
**Status:** static analysis complete; **candidate fix INEFFECTIVE per visual verify** — see "Visual-verify result" at bottom; root cause is the same as 2026-05-03 hypothesis F (cycle stuck downstream of `Sequencer.CurrentMotion`).

This is what code reading + ACE cross-reference + named-retail grep
established before any TTD/cdb trace was run. It scopes the candidate
fix and identifies which questions still need the trace.

---

## What's confirmed by static analysis

### 1. ACE broadcasts unconditionally on inbound MoveToState

`references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionMoveToState.cs:36`
calls `session.Player.BroadcastMovement(moveToState)` for every received
MoveToState, no diff-check. So **whether ACE broadcasts a UM is determined
purely by whether the client sent a MoveToState**.

### 2. ACE auto-upgrades `WalkForward + HoldKey.Run → RunForward` on broadcast

`references/ACE/Source/ACE.Server/Network/Motion/MovementData.cs:110-113`
shows the conversion: when client sends `WalkForward + HoldKey.Run`, the
broadcast `interpState.ForwardCommand` is upgraded to `RunForward`. So
the broadcast UM byte differs between Run and Walk even though the
client's wire byte is the same `WalkForward`.

### 3. Therefore the bug is one of these two

Either:
- **A.** Retail (the actor) does NOT send a fresh MoveToState when only
  HoldKey changes (Shift toggle while a direction key is held). ACE has
  nothing to broadcast → no UM arrives at the observer. UPs continue
  with new velocity (run-pace ↔ walk-pace), but observer's UM-driven
  cycle never updates.
- **B.** Retail DOES send a fresh MoveToState. ACE broadcasts a UM. Our
  parser receives it but fails to apply the cycle change for some
  reason.

The diagnostic data captured in 2026-05-03's investigation
(`[FWD_WIRE]`, `[UM_RAW]`, `[SETCYCLE]`) is more consistent with **A**:
no `[FWD_WIRE]` line was logged for Shift toggles, suggesting no UM
arrived. But that data was for a different scenario, so a fresh trace
is needed.

**This question is the primary gap the TTD/cdb trace must close.**
A 2-minute cdb session with a breakpoint counter on
`acclient!CommandInterpreter::SendMovementEvent` answers it
definitively.

### 4. `ApplyServerControlledVelocityCycle` was gated against player remotes at three sites

In `src/AcDream.App/Rendering/GameWindow.cs` before this change:

- **Inner gate**, function entry (~line 3326):
  `if (IsPlayerGuid(serverGuid)) return;`
- **Outer gate**, call site in `OnLivePositionUpdated` (~line 3683):
  `if (!IsPlayerGuid(update.Guid) && rmState.HasServerVelocity ...)`
- **TickAnimations gate** (~line 6325):
  `if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity)` —
  this one is for the "stale velocity → reset to zero" path, used by
  NPCs whose MoveTo has expired. Leave as-is for now; player remotes
  don't go through this branch (no MoveTo path).

### 5. `ServerControlledLocomotion.PlanFromVelocity` thresholds are wrong for player speeds

In `src/AcDream.Core/Physics/ServerControlledLocomotion.cs:30-33`:

```csharp
public const float StopSpeed = 0.20f;
public const float RunThreshold = 1.25f;
```

For player speeds (Walk ≈ 2.5 m/s, Run ≈ 9 m/s — both observed in
prior `[VEL_DIAG]` traces), `RunThreshold = 1.25` is below both
buckets. Both speeds resolve to `RunForward`. So even if we just
un-gated the existing function for player remotes, the function would
*always* return `RunForward` and the bug would persist.

The function works correctly for NPCs (typical NPC speed range
1–6 m/s falls on the right side of the 1.25 threshold for the
Idle/Walk/Run distinction).

**Implication:** the player path must use different thresholds. The
candidate fix introduces hysteresis tuned for player speeds (4.5 demote,
5.5 promote) and routes player remotes through a dedicated
`ApplyPlayerLocomotionRefinement` helper instead of `PlanFromVelocity`.

### 6. `PlanFromVelocity` returns Forward-only motions

It returns `Ready`, `WalkForward`, or `RunForward` — never sidestep,
never backward. Even with thresholds fixed, calling it for a sidestepping
player remote would clobber `SideStepLeft`/`Right` with `RunForward`.

**Implication:** the player path must scope itself to forward direction
only; sidestep/backward HoldKey toggles are deferred until TTD confirms
retail's per-direction algorithm.

---

## What the candidate fix does (and doesn't)

**Does:**

- Adds `RemoteMotion.LastUMTime` (timestamp of most recent UM for a remote).
- Stamps `LastUMTime` at the top of `OnLiveMotionUpdated`.
- Removes the `IsPlayerGuid` early return inside
  `ApplyServerControlledVelocityCycle`.
- Removes the `!IsPlayerGuid` gate at the outer call site in
  `OnLivePositionUpdated` (so player remotes get the function called).
- Routes player remotes through new `ApplyPlayerLocomotionRefinement`:
  - 500 ms UM grace window (UMs are authoritative).
  - Forward-direction-only refinement (low byte 0x05 / 0x07).
  - Hysteresis: Run → Walk demote at < 4.5 m/s; Walk → Run promote at
    > 5.5 m/s.
  - SetCycle skipped if neither motion ID nor speedMod changed
    meaningfully.
  - Diagnostic `[UPCYCLE_PLAYER]` line gated on `ACDREAM_REMOTE_VEL_DIAG=1`.

**Does NOT (deferred to TTD-validated follow-up):**

- Backward (case #2) HoldKey toggle.
- Sidestep speed-bucket refinement (cases #4, #5).
- Direction-flip transitions (cases #3, #6, #7) — these are believed
  to work today via UM-driven SetCycle, but the prompt's acceptance
  criteria explicitly call for visual confirmation of all 7 cases.
- Tuning the grace window or the hysteresis thresholds against retail's
  exact behavior. 500 ms / 4.5 / 5.5 are defensible defaults but TTD
  may show retail uses different values.

---

## Acceptance for the candidate fix (case #1 only)

When acdream observes a retail-driven character:

- Hold W (run) for 2 s, then press Shift, then release Shift, then
  release W. The visible leg cycle should switch Run → Walk → Run
  → Idle within ~200–500 ms of each transition.
- All other working cases (acdream-on-acdream, retail-on-acdream,
  Idle ↔ Run, Idle ↔ Walk) must still work — no regressions.

If the candidate fix produces visible Run↔Walk transitions on retail
actors, ship it (close part of #39, file the remaining 6 cases as a
follow-up issue) and run TTD on cases #2–#7 next.

If it doesn't switch the cycle (or thrashes / regresses something),
the next investigation step is the cdb breakpoint count on
`CommandInterpreter::SendMovementEvent` to settle question A vs. B
above before any further fix attempt.

---

## Files touched by the candidate fix

- `src/AcDream.App/Rendering/GameWindow.cs`
  - Added `RemoteMotion.LastUMTime` field
  - Stamped `LastUMTime` at top of `OnLiveMotionUpdated`
  - Removed inner `IsPlayerGuid` gate in `ApplyServerControlledVelocityCycle`
  - Routed player remotes to new `ApplyPlayerLocomotionRefinement`
  - Added constants `UmGraceSeconds`, `PlayerRunPromoteSpeed`,
    `PlayerRunDemoteSpeed`
  - Removed `!IsPlayerGuid` gate at outer call site in `OnLivePositionUpdated`

No changes to `ServerControlledLocomotion.cs`, `MotionInterpreter.cs`,
or `AnimationSequencer.cs`.

---

## Visual-verify result (2026-05-06, evening)

**The candidate fix did not switch the cycle.** User report: "if I shift
walk and then release shift it just blips forward in walking animation."

Diagnostic dump from `launch-39-candidate.log` (filtered to the
retail-driven actor, guid `0x50000001`):

| Diag | Result |
|---|---|
| `[FWD_WIRE]` | **Many direct Walk↔Run transitions present** — `oldCmd=0x44000007 → newCmd=0x45000005` and reverse. ACE IS broadcasting UMs on Shift toggle (refutes the static-analysis hypothesis that retail doesn't send MoveToState on HoldKey-only changes). |
| `[UM_RAW]` `seq.CurrentMotion` distribution for `0x50000001` | 0x41000003: 63, 0x45000005: 52, 0x6500000D: 31, 0x44000007: 20, 0x6500000F: 6 — i.e., the sequencer's `CurrentMotion` field DOES change to Walk / Run / Turn / Sidestep at various sample moments. |
| `[SETCYCLE]` | Many direct Walk↔Run SetCycle calls — `old=(motion=0x45000005…) new=(motion=0x44000007…)` — confirming the picker correctly resolves Run cycle from the inbound UM. |
| `[SCFULL]` | After Walk→Run SetCycle: `currNodeIsCyclic=False`, `qCount=12, 13, 14, … 41, 42, 49`. Queue grows steadily over ~30 transitions; `_currNode` lands on a non-cyclic transition link rather than the new cycle. |
| `[PARTSDIAG]` `seqHash` | 447 distinct values across 467 samples — animation IS being rendered, frames ARE changing. The cycle just isn't the right one. |
| `[UPCYCLE_PLAYER]` | **Never fired.** The 500 ms UM grace window blocks every call (UMs arrive at >2 Hz, well within the grace), which is the correct behavior — UMs are authoritative when fresh. |

### What this proves

1. **Wire is correct.** ACE delivers UMs on Shift toggle (refutes
   hypothesis A from this doc, refutes the original 2026-05-06
   investigation-prompt's wire-level hypothesis).
2. **Dispatch is correct.** `OnLiveMotionUpdated` parses each UM,
   resolves the right cycle, calls `AnimationSequencer.SetCycle`.
3. **Sequencer state updates.** `Sequencer.CurrentMotion` reaches the
   new motion ID (Run, Walk, Turn, Sidestep all observed in `[UM_RAW]`).
4. **Visible animation does NOT match `CurrentMotion`.** This is exactly
   the same residual symptom the 2026-05-03 investigation chased and
   the SetCycle force-`_currNode` fix in commit `357dcc0` attempted but
   did not fully resolve.

### Where the bug actually lives

Downstream of `Sequencer.CurrentMotion`, in one of:

- `AnimationSequencer.Advance(dt)` consuming the wrong queue node
- `AnimationSequencer.BuildBlendedFrame()` reading from a stale
  `_currNode`
- The transition link from old-cycle to new-cycle never advancing to
  the cycle (link plays as the old motion)
- Queue-management gap: retail's `MotionTableManager::CheckForCompletedMotions` /
  `remove_redundant_links` (named-retail addresses 0x0051BE00 / 0x0051BF20)
  are not ported. Our `qCount=49` after ~30 transitions confirms
  unbounded queue growth — but `_currNode` should still be on the most
  recent SetCycle's first node, so this alone may not be the bug.

The 2026-05-03 prompt's hypothesis F is the most likely:

> `Advance` plays through stale link frames before reaching the cycle.
> Our `357dcc0` fix forces `_currNode` onto the first newly-enqueued
> node — but for `Walk→Run`, the newly-enqueued sequence is `[walk-to-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."

The SCFULL data (`currNodeIsCyclic=False` after every Walk↔Run direct
transition) is consistent with this — `_currNode` is on the link,
not the cycle, immediately after SetCycle.

### What to do next

The candidate fix code (`LastUMTime`, `ApplyPlayerLocomotionRefinement`,
hysteresis constants, un-gated call site) is **harmless but ineffective**
— left in place because:
- The infrastructure may be useful for cases #2–#7 if/when those turn
  out to need velocity fallback.
- The UM grace window is correctly preventing it from interfering with
  the working UM-driven path.
- Reverting would lose context tied to the findings doc.

The next investigation should target **AnimationSequencer.Advance / queue
management for cyclic→cyclic direct transitions** — this is the same
problem 2026-05-03 left open. Recommended path:

1. Add per-tick diag for `_currNode.Anim.Id` + `_framePosition` (per
   the 2026-05-03 prompt's "Concrete next steps" §1).
2. Cross-reference retail's `CSequence::update` via cdb live attach OR
   read it in named-retail (it's named — search `acclient_2013_pseudo_c.txt`
   for `CSequence::update_internal`).
3. Compare retail's queue-handling for direct cyclic→cyclic with our
   `AnimationSequencer.Advance` — particularly the conditions under which
   `_currNode` advances past a link to the next cycle.
