From 357dcc0547c4ed1cb52cd8a56b1b2d39fde49207 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 3 May 2026 19:54:54 +0200 Subject: [PATCH] fix(motion): SetCycle forces _currNode onto first newly-enqueued node; skip SubState commands in UM Commands list iteration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes for the "remote-driven character animation cycle does not visibly switch" bug: 1. AnimationSequencer.SetCycle now snapshots _queue.Last BEFORE appending the new link/cycle nodes, then forces _currNode onto preEnqueueTail.Next (= first newly-added node). Without this, _currNode could stay pointing into stale non-cyclic head frames left over from the previous cycle (typically a Walk_link or Ready_link's tail), and the visible animation continues playing those stale frames before the queue advances naturally to the new cycle. Local player avoided the bug because PlayerMovementController fires SetCycle in a tight per-input loop that keeps the queue clean; remote player accumulates stale link drains across many bundled UMs. 2. OnLiveMotionUpdated's UM Commands list iteration now skips SubState class commands (high byte 0x40-0x4F like Ready 0x41000003). The router's SetCycle call for those would silently override the animCycle picker's own SetCycle a few lines above in the same UM packet — verified via SETCYCLE diag captures showing run/walk being immediately re-cycled to Ready. Only Action / Modifier / ChatEmote class commands (overlays that interleave with the cycle) belong in this list iteration. This fixed the landing-from-jump animation issue (user-confirmed: "landing now works"). Walk↔run direct transitions still don't visibly switch the leg cycle for observed retail-driven characters even though ae.Sequencer.CurrentMotion correctly transitions (per-tick SEQSTATE diag added — proves the sequencer's logical state holds the right motion). Bug is somewhere downstream of SetCycle's queue/state setup, possibly in Advance/BuildBlendedFrame or in how seqFrames are applied to MeshRefs for remote entities. Filed for next investigation. Adds env-var-gated diagnostics (ACDREAM_REMOTE_VEL_DIAG=1): CMD_LIST — what's in the UM's Commands list at receive time HASCYCLE — whether the requested cycle exists in the dat SEQSTATE — per-tick sequencer.CurrentMotion + CurrentSpeedMod for the observed retail char (1Hz throttled) Co-Authored-By: Claude Opus 4.7 --- src/AcDream.App/Rendering/GameWindow.cs | 55 +++++++++++++++++++ .../Physics/AnimationSequencer.cs | 31 ++++++++++- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 43ba1c1..8c7bc94 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -3113,10 +3113,46 @@ public sealed class GameWindow : IDisposable // InterpretedMotionState.Commands[]; the router reconstructs the // class byte and chooses PlayAction for actions/modifiers/emotes // or SetCycle for persistent substates. + // + // 2026-05-03: SKIP SubState class commands (high-byte 0x40-0x4F). + // The animCycle picker above already chose the correct SubState + // cycle based on Forward/Sidestep/Turn command priority and just + // called SetCycle for it. Letting the Commands list also call + // SetCycle(SubState) would OVERRIDE our chosen cycle — e.g. ACE + // bundles Ready (0x41000003) into the Commands list of a + // RunForward UpdateMotion (cdb trace 2026-05-03 confirmed retail + // does the same), and our router would silently re-cycle the + // sequencer back to Ready right after we set RunForward. That's + // why observed retail-driven characters never visibly switched + // their leg cycle even though SETCYCLE diags fired correctly: + // a second SetCycle call wiped the first within the same UM + // packet processing. Only Actions/Modifiers/ChatEmotes (overlays + // that interleave with the cycle) belong in the list iteration. if (update.MotionState.Commands is { Count: > 0 } cmds) { + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") + { + var sb = new System.Text.StringBuilder(); + sb.Append($"[CMD_LIST] guid={update.Guid:X8} fwd=0x{fullMotion:X8} cmds=["); + for (int i = 0; i < cmds.Count; i++) + { + if (i > 0) sb.Append(", "); + uint fc = AcDream.Core.Physics.MotionCommandResolver + .ReconstructFullCommand(cmds[i].Command); + var rt = AcDream.Core.Physics.AnimationCommandRouter.Classify(fc); + sb.Append($"0x{fc:X8}({rt})"); + } + sb.Append("]"); + System.Console.WriteLine(sb.ToString()); + } foreach (var item in cmds) { + uint fullItemCommand = AcDream.Core.Physics.MotionCommandResolver + .ReconstructFullCommand(item.Command); + var itemRoute = AcDream.Core.Physics.AnimationCommandRouter + .Classify(fullItemCommand); + if (itemRoute == AcDream.Core.Physics.AnimationCommandRouteKind.SubState) + continue; AcDream.Core.Physics.AnimationCommandRouter.RouteWireCommand( ae.Sequencer, fullStyle, @@ -6475,6 +6511,25 @@ public sealed class GameWindow : IDisposable IReadOnlyList? seqFrames = null; if (ae.Sequencer is not null) { + // Per-tick sequencer-state diag: prove whether the sequencer + // for the observed retail char actually holds the latest + // motion (= SetCycle landed) OR is stuck on an old motion + // (= something elsewhere is reverting). Throttled to once + // per second per remote. + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1" + && serverGuid != 0 + && serverGuid != _playerServerGuid) + { + double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; + if (_remoteDeadReckon.TryGetValue(serverGuid, out var rmDiag) + && nowSec - rmDiag.LastOmegaDiagLogTime > 1.0) + { + System.Console.WriteLine( + $"[SEQSTATE] guid={serverGuid:X8} CurrentMotion=0x{ae.Sequencer.CurrentMotion:X8} " + + $"CurrentSpeedMod={ae.Sequencer.CurrentSpeedMod:F3}"); + rmDiag.LastOmegaDiagLogTime = nowSec; + } + } seqFrames = ae.Sequencer.Advance(dt); // Phase E.1: drain animation hooks (footstep sounds, attack diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index fb33c0f..7fc7e68 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -447,6 +447,21 @@ public sealed class AnimationSequencer // add_motion chain (MotionTable.cs L100-L101, L152-L153). ClearPhysics(); + // Snapshot the queue tail BEFORE appending new motion data so we + // can locate the first newly-added node afterward and force + // _currNode onto it. Without this, _currNode can stay pointing + // into stale non-cyclic head frames left over from the previous + // cycle (typically a Walk_link or Ready_link's tail), and the + // visible animation continues playing those stale frames before + // the queue advances naturally to the new cycle. For remote + // entities receiving many bundled UMs over time, this stale-head + // build-up was the root cause of "transitions between cycles + // don't visibly switch the leg pose" even though SetCycle's + // CurrentMotion/CurrentSpeedMod were updated correctly. Local + // player avoided the bug because PlayerMovementController fires + // SetCycle in a tight per-input loop that keeps the queue clean. + var preEnqueueTail = _queue.Last; + // Enqueue link frames (with adjusted speed for left→right remapping). if (linkData is { Anims.Count: > 0 }) EnqueueMotionData(linkData, adjustedSpeed, isLooping: false); @@ -478,9 +493,21 @@ public sealed class AnimationSequencer } } - // If we have no current anim, start at the beginning of the queue. - if (_currNode == null) + // Force _currNode onto the FIRST NEWLY-ENQUEUED node so the + // visible animation switches to the new cycle/link immediately + // instead of finishing whatever stale head frames were sitting + // at the front of the queue. preEnqueueTail.Next is the first + // newly-added node; if preEnqueueTail was null (queue was empty + // before enqueue), the first new node is _queue.First. + var firstNew = preEnqueueTail is null ? _queue.First : preEnqueueTail.Next; + if (firstNew is not null) { + _currNode = firstNew; + _framePosition = _currNode.Value.GetStartFramePosition(); + } + else if (_currNode == null) + { + // Defensive fallback: nothing newly added AND no current node. _currNode = _queue.First; _framePosition = _currNode?.Value.GetStartFramePosition() ?? 0.0; }