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:
parent
a2ae2aefcc
commit
357dcc0547
2 changed files with 84 additions and 2 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue