# Locomotion-cycle transitions — static-analysis findings **Date:** 2026-05-06 (follow-up to `investigation-prompt.md`) **Status:** static analysis complete; candidate fix shipped for case #1; cases #2–#7 deferred until TTD validation. 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`.