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>
252 lines
12 KiB
Markdown
252 lines
12 KiB
Markdown
# 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.
|