From 7656fe0970db577b7b9f53a7d5bc1c011f37804c Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Apr 2026 19:38:52 +0200 Subject: [PATCH] fix(anim): Phase L.1c animate server-controlled chase --- src/AcDream.App/Rendering/GameWindow.cs | 94 +++++++++++++++++-- src/AcDream.Core.Net/Messages/CreateObject.cs | 15 ++- src/AcDream.Core.Net/Messages/UpdateMotion.cs | 9 +- .../Physics/ServerControlledLocomotion.cs | 63 +++++++++++++ .../Messages/UpdateMotionTests.cs | 4 +- .../ServerControlledLocomotionTests.cs | 52 ++++++++++ 6 files changed, 224 insertions(+), 13 deletions(-) create mode 100644 src/AcDream.Core/Physics/ServerControlledLocomotion.cs create mode 100644 tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index fe550c7..329abd8 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -536,6 +536,7 @@ public sealed class GameWindow : IDisposable string? Name, AcDream.Core.Items.ItemType ItemType); private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u; + private const double ServerControlledVelocityStaleSeconds = 0.60; private int _liveSpawnReceived; // diagnostics private int _liveSpawnHydrated; private int _liveDropReasonNoPos; @@ -2037,8 +2038,22 @@ public sealed class GameWindow : IDisposable if (mtable is not null) { sequencer = new AcDream.Core.Physics.AnimationSequencer(setup, mtable, _animLoader); - uint seqStyle = stanceOverride is > 0 ? (uint)stanceOverride.Value : (uint)mtable.DefaultStyle; - uint seqMotion = commandOverride is > 0 ? (uint)commandOverride.Value : 0x41000003u; + uint seqStyle = stanceOverride is > 0 + ? (0x80000000u | (uint)stanceOverride.Value) + : (uint)mtable.DefaultStyle; + uint seqMotion; + if (commandOverride is > 0) + { + uint resolved = AcDream.Core.Physics.MotionCommandResolver + .ReconstructFullCommand(commandOverride.Value); + seqMotion = resolved != 0 + ? resolved + : (0x40000000u | (uint)commandOverride.Value); + } + else + { + seqMotion = AcDream.Core.Physics.MotionCommand.Ready; + } sequencer.SetCycle(seqStyle, seqMotion); } } @@ -2217,7 +2232,7 @@ public sealed class GameWindow : IDisposable 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} " + + $"UM guid=0x{update.Guid:X8} mt=0x{update.MotionState.MovementType:X2} stance=0x{stance:X4} cmd={cmdStr} spd={spd:F2} " + $"| seq now style=0x{seqStyle:X8} motion=0x{seqMotion:X8}"); } @@ -2259,9 +2274,18 @@ public sealed class GameWindow : IDisposable // command.Value == 0 → explicit 0 (rare) → Ready // otherwise → resolve class byte and use full cmd uint fullMotion; - if (!command.HasValue || command.Value == 0) + if ((!command.HasValue || command.Value == 0) + && update.MotionState.IsServerControlledMoveTo) + { + // MoveTo packets preserve the current cycle until velocity + // chooses the visible walk/run/ready state. + uint current = ae.Sequencer.CurrentMotion; + fullMotion = (current & 0xFF000000u) != 0 + ? current + : AcDream.Core.Physics.MotionCommand.Ready; + } + else if (!command.HasValue || command.Value == 0) { - // Stop — return to the style's default substate (Ready). fullMotion = 0x41000003u; } else @@ -2619,6 +2643,34 @@ public sealed class GameWindow : IDisposable } } + private static bool IsRemoteLocomotion(uint motion) + { + uint low = motion & 0xFFu; + return low is 0x05 or 0x06 or 0x07 or 0x0F or 0x10; + } + + private void ApplyServerControlledVelocityCycle( + uint serverGuid, + AnimatedEntity ae, + RemoteMotion rm, + System.Numerics.Vector3 velocity) + { + if (IsPlayerGuid(serverGuid)) return; + if (rm.Airborne) return; + if (ae.Sequencer is null) return; + + var plan = AcDream.Core.Physics.ServerControlledLocomotion + .PlanFromVelocity(velocity); + uint currentMotion = ae.Sequencer.CurrentMotion; + if (!plan.IsMoving && !IsRemoteLocomotion(currentMotion)) + return; + + uint style = ae.Sequencer.CurrentStyle != 0 + ? ae.Sequencer.CurrentStyle + : 0x8000003Du; + ae.Sequencer.SetCycle(style, plan.Motion, plan.SpeedMod); + } + private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update) { // Phase A.1: track the most recently updated entity's landblock so the @@ -2789,6 +2841,17 @@ public sealed class GameWindow : IDisposable rmState.Body.Velocity = rmState.ServerVelocity; } + if (!IsPlayerGuid(update.Guid) + && rmState.HasServerVelocity + && _animatedEntities.TryGetValue(entity.Id, out var aeForVelocity)) + { + ApplyServerControlledVelocityCycle( + update.Guid, + aeForVelocity, + rmState, + rmState.ServerVelocity); + } + entity.Position = rmState.Body.Position; entity.Rotation = rmState.Body.Orientation; } @@ -4937,9 +5000,28 @@ public sealed class GameWindow : IDisposable | AcDream.Core.Physics.TransientStateFlags.OnWalkable | AcDream.Core.Physics.TransientStateFlags.Active; if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity) - rm.Body.Velocity = rm.ServerVelocity; + { + double velocityAge = nowSec - rm.LastServerPosTime; + if (velocityAge > ServerControlledVelocityStaleSeconds) + { + rm.ServerVelocity = System.Numerics.Vector3.Zero; + rm.HasServerVelocity = false; + rm.Body.Velocity = System.Numerics.Vector3.Zero; + ApplyServerControlledVelocityCycle( + serverGuid, + ae, + rm, + System.Numerics.Vector3.Zero); + } + else + { + rm.Body.Velocity = rm.ServerVelocity; + } + } else + { rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false); + } } else { diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs index 3b4e90a..be6dc9b 100644 --- a/src/AcDream.Core.Net/Messages/CreateObject.cs +++ b/src/AcDream.Core.Net/Messages/CreateObject.cs @@ -139,7 +139,17 @@ public static class CreateObject ushort? SideStepCommand = null, float? SideStepSpeed = null, ushort? TurnCommand = null, - float? TurnSpeed = null); + float? TurnSpeed = null, + byte MovementType = 0) + { + /// + /// ACE/retail movement types 6 and 7 are server-controlled + /// MoveToObject/MoveToPosition packets. Their union body does not + /// carry an InterpretedMotionState.ForwardCommand, so command absence + /// is not a stop signal. + /// + public bool IsServerControlledMoveTo => MovementType is 6 or 7; + } /// /// One entry in the InterpretedMotionState's Commands list (MotionItem). @@ -648,7 +658,8 @@ public static class CreateObject return new ServerMotionState( currentStyle, forwardCommand, forwardSpeed, commands, - sidestepCommand, sidestepSpeed, turnCommand, turnSpeed); + sidestepCommand, sidestepSpeed, turnCommand, turnSpeed, + movementType); } catch { diff --git a/src/AcDream.Core.Net/Messages/UpdateMotion.cs b/src/AcDream.Core.Net/Messages/UpdateMotion.cs index 65791a7..d2062ac 100644 --- a/src/AcDream.Core.Net/Messages/UpdateMotion.cs +++ b/src/AcDream.Core.Net/Messages/UpdateMotion.cs @@ -135,7 +135,7 @@ public static class UpdateMotion // MovementInvalid branch, just reached via the header'd path. // Includes the Commands list (MotionItem[]) that carries // Actions, emotes, and other one-shots not in ForwardCommand. - if (body.Length - pos < 4) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null)); + if (body.Length - pos < 4) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType)); uint packed = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); pos += 4; uint flags = packed & 0x7Fu; @@ -158,13 +158,13 @@ public static class UpdateMotion if ((flags & 0x1u) != 0) { - if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null)); + if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType)); currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2; } if ((flags & 0x2u) != 0) { - if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null)); + if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType)); forwardCommand = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2; } @@ -224,7 +224,8 @@ public static class UpdateMotion return new Parsed(guid, new CreateObject.ServerMotionState( currentStyle, forwardCommand, forwardSpeed, commands, - sidestepCommand, sidestepSpeed, turnCommand, turnSpeed)); + sidestepCommand, sidestepSpeed, turnCommand, turnSpeed, + movementType)); } catch { diff --git a/src/AcDream.Core/Physics/ServerControlledLocomotion.cs b/src/AcDream.Core/Physics/ServerControlledLocomotion.cs new file mode 100644 index 0000000..992a597 --- /dev/null +++ b/src/AcDream.Core/Physics/ServerControlledLocomotion.cs @@ -0,0 +1,63 @@ +using System; +using System.Numerics; + +namespace AcDream.Core.Physics; + +/// +/// Chooses the visible locomotion cycle for server-controlled remotes whose +/// UpdateMotion packet is a MoveToObject/MoveToPosition union rather than an +/// InterpretedMotionState. +/// +/// Retail references: +/// +/// +/// MovementManager::PerformMovement (0x00524440) dispatches movement +/// types 6/7 into MoveToManager::MoveToObject/MoveToPosition instead +/// of unpacking an InterpretedMotionState. +/// +/// +/// MovementParameters::UnPackNet (0x0052AC50) shows MoveTo packets +/// carry movement params + run rate, not a ForwardCommand field. +/// +/// +/// ACE MovementData.Write uses the same movement type union; holtburger +/// documents the matching MovementType::MoveToPosition = 7. +/// +/// +/// +public static class ServerControlledLocomotion +{ + public const float StopSpeed = 0.20f; + public const float RunThreshold = 1.25f; + public const float MinSpeedMod = 0.25f; + public const float MaxSpeedMod = 3.00f; + + public static LocomotionCycle PlanFromVelocity(Vector3 worldVelocity) + { + float horizontalSpeed = MathF.Sqrt( + worldVelocity.X * worldVelocity.X + + worldVelocity.Y * worldVelocity.Y); + + if (horizontalSpeed < StopSpeed) + return new LocomotionCycle(MotionCommand.Ready, 1f, false); + + if (horizontalSpeed < RunThreshold) + { + float speedMod = Math.Clamp( + horizontalSpeed / MotionInterpreter.WalkAnimSpeed, + MinSpeedMod, + MaxSpeedMod); + return new LocomotionCycle(MotionCommand.WalkForward, speedMod, true); + } + + return new LocomotionCycle( + MotionCommand.RunForward, + Math.Clamp(horizontalSpeed / MotionInterpreter.RunAnimSpeed, MinSpeedMod, MaxSpeedMod), + true); + } + + public readonly record struct LocomotionCycle( + uint Motion, + float SpeedMod, + bool IsMoving); +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs index 08de618..b47168a 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs @@ -194,7 +194,7 @@ public class UpdateMotionTests BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x99999999u); p += 4; BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; p += 6; - body[p++] = 1; // movementType = MoveToObject (non-Invalid) + body[p++] = 7; // movementType = MoveToPosition (non-Invalid) body[p++] = 0; BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x00CC); p += 2; @@ -202,5 +202,7 @@ public class UpdateMotionTests Assert.NotNull(result); Assert.Equal((ushort)0x00CC, result!.Value.MotionState.Stance); Assert.Null(result.Value.MotionState.ForwardCommand); + Assert.Equal((byte)7, result.Value.MotionState.MovementType); + Assert.True(result.Value.MotionState.IsServerControlledMoveTo); } } diff --git a/tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs b/tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs new file mode 100644 index 0000000..78f3f0e --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs @@ -0,0 +1,52 @@ +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +public sealed class ServerControlledLocomotionTests +{ + [Fact] + public void PlanFromVelocity_StopsBelowRetailNoiseThreshold() + { + var plan = ServerControlledLocomotion.PlanFromVelocity( + new Vector3(0.10f, 0.12f, 3.0f)); + + Assert.False(plan.IsMoving); + Assert.Equal(MotionCommand.Ready, plan.Motion); + Assert.Equal(1.0f, plan.SpeedMod); + } + + [Fact] + public void PlanFromVelocity_WalksForSlowServerControlledMotion() + { + var plan = ServerControlledLocomotion.PlanFromVelocity( + new Vector3(0.0f, 0.80f, 0.0f)); + + Assert.True(plan.IsMoving); + Assert.Equal(MotionCommand.WalkForward, plan.Motion); + Assert.InRange(plan.SpeedMod, 0.25f, 0.27f); + } + + [Fact] + public void PlanFromVelocity_RunsAtRetailRunScale() + { + var plan = ServerControlledLocomotion.PlanFromVelocity( + new Vector3(0.0f, MotionInterpreter.RunAnimSpeed, 0.0f)); + + Assert.True(plan.IsMoving); + Assert.Equal(MotionCommand.RunForward, plan.Motion); + Assert.Equal(1.0f, plan.SpeedMod, precision: 4); + } + + [Fact] + public void PlanFromVelocity_ClampsVeryFastSnapshots() + { + var plan = ServerControlledLocomotion.PlanFromVelocity( + new Vector3(0.0f, 30.0f, 0.0f)); + + Assert.True(plan.IsMoving); + Assert.Equal(MotionCommand.RunForward, plan.Motion); + Assert.Equal(ServerControlledLocomotion.MaxSpeedMod, plan.SpeedMod); + } +}