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

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