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);
+ }
+}