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() {