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
// 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<AcDream.Core.Physics.PartTransform>? 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