diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 0142fe6..333b948 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -224,6 +224,13 @@ public sealed class GameWindow : IDisposable
public System.Numerics.Vector3 ServerVelocity;
public bool HasServerVelocity;
///
+ /// True while a server MoveToObject/MoveToPosition packet is the
+ /// active locomotion source. Retail runs these through MoveToManager
+ /// and CMotionInterp using the packet's runRate; deriving velocity
+ /// from sparse UpdatePosition deltas under-speeds combat chases.
+ ///
+ public bool ServerMoveToActive;
+ ///
/// Legacy field — no longer used for slerp (retail hard-snaps
/// per FUN_00514b90 set_frame). Kept to avoid churn.
///
@@ -2228,7 +2235,9 @@ public sealed class GameWindow : IDisposable
&& update.Guid != _playerServerGuid)
{
string cmdStr = command.HasValue ? $"0x{command.Value:X4}" : "null";
- float spd = update.MotionState.ForwardSpeed ?? 0f;
+ float spd = update.MotionState.ForwardSpeed
+ ?? ((update.MotionState.MoveToSpeed ?? 0f)
+ * (update.MotionState.MoveToRunRate ?? 0f));
uint seqStyle = ae.Sequencer?.CurrentStyle ?? 0;
uint seqMotion = ae.Sequencer?.CurrentMotion ?? 0;
Console.WriteLine(
@@ -2278,25 +2287,19 @@ public sealed class GameWindow : IDisposable
if ((!command.HasValue || command.Value == 0)
&& update.MotionState.IsServerControlledMoveTo)
{
- uint current = ae.Sequencer.CurrentMotion;
- if (IsRemoteLocomotion(current))
- {
- // MoveTo packets preserve an active locomotion cycle;
- // position velocity will refine the speed.
- fullMotion = current;
- }
- else
- {
- // Retail MoveToManager::BeginMoveForward calls
- // MovementParameters::get_command (0x0052AA00), then
- // _DoMotion -> adjust_motion. With default CanRun and
- // enough distance, WalkForward + HoldKey_Run becomes
- // RunForward immediately, before the next position echo.
- var seed = AcDream.Core.Physics.ServerControlledLocomotion
- .PlanMoveToStart();
- fullMotion = seed.Motion;
- speedMod = seed.SpeedMod;
- }
+ // Retail MoveToManager::BeginMoveForward calls
+ // MovementParameters::get_command (0x0052AA00), then
+ // _DoMotion -> adjust_motion. With CanRun and enough
+ // distance, WalkForward + HoldKey_Run becomes RunForward,
+ // and CMotionInterp::apply_run_to_command (0x00527BE0)
+ // multiplies speed by the packet's runRate.
+ var seed = AcDream.Core.Physics.ServerControlledLocomotion
+ .PlanMoveToStart(
+ update.MotionState.MoveToSpeed ?? 1f,
+ update.MotionState.MoveToRunRate ?? 1f,
+ update.MotionState.MoveToCanRun);
+ fullMotion = seed.Motion;
+ speedMod = seed.SpeedMod;
}
else if (!command.HasValue || command.Value == 0)
{
@@ -2448,6 +2451,18 @@ public sealed class GameWindow : IDisposable
// FUN_00529210 apply_current_movement
if (_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot))
{
+ remoteMot.ServerMoveToActive = update.MotionState.IsServerControlledMoveTo;
+ if (remoteMot.ServerMoveToActive && !IsPlayerGuid(update.Guid))
+ {
+ // Retail MoveTo packets already carry enough state
+ // for CMotionInterp to drive velocity. A velocity
+ // inferred from sparse UpdatePosition packets lags
+ // during combat chases and visibly under-speeds the
+ // run cycle until the next hard snap.
+ remoteMot.HasServerVelocity = false;
+ remoteMot.ServerVelocity = System.Numerics.Vector3.Zero;
+ }
+
// Forward axis (Ready / WalkForward / RunForward / WalkBackward).
remoteMot.Motion.DoInterpretedMotion(
fullMotion, speedMod, modifyInterpretedState: true);
@@ -2756,6 +2771,7 @@ public sealed class GameWindow : IDisposable
System.Numerics.Vector3? serverVelocity = update.Velocity;
if (serverVelocity is null
&& !IsPlayerGuid(update.Guid)
+ && !rmState.ServerMoveToActive
&& rmState.LastServerPosTime > 0.0)
{
double elapsed = nowSec - rmState.LastServerPosTime;
@@ -2836,6 +2852,7 @@ public sealed class GameWindow : IDisposable
// carries no stop information for our ACE.
if (svel.LengthSquared() < 0.04f)
{
+ rmState.ServerMoveToActive = false;
rmState.Motion.StopCompletely();
if (_animatedEntities.TryGetValue(entity.Id, out var aeForStop)
&& aeForStop.Sequencer is not null)
diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs
index be6dc9b..847f1c2 100644
--- a/src/AcDream.Core.Net/Messages/CreateObject.cs
+++ b/src/AcDream.Core.Net/Messages/CreateObject.cs
@@ -140,7 +140,10 @@ public static class CreateObject
float? SideStepSpeed = null,
ushort? TurnCommand = null,
float? TurnSpeed = null,
- byte MovementType = 0)
+ byte MovementType = 0,
+ uint? MoveToParameters = null,
+ float? MoveToSpeed = null,
+ float? MoveToRunRate = null)
{
///
/// ACE/retail movement types 6 and 7 are server-controlled
@@ -149,6 +152,9 @@ public static class CreateObject
/// is not a stop signal.
///
public bool IsServerControlledMoveTo => MovementType is 6 or 7;
+
+ public bool MoveToCanRun => !MoveToParameters.HasValue
+ || (MoveToParameters.Value & 0x2u) != 0;
}
///
@@ -553,6 +559,9 @@ public static class CreateObject
float? sidestepSpeed = null;
ushort? turnCommand = null;
float? turnSpeed = null;
+ uint? moveToParameters = null;
+ float? moveToSpeed = null;
+ float? moveToRunRate = null;
List? commands = null;
// 0 = Invalid is the only union variant we care about for static
@@ -655,15 +664,62 @@ public static class CreateObject
}
done:;
}
+ else if (movementType is 6 or 7)
+ {
+ TryParseMoveToPayload(
+ mv,
+ p,
+ movementType,
+ out moveToParameters,
+ out moveToSpeed,
+ out moveToRunRate);
+ }
return new ServerMotionState(
currentStyle, forwardCommand, forwardSpeed, commands,
sidestepCommand, sidestepSpeed, turnCommand, turnSpeed,
- movementType);
+ movementType,
+ moveToParameters,
+ moveToSpeed,
+ moveToRunRate);
}
catch
{
return null;
}
}
+
+ private static bool TryParseMoveToPayload(
+ ReadOnlySpan body,
+ int pos,
+ byte movementType,
+ out uint? movementParameters,
+ out float? speed,
+ out float? runRate)
+ {
+ movementParameters = null;
+ speed = null;
+ runRate = null;
+
+ if (movementType == 6)
+ {
+ if (body.Length - pos < 4) return false;
+ pos += 4; // target guid
+ }
+
+ if (body.Length - pos < 16 + 28 + 4) return false;
+ pos += 16; // Origin
+
+ movementParameters = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
+ pos += 4;
+ pos += 4; // distanceToObject
+ pos += 4; // minDistance
+ pos += 4; // failDistance
+ speed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
+ pos += 4;
+ pos += 4; // walkRunThreshold
+ pos += 4; // desiredHeading
+ runRate = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
+ return true;
+ }
}
diff --git a/src/AcDream.Core.Net/Messages/UpdateMotion.cs b/src/AcDream.Core.Net/Messages/UpdateMotion.cs
index d2062ac..6cc76cc 100644
--- a/src/AcDream.Core.Net/Messages/UpdateMotion.cs
+++ b/src/AcDream.Core.Net/Messages/UpdateMotion.cs
@@ -127,6 +127,9 @@ public static class UpdateMotion
float? sidestepSpeed = null;
ushort? turnCommand = null;
float? turnSpeed = null;
+ uint? moveToParameters = null;
+ float? moveToSpeed = null;
+ float? moveToRunRate = null;
List? commands = null;
if (movementType == 0)
@@ -221,15 +224,69 @@ public static class UpdateMotion
}
done:;
}
+ else if (movementType is 6 or 7)
+ {
+ TryParseMoveToPayload(
+ body,
+ pos,
+ movementType,
+ out moveToParameters,
+ out moveToSpeed,
+ out moveToRunRate);
+ }
return new Parsed(guid, new CreateObject.ServerMotionState(
currentStyle, forwardCommand, forwardSpeed, commands,
sidestepCommand, sidestepSpeed, turnCommand, turnSpeed,
- movementType));
+ movementType,
+ moveToParameters,
+ moveToSpeed,
+ moveToRunRate));
}
catch
{
return null;
}
}
+
+ private static bool TryParseMoveToPayload(
+ ReadOnlySpan body,
+ int pos,
+ byte movementType,
+ out uint? movementParameters,
+ out float? speed,
+ out float? runRate)
+ {
+ movementParameters = null;
+ speed = null;
+ runRate = null;
+
+ // Retail MovementManager::PerformMovement (0x00524440) consumes
+ // MoveToObject/MoveToPosition as:
+ // [object guid, for MoveToObject only]
+ // Origin(cell + xyz)
+ // MovementParameters::UnPackNet (0x0052AC50): flags, distance,
+ // min, fail, speed, walk/run threshold, desired heading
+ // f32 runRate copied into CMotionInterp::my_run_rate.
+ if (movementType == 6)
+ {
+ if (body.Length - pos < 4) return false;
+ pos += 4; // target guid
+ }
+
+ if (body.Length - pos < 16 + 28 + 4) return false;
+ pos += 16; // Origin
+
+ movementParameters = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
+ pos += 4;
+ pos += 4; // distanceToObject
+ pos += 4; // minDistance
+ pos += 4; // failDistance
+ speed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
+ pos += 4;
+ pos += 4; // walkRunThreshold
+ pos += 4; // desiredHeading
+ runRate = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
+ return true;
+ }
}
diff --git a/src/AcDream.Core/Physics/ServerControlledLocomotion.cs b/src/AcDream.Core/Physics/ServerControlledLocomotion.cs
index ce59998..af4d14d 100644
--- a/src/AcDream.Core/Physics/ServerControlledLocomotion.cs
+++ b/src/AcDream.Core/Physics/ServerControlledLocomotion.cs
@@ -34,9 +34,21 @@ public static class ServerControlledLocomotion
// Retail MoveToManager::BeginMoveForward -> MovementParameters::get_command
// (0x0052AA00) seeds forward motion before the next position update.
- public static LocomotionCycle PlanMoveToStart()
+ public static LocomotionCycle PlanMoveToStart(
+ float moveToSpeed = 1f,
+ float runRate = 1f,
+ bool canRun = true)
{
- return new LocomotionCycle(MotionCommand.RunForward, 1f, true);
+ moveToSpeed = SanitizePositive(moveToSpeed);
+ runRate = SanitizePositive(runRate);
+
+ if (!canRun)
+ return new LocomotionCycle(MotionCommand.WalkForward, moveToSpeed, true);
+
+ return new LocomotionCycle(
+ MotionCommand.RunForward,
+ moveToSpeed * runRate,
+ true);
}
public static LocomotionCycle PlanFromVelocity(Vector3 worldVelocity)
@@ -67,4 +79,9 @@ public static class ServerControlledLocomotion
uint Motion,
float SpeedMod,
bool IsMoving);
+
+ private static float SanitizePositive(float value)
+ {
+ return float.IsFinite(value) && value > 0f ? value : 1f;
+ }
}
diff --git a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs
index b47168a..ad0f01a 100644
--- a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs
+++ b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs
@@ -185,7 +185,8 @@ public class UpdateMotionTests
[Fact]
public void HandlesNonInvalidMovementType_GracefullyReturnsOuterStance()
{
- // movementType != 0 means one of the Move* variants we don't parse.
+ // movementType != 0 means one of the Move* variants; a truncated
+ // non-Invalid payload still returns the outer state.
// The parser must still return a valid Parsed with the outer stance
// and a null ForwardCommand rather than failing the whole message.
var body = new byte[4 + 4 + 2 + 6 + 4];
@@ -205,4 +206,50 @@ public class UpdateMotionTests
Assert.Equal((byte)7, result.Value.MotionState.MovementType);
Assert.True(result.Value.MotionState.IsServerControlledMoveTo);
}
+
+ [Fact]
+ public void ParsesMoveToPositionSpeedAndRunRate()
+ {
+ // Layout after MovementData's movementType/motionFlags/currentStyle:
+ // Origin: cell + xyz (16 bytes)
+ // MoveToParameters: flags, distance, min, fail, speed,
+ // walk/run threshold, desired heading (28 bytes)
+ // runRate: f32
+ var body = new byte[4 + 4 + 2 + 6 + 4 + 16 + 28 + 4];
+ int p = 0;
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80001234u); p += 4;
+ BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
+ p += 6;
+ body[p++] = 7; // MoveToPosition
+ body[p++] = 0;
+ BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2;
+
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xA8B4000Eu); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 10f); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 20f); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 30f); p += 4;
+
+ const uint canWalkCanRunMoveTowards = 0x1u | 0x2u | 0x200u;
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), canWalkCanRunMoveTowards); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.6f); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.0f); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), float.MaxValue); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.25f); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 15.0f); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 90.0f); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.5f); p += 4;
+
+ var result = UpdateMotion.TryParse(body);
+
+ Assert.NotNull(result);
+ Assert.Equal((byte)7, result!.Value.MotionState.MovementType);
+ Assert.True(result.Value.MotionState.IsServerControlledMoveTo);
+ Assert.Equal((ushort)0x003D, result.Value.MotionState.Stance);
+ Assert.Null(result.Value.MotionState.ForwardCommand);
+ Assert.Equal(canWalkCanRunMoveTowards, result.Value.MotionState.MoveToParameters);
+ Assert.Equal(1.25f, result.Value.MotionState.MoveToSpeed);
+ Assert.Equal(1.5f, result.Value.MotionState.MoveToRunRate);
+ Assert.True(result.Value.MotionState.MoveToCanRun);
+ }
}
diff --git a/tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs b/tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs
index 448f1f8..65cc50d 100644
--- a/tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs
@@ -16,6 +16,32 @@ public sealed class ServerControlledLocomotionTests
Assert.Equal(1.0f, plan.SpeedMod);
}
+ [Fact]
+ public void PlanMoveToStart_AppliesRetailRunRate()
+ {
+ var plan = ServerControlledLocomotion.PlanMoveToStart(
+ moveToSpeed: 1.25f,
+ runRate: 1.5f,
+ canRun: true);
+
+ Assert.True(plan.IsMoving);
+ Assert.Equal(MotionCommand.RunForward, plan.Motion);
+ Assert.Equal(1.875f, plan.SpeedMod);
+ }
+
+ [Fact]
+ public void PlanMoveToStart_UsesWalkWhenRunDisallowed()
+ {
+ var plan = ServerControlledLocomotion.PlanMoveToStart(
+ moveToSpeed: 0.75f,
+ runRate: 2.0f,
+ canRun: false);
+
+ Assert.True(plan.IsMoving);
+ Assert.Equal(MotionCommand.WalkForward, plan.Motion);
+ Assert.Equal(0.75f, plan.SpeedMod);
+ }
+
[Fact]
public void PlanFromVelocity_StopsBelowRetailNoiseThreshold()
{