acdream/docs/research/2026-05-06-locomotion-cycle-transitions/findings-static.md
Erik 5660f3483d docs(motion): #39 — candidate fix ineffective; refute Shift-toggle wire hypothesis
Visual-verify of commit 8fa04af in launch-39-candidate.log refutes the
static-analysis hypothesis that retail does not broadcast UMs on
HoldKey-only changes. The log shows:

- [FWD_WIRE] for retail actor 0x50000001 contains many direct Walk↔Run
  transitions (0x44000007 ↔ 0x45000005). ACE IS sending UMs on Shift
  toggle.
- [SETCYCLE] fires correctly per UM; Sequencer.CurrentMotion cycles
  through Walk / Run / Turn / Sidestep correctly per [UM_RAW].
- [UPCYCLE_PLAYER] never fired — UM grace correctly suppressed it
  (UMs at >2 Hz, well within 500 ms grace).
- User reports legs visually stuck in walking animation despite the
  wire/sequencer saying Run.

Conclusion: bug is downstream of Sequencer.CurrentMotion — same as
2026-05-03 hypothesis F. Most likely _currNode lands on the walk-to-run
transition link after SetCycle (`currNodeIsCyclic=False` confirmed in
[SCFULL]) and Advance does not progress past it to the cycle.

The candidate fix code (LastUMTime, ApplyPlayerLocomotionRefinement,
hysteresis constants, un-gated call site) is left in place — harmless
because UM grace blocks the velocity-fallback path while UMs arrive,
and the infrastructure may be useful for cases #2–#7 if those need
velocity fallback. But it does not close case #1.

Updates ISSUES.md #39 with refuted hypothesis + new evidence + next
step pointer. findings-static.md gains "Visual-verify result" §
documenting the diagnostic dump and recommending the next investigation
target be AnimationSequencer.Advance queue-handling.

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

252 lines
12 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 — 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
16 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 ~200500 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 RunWalk 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 WalkRun SetCycle calls `old=(motion=0x45000005…) new=(motion=0x44000007…)` confirming the picker correctly resolves Run cycle from the inbound UM. |
| `[SCFULL]` | After WalkRun 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.51 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.