diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index 3502b52..83fcd96 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -131,6 +131,9 @@ public sealed class PlayerMovementController private uint? _prevForwardCmd; private uint? _prevSidestepCmd; private uint? _prevTurnCmd; + private float? _prevForwardSpeed; + private bool _prevRunHold; + private uint? _prevLocalAnimCmd; // Heartbeat timer. private float _heartbeatAccum; @@ -449,12 +452,40 @@ public sealed class PlayerMovementController } // ── 7. Detect motion state change ───────────────────────────────────── - bool changed = outForwardCmd != _prevForwardCmd - || outSidestepCmd != _prevSidestepCmd - || outTurnCmd != _prevTurnCmd; - _prevForwardCmd = outForwardCmd; - _prevSidestepCmd = outSidestepCmd; - _prevTurnCmd = outTurnCmd; + // Bug fix: ForwardCommand can stay the same (WalkForward) while ONLY + // ForwardSpeed or the run-hold bit changes. If the user is already + // walking (W held), then presses Shift, the outbound wire still has + // ForwardCommand=WalkForward but outForwardSpeed jumps from 1.0 to + // runRate. Without also tracking speed + hold-key here, no new + // MoveToState is sent — the server keeps thinking the player walks, + // and retail observers render walking animation despite the local + // player's RunForward cycle. + // + // Similarly LocalAnimationCommand change (Walk→Run on local cycle) + // must force a fresh outbound so ACE's BroadcastMovement re-runs + // MovementData(this, moveToState) which only reads ForwardCommand + + // ForwardSpeed + HoldKey to pick between WalkForward vs RunForward + // for remote observers. + bool runHold = input.Run; + bool changed = outForwardCmd != _prevForwardCmd + || outSidestepCmd != _prevSidestepCmd + || outTurnCmd != _prevTurnCmd + || !FloatsEqual(outForwardSpeed, _prevForwardSpeed) + || runHold != _prevRunHold + || localAnimCmd != _prevLocalAnimCmd; + _prevForwardCmd = outForwardCmd; + _prevSidestepCmd = outSidestepCmd; + _prevTurnCmd = outTurnCmd; + _prevForwardSpeed = outForwardSpeed; + _prevRunHold = runHold; + _prevLocalAnimCmd = localAnimCmd; + + static bool FloatsEqual(float? a, float? b) + { + if (a.HasValue != b.HasValue) return false; + if (!a.HasValue || !b.HasValue) return true; + return System.Math.Abs(a.Value - b.Value) < 1e-4f; + } // ── 8. Heartbeat timer (only while moving) ──────────────────────────── bool isMoving = outForwardCmd is not null diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 540d0e2..6ba179a 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1602,24 +1602,23 @@ public sealed class GameWindow : IDisposable } 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; + // Use MotionCommandResolver to restore the proper class + // byte from the wire's 16-bit ForwardCommand. The old + // heuristic (OR the sequencer's current high byte) + // produced wrong values like 0x41000007 instead of the + // real RunForward 0x44000007 — SetCycle's cycleKey + // lookup uses low 24 bits so it'd still hit the right + // MotionData cycle, BUT the class byte gates later + // behaviour (locomotion-motion-detection at line 3615 + // uses `motion & 0xFFu`, and the class byte is needed + // for stance-aware code paths). + uint resolved = AcDream.Core.Physics.MotionCommandResolver + .ReconstructFullCommand(command.Value); + fullMotion = resolved != 0 + ? resolved + : (ae.Sequencer.CurrentMotion & 0xFF000000u) | (uint)command.Value; + if (fullMotion == (uint)command.Value) // no class bits yet + fullMotion = 0x40000000u | (uint)command.Value; } } else @@ -1646,8 +1645,39 @@ public sealed class GameWindow : IDisposable $"UM ↳ SetCycle(style=0x{fullStyle:X8}, motion=0x{fullMotion:X8}, speed={speedMod:F2})"); // No-op if same; the sequencer's fast path guards against that. + uint priorMotion = ae.Sequencer.CurrentMotion; ae.Sequencer.SetCycle(fullStyle, fullMotion, speedMod); + // CRITICAL: when we enter a locomotion cycle (Walk/Run/etc), + // stamp the _remoteLastMove timestamp to "now". Without this, + // the stop-detection loop in TickAnimations sees the previous + // _remoteLastMove timestamp (set by the last UpdatePosition, + // often >300ms ago during idle) and fires the stop signal + // IMMEDIATELY — flipping the sequencer straight back to Ready. + // The visible symptom was "remote char never animates; just + // stands there, teleporting position every UpdatePosition." + // Fresh timestamp gives the stop-timer a full 300ms window to + // observe genuine position stagnation before reverting. + uint newLo = fullMotion & 0xFFu; + bool enteringLocomotion = newLo == 0x05 || newLo == 0x06 + || newLo == 0x07 + || newLo == 0x0F || newLo == 0x10; + uint oldLo = priorMotion & 0xFFu; + bool wasLocomotion = oldLo == 0x05 || oldLo == 0x06 + || oldLo == 0x07 + || oldLo == 0x0F || oldLo == 0x10; + if (enteringLocomotion && !wasLocomotion && update.Guid != _playerServerGuid) + { + // Reset both stop signals so stop-detection starts a fresh + // window from this transition. Without this, the entity + // starts its run animation and is instantly interrupted. + var refreshedTime = System.DateTime.UtcNow; + if (_remoteLastMove.TryGetValue(update.Guid, out var prev)) + _remoteLastMove[update.Guid] = (prev.Pos, refreshedTime); + if (_remoteDeadReckon.TryGetValue(update.Guid, out var dr)) + dr.LastServerPosTime = refreshedTime; + } + // Route the Commands list — one-shot Actions, Modifiers, and // ChatEmotes (mask classes 0x10, 0x20, 0x13 per r03 §3). These // live in the motion table's Links / Modifiers dicts, not