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

12 KiB
Raw Blame History

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:

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 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 presentoldCmd=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.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.