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

@ -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;
}