fix(motion): SetCycle forces _currNode onto first newly-enqueued node;

skip SubState commands in UM Commands list iteration

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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-03 19:54:54 +02:00
parent a2ae2aefcc
commit 357dcc0547
2 changed files with 84 additions and 2 deletions

View file

@ -3113,10 +3113,46 @@ public sealed class GameWindow : IDisposable
// InterpretedMotionState.Commands[]; the router reconstructs the // InterpretedMotionState.Commands[]; the router reconstructs the
// class byte and chooses PlayAction for actions/modifiers/emotes // class byte and chooses PlayAction for actions/modifiers/emotes
// or SetCycle for persistent substates. // 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 (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) 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( AcDream.Core.Physics.AnimationCommandRouter.RouteWireCommand(
ae.Sequencer, ae.Sequencer,
fullStyle, fullStyle,
@ -6475,6 +6511,25 @@ public sealed class GameWindow : IDisposable
IReadOnlyList<AcDream.Core.Physics.PartTransform>? seqFrames = null; IReadOnlyList<AcDream.Core.Physics.PartTransform>? seqFrames = null;
if (ae.Sequencer is not 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); seqFrames = ae.Sequencer.Advance(dt);
// Phase E.1: drain animation hooks (footstep sounds, attack // Phase E.1: drain animation hooks (footstep sounds, attack

View file

@ -447,6 +447,21 @@ public sealed class AnimationSequencer
// add_motion chain (MotionTable.cs L100-L101, L152-L153). // add_motion chain (MotionTable.cs L100-L101, L152-L153).
ClearPhysics(); 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). // Enqueue link frames (with adjusted speed for left→right remapping).
if (linkData is { Anims.Count: > 0 }) if (linkData is { Anims.Count: > 0 })
EnqueueMotionData(linkData, adjustedSpeed, isLooping: false); 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. // Force _currNode onto the FIRST NEWLY-ENQUEUED node so the
if (_currNode == null) // 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; _currNode = _queue.First;
_framePosition = _currNode?.Value.GetStartFramePosition() ?? 0.0; _framePosition = _currNode?.Value.GetStartFramePosition() ?? 0.0;
} }