fix(anim): remote-entity stop detection from position deltas

Root cause found from ACE source:
- Player_Tick.cs:368 — "the client will never send a 'client released
  forward' MoveToState in this scenario unfortunately"
- Server therefore can't broadcast a MotionCommand.Ready UpdateMotion
  when a remote player stops moving.
- Retail observer infers stopped state from position deltas going to
  zero, not from an explicit motion message.

Also found + fixed the UpdateMotion parser's 2-byte offset bug: ACE's
Align() pads based on absolute stream length (length=15 → 1 pad byte),
not relative-to-block. Previous parser assumed 3 pad bytes after the
MovementData header, which mis-aligned every subsequent field by 2.
After fix, stance/command/speed decode correctly for both server-
controlled NPCs (full stance 0x003D + cmd transitions) and remote
players (stance=0 meaning "no change" + per-axis commands).

OnLiveMotionUpdated rewrite: use SetCycle directly for sequencer
entities instead of routing through GetIdleCycle (which ignored
command when stance was 0). Preserve current style/motion when the
server omits a field ("no change" semantics). Reconstruct full
MotionCommand high byte from current motion or SubState mask.

Remote stop-detection: new _remoteLastMove dict tracks per-entity last
meaningful position + time. OnLivePositionUpdated updates only on
moves > 0.05m so the timestamp captures last actual movement.
TickAnimations checks every entity in a locomotion cycle; if their
last-move time is >400ms stale, swap sequencer to Ready. Excludes
player's own entity (driven by local input, not server observation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-19 10:18:34 +02:00
parent b092a6090d
commit 53f0110b89
2 changed files with 191 additions and 35 deletions

View file

@ -141,6 +141,15 @@ public sealed class GameWindow : IDisposable
private AcDream.Core.Vfx.ParticleSystem? _particleSystem;
private AcDream.Core.Vfx.ParticleHookSink? _particleSink;
// Remote-entity motion inference: tracks when each remote entity last
// moved meaningfully. Used in TickAnimations to swap to Ready when
// position has stalled for >StopIdleMs — retail observer pattern per
// ACE Player_Tick.cs line 368: the client never sends "released forward"
// MoveToState, so the server never broadcasts an explicit stop. Observer
// must infer it from position deltas.
private readonly Dictionary<uint, (System.Numerics.Vector3 Pos, System.DateTime Time)>
_remoteLastMove = new();
// Phase F.1-H.1 — client-side state classes fed by GameEventWiring.
// Exposed publicly so plugins + UI panels can bind directly.
public readonly AcDream.Core.Chat.ChatLog Chat = new();
@ -1301,29 +1310,33 @@ public sealed class GameWindow : IDisposable
// Re-resolve using the new stance/command. Keep the setup and
// motion-table we already know about — the server's motion
// updates override state within the same table, not swap tables.
//
// IMPORTANT: stance and command are BOTH optional. Remote-player
// autonomous broadcasts frequently set only one flag (e.g. just
// ForwardCommand) with currentStyle=0x0000 meaning "no stance
// change — keep current." Treating stance=0 as "default stance"
// drops the real state; instead we preserve the sequencer's
// current style.
ushort stance = update.MotionState.Stance;
ushort? command = update.MotionState.ForwardCommand;
var newCycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle(
ae.Setup, _dats,
motionTableIdOverride: null, // same table; already burned into ae.Animation
stanceOverride: stance,
commandOverride: command);
// Diagnostic: dump every inbound UpdateMotion so we can trace why
// remote chars don't transition off RunForward when they stop.
// Enable with ACDREAM_DUMP_MOTION=1.
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1"
&& update.Guid != _playerServerGuid)
{
string cmdStr = command.HasValue ? $"0x{command.Value:X4}" : "null";
float spd = update.MotionState.ForwardSpeed ?? 0f;
uint seqStyle = ae.Sequencer?.CurrentStyle ?? 0;
uint seqMotion = ae.Sequencer?.CurrentMotion ?? 0;
Console.WriteLine(
$"UM guid=0x{update.Guid:X8} stance=0x{stance:X4} cmd={cmdStr} spd={spd:F2} " +
$"| seq now style=0x{seqStyle:X8} motion=0x{seqMotion:X8}");
}
// If the new cycle is bad (null, framerate=0, or single-frame), do
// NOT remove the entity from the animated set. Keep its existing
// cycle running so it continues to breathe / idle. Removing on
// re-resolve failure was a bug that silently unregistered NPCs the
// moment the server sent a motion update with a stance/command
// pair the resolver couldn't translate cleanly. Defensive: switch
// only when we have a clearly better cycle.
bool newCycleIsGood = newCycle is not null
&& newCycle.Framerate != 0f
&& newCycle.HighFrame > newCycle.LowFrame
&& newCycle.Animation.PartFrames.Count > 1;
// Wire server-echoed RunRate BEFORE the animation early-return.
// If the cycle can't resolve (bad stance), we still need the speed.
// Wire server-echoed RunRate first — used for the player's own
// locomotion tuning regardless of whether a cycle resolves.
if (_playerController is not null
&& update.Guid == _playerServerGuid
&& update.MotionState.ForwardSpeed.HasValue
@ -1332,20 +1345,85 @@ public sealed class GameWindow : IDisposable
_playerController.ApplyServerRunRate(update.MotionState.ForwardSpeed.Value);
}
if (!newCycleIsGood)
return;
// (RunRate wiring moved above the early-return)
// Sequencer path
// ── Sequencer path (preferred) ──────────────────────────────────
// Call SetCycle directly. The sequencer already handles:
// - left→right / backward→forward remapping via adjust_motion
// - style and motion as u32 MotionCommand values
// - fast-path for identical state
//
// When the server omits a field (stance flag not set, or command
// flag not set), "no change" means we must preserve the sequencer's
// current state, NOT fall back to a table default.
if (ae.Sequencer is not null)
{
uint fullStyle = stance != 0 ? (0x80000000u | (uint)stance) : ae.Sequencer.CurrentStyle;
uint fullMotion = command is > 0 ? (uint)command.Value : 0x41000003u;
uint fullStyle = stance != 0
? (0x80000000u | (uint)stance)
: ae.Sequencer.CurrentStyle;
// If the server told us a command, use it. If command is 0,
// that's "stop / return to idle"; translate that into the
// style's default (typically Ready, 0x41000003 for NonCombat).
// If command is null ("not updated"), keep current motion.
uint fullMotion;
if (command.HasValue)
{
if (command.Value == 0)
{
// Stop — pick the style's default substate (Ready).
fullMotion = 0x41000003u;
}
else
{
// Restore the high byte of the MotionCommand; the server
// only transmits the low 16 bits of the 32-bit enum.
// Known patterns:
// 0x0003 (Ready) → 0x41000003
// 0x0005 (WalkForward) → 0x45000005
// 0x0007 (RunForward) → 0x44000007
// 0x000D (TurnRight) → 0x65000015 (close; use 0x65xx)
// We can reconstruct by masking in the high nibble from
// the sequencer's current motion (preserves class bits),
// but for the substate-class commands the cycle lookup
// uses only the low 24 bits anyway. Just OR in the
// SubState class byte if none is present.
uint cmdLo = (uint)command.Value;
fullMotion = (cmdLo & 0xFF000000u) != 0
? cmdLo
: (ae.Sequencer.CurrentMotion & 0xFF000000u) | cmdLo;
if (fullMotion == cmdLo) // nothing in current; mark as SubState
fullMotion = 0x40000000u | cmdLo;
}
}
else
{
fullMotion = ae.Sequencer.CurrentMotion;
}
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1"
&& update.Guid != _playerServerGuid)
Console.WriteLine(
$"UM ↳ SetCycle(style=0x{fullStyle:X8}, motion=0x{fullMotion:X8})");
// No-op if same; the sequencer's fast path guards against that.
ae.Sequencer.SetCycle(fullStyle, fullMotion);
return;
}
// Legacy path
// ── Legacy path (entities without a sequencer) ──────────────────
// Here we DO use GetIdleCycle because the legacy tick loop needs
// a concrete Animation + frame range. Only swap when the resolver
// returns a clearly-better cycle.
var newCycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle(
ae.Setup, _dats,
motionTableIdOverride: null,
stanceOverride: stance,
commandOverride: command);
bool newCycleIsGood = newCycle is not null
&& newCycle.Framerate != 0f
&& newCycle.HighFrame >= newCycle.LowFrame
&& newCycle.Animation.PartFrames.Count >= 1;
if (!newCycleIsGood) return;
ae.Animation = newCycle!.Animation;
ae.LowFrame = Math.Max(0, newCycle.LowFrame);
ae.HighFrame = Math.Min(newCycle.HighFrame, newCycle.Animation.PartFrames.Count - 1);
@ -1388,6 +1466,25 @@ public sealed class GameWindow : IDisposable
entity.Position = worldPos;
entity.Rotation = rot;
// Track remote-entity motion for stop detection. Only record the
// timestamp when position moved MEANINGFULLY (> 0.05m). Updates
// that report the same position keep the old Time, so the
// TickAnimations check can see when motion last changed.
if (update.Guid != _playerServerGuid)
{
if (_remoteLastMove.TryGetValue(update.Guid, out var prev))
{
float moveDist = System.Numerics.Vector3.Distance(prev.Pos, worldPos);
if (moveDist > 0.05f)
_remoteLastMove[update.Guid] = (worldPos, System.DateTime.UtcNow);
// else: leave old entry so "Time" = last real movement time
}
else
{
_remoteLastMove[update.Guid] = (worldPos, System.DateTime.UtcNow);
}
}
// Phase B.3: portal-space arrival detection.
// Only runs for our own player character while in PortalSpace.
if (_playerController is not null
@ -3033,10 +3130,50 @@ public sealed class GameWindow : IDisposable
/// </summary>
private void TickAnimations(float dt)
{
// Stop-detection window: if a remote entity is in a locomotion
// cycle but hasn't moved meaningfully in this many ms, swap them
// to Ready. Retail observer pattern — server never broadcasts an
// explicit stop; observer infers from position deltas.
const double StopIdleMs = 400.0;
var now = System.DateTime.UtcNow;
foreach (var kv in _animatedEntities)
{
var ae = kv.Value;
// ── Remote stop-detection: if this entity's sequencer is in a
// locomotion cycle and their position hasn't changed in >400ms,
// the retail player stopped moving. Swap them to Ready. This
// replaces the never-arriving "released forward" UpdateMotion.
if (ae.Sequencer is not null)
{
uint motionLo = ae.Sequencer.CurrentMotion & 0xFFu;
bool inLocomotion = motionLo == 0x05 // WalkForward
|| motionLo == 0x06 // WalkBackward
|| motionLo == 0x07 // RunForward
|| motionLo == 0x0F // SideStepRight
|| motionLo == 0x10; // SideStepLeft
// Locate the server guid for this entity (reverse lookup).
// Skip the player's own entity — we drive our own anim locally.
uint serverGuid = 0;
foreach (var esg in _entitiesByServerGuid)
{
if (ReferenceEquals(esg.Value, ae.Entity)) { serverGuid = esg.Key; break; }
}
if (inLocomotion
&& serverGuid != 0
&& serverGuid != _playerServerGuid
&& _remoteLastMove.TryGetValue(serverGuid, out var last)
&& (now - last.Time).TotalMilliseconds > StopIdleMs)
{
uint curStyle = ae.Sequencer.CurrentStyle;
uint ready = (curStyle & 0xFF000000u) != 0
? ((curStyle & 0xFF000000u) | 0x01000003u)
: 0x41000003u;
ae.Sequencer.SetCycle(curStyle, ready);
}
}
// ── Get per-part (origin, orientation) from either sequencer or legacy ──
IReadOnlyList<AcDream.Core.Physics.PartTransform>? seqFrames = null;
if (ae.Sequencer is not null)

View file

@ -86,13 +86,23 @@ public static class UpdateMotion
pos += 2;
// MovementData header: u16 movementSequence, u16 serverControlSequence,
// u8 isAutonomous, then align to 4. The header bytes total 6 (2+2+1+1 pad),
// because 2+2+1 = 5, and aligning to 4 from offset 5 needs 3 pad bytes...
// Actually, ACE's writer.Align() pads the CURRENT BaseStream position
// after writing the byte, so after u16 + u16 + u8 we're at 5 bytes into
// MovementData; alignment rounds up to 8. So the header slot is 8 bytes.
if (body.Length - pos < 8) return null;
pos += 8;
// u8 isAutonomous, then Align().
//
// ACE's Align() (Network/Extensions.cs:55) uses
// CalculatePadMultiple(BaseStream.Length, 4) — i.e. it pads based on
// the ABSOLUTE stream length, not a relative offset within the
// MovementData block.
//
// At this point the absolute stream has: opcode (4) + guid (4) +
// objectInstance (2) + movSeq (2) + srvSeq (2) + isAut (1) = 15.
// Align(4) rounds 15 → 16, so ONE pad byte is written.
// MovementData header = 2+2+1+1 = 6 bytes.
//
// Previous version mistakenly reserved 8 bytes here, which shifted
// every subsequent field by 2 and made every remote-char UpdateMotion
// decode as garbage (stance read from the packed-flags dword).
if (body.Length - pos < 6) return null;
pos += 6;
// movementType u8, motionFlags u8, currentStyle u16
if (body.Length - pos < 4) return null;
@ -101,6 +111,15 @@ public static class UpdateMotion
ushort currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
{
int preHex = Math.Min(body.Length, 32);
var hex = new System.Text.StringBuilder();
for (int i = 0; i < preHex; i++) hex.Append($"{body[i]:X2} ");
System.Console.WriteLine(
$" UM raw: mt=0x{movementType:X2} mf=0x{_motionFlags:X2} cs=0x{currentStyle:X4} | {hex}");
}
ushort? forwardCommand = null;
float? forwardSpeed = null;