diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index e2c8925..b7a7d41 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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 + _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 /// 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? seqFrames = null; if (ae.Sequencer is not null) diff --git a/src/AcDream.Core.Net/Messages/UpdateMotion.cs b/src/AcDream.Core.Net/Messages/UpdateMotion.cs index edc9717..5a74e76 100644 --- a/src/AcDream.Core.Net/Messages/UpdateMotion.cs +++ b/src/AcDream.Core.Net/Messages/UpdateMotion.cs @@ -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;