fix(anim): Phase L.1c match MoveTo run speed

Retail MovementManager::PerformMovement (0x00524440) reads MoveTo speed and runRate from the packet, MovementParameters::UnPackNet (0x0052AC50) defines the layout, and CMotionInterp::apply_run_to_command (0x00527BE0) multiplies RunForward by runRate. Parse those fields for UpdateMotion/CreateObject, seed server-controlled MoveTo locomotion with the retail speed multiplier, and avoid overriding active monster MoveTo with sparse UpdatePosition-derived velocity.
This commit is contained in:
Erik 2026-04-28 20:58:22 +02:00
parent 4dd8d4b46e
commit 9812965183
6 changed files with 246 additions and 26 deletions

View file

@ -224,6 +224,13 @@ public sealed class GameWindow : IDisposable
public System.Numerics.Vector3 ServerVelocity; public System.Numerics.Vector3 ServerVelocity;
public bool HasServerVelocity; public bool HasServerVelocity;
/// <summary> /// <summary>
/// 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.
/// </summary>
public bool ServerMoveToActive;
/// <summary>
/// Legacy field — no longer used for slerp (retail hard-snaps /// Legacy field — no longer used for slerp (retail hard-snaps
/// per FUN_00514b90 set_frame). Kept to avoid churn. /// per FUN_00514b90 set_frame). Kept to avoid churn.
/// </summary> /// </summary>
@ -2228,7 +2235,9 @@ public sealed class GameWindow : IDisposable
&& update.Guid != _playerServerGuid) && update.Guid != _playerServerGuid)
{ {
string cmdStr = command.HasValue ? $"0x{command.Value:X4}" : "null"; 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 seqStyle = ae.Sequencer?.CurrentStyle ?? 0;
uint seqMotion = ae.Sequencer?.CurrentMotion ?? 0; uint seqMotion = ae.Sequencer?.CurrentMotion ?? 0;
Console.WriteLine( Console.WriteLine(
@ -2278,25 +2287,19 @@ public sealed class GameWindow : IDisposable
if ((!command.HasValue || command.Value == 0) if ((!command.HasValue || command.Value == 0)
&& update.MotionState.IsServerControlledMoveTo) && update.MotionState.IsServerControlledMoveTo)
{ {
uint current = ae.Sequencer.CurrentMotion; // Retail MoveToManager::BeginMoveForward calls
if (IsRemoteLocomotion(current)) // MovementParameters::get_command (0x0052AA00), then
{ // _DoMotion -> adjust_motion. With CanRun and enough
// MoveTo packets preserve an active locomotion cycle; // distance, WalkForward + HoldKey_Run becomes RunForward,
// position velocity will refine the speed. // and CMotionInterp::apply_run_to_command (0x00527BE0)
fullMotion = current; // multiplies speed by the packet's runRate.
} var seed = AcDream.Core.Physics.ServerControlledLocomotion
else .PlanMoveToStart(
{ update.MotionState.MoveToSpeed ?? 1f,
// Retail MoveToManager::BeginMoveForward calls update.MotionState.MoveToRunRate ?? 1f,
// MovementParameters::get_command (0x0052AA00), then update.MotionState.MoveToCanRun);
// _DoMotion -> adjust_motion. With default CanRun and fullMotion = seed.Motion;
// enough distance, WalkForward + HoldKey_Run becomes speedMod = seed.SpeedMod;
// RunForward immediately, before the next position echo.
var seed = AcDream.Core.Physics.ServerControlledLocomotion
.PlanMoveToStart();
fullMotion = seed.Motion;
speedMod = seed.SpeedMod;
}
} }
else if (!command.HasValue || command.Value == 0) else if (!command.HasValue || command.Value == 0)
{ {
@ -2448,6 +2451,18 @@ public sealed class GameWindow : IDisposable
// FUN_00529210 apply_current_movement // FUN_00529210 apply_current_movement
if (_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot)) 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). // Forward axis (Ready / WalkForward / RunForward / WalkBackward).
remoteMot.Motion.DoInterpretedMotion( remoteMot.Motion.DoInterpretedMotion(
fullMotion, speedMod, modifyInterpretedState: true); fullMotion, speedMod, modifyInterpretedState: true);
@ -2756,6 +2771,7 @@ public sealed class GameWindow : IDisposable
System.Numerics.Vector3? serverVelocity = update.Velocity; System.Numerics.Vector3? serverVelocity = update.Velocity;
if (serverVelocity is null if (serverVelocity is null
&& !IsPlayerGuid(update.Guid) && !IsPlayerGuid(update.Guid)
&& !rmState.ServerMoveToActive
&& rmState.LastServerPosTime > 0.0) && rmState.LastServerPosTime > 0.0)
{ {
double elapsed = nowSec - rmState.LastServerPosTime; double elapsed = nowSec - rmState.LastServerPosTime;
@ -2836,6 +2852,7 @@ public sealed class GameWindow : IDisposable
// carries no stop information for our ACE. // carries no stop information for our ACE.
if (svel.LengthSquared() < 0.04f) if (svel.LengthSquared() < 0.04f)
{ {
rmState.ServerMoveToActive = false;
rmState.Motion.StopCompletely(); rmState.Motion.StopCompletely();
if (_animatedEntities.TryGetValue(entity.Id, out var aeForStop) if (_animatedEntities.TryGetValue(entity.Id, out var aeForStop)
&& aeForStop.Sequencer is not null) && aeForStop.Sequencer is not null)

View file

@ -140,7 +140,10 @@ public static class CreateObject
float? SideStepSpeed = null, float? SideStepSpeed = null,
ushort? TurnCommand = null, ushort? TurnCommand = null,
float? TurnSpeed = null, float? TurnSpeed = null,
byte MovementType = 0) byte MovementType = 0,
uint? MoveToParameters = null,
float? MoveToSpeed = null,
float? MoveToRunRate = null)
{ {
/// <summary> /// <summary>
/// ACE/retail movement types 6 and 7 are server-controlled /// ACE/retail movement types 6 and 7 are server-controlled
@ -149,6 +152,9 @@ public static class CreateObject
/// is not a stop signal. /// is not a stop signal.
/// </summary> /// </summary>
public bool IsServerControlledMoveTo => MovementType is 6 or 7; public bool IsServerControlledMoveTo => MovementType is 6 or 7;
public bool MoveToCanRun => !MoveToParameters.HasValue
|| (MoveToParameters.Value & 0x2u) != 0;
} }
/// <summary> /// <summary>
@ -553,6 +559,9 @@ public static class CreateObject
float? sidestepSpeed = null; float? sidestepSpeed = null;
ushort? turnCommand = null; ushort? turnCommand = null;
float? turnSpeed = null; float? turnSpeed = null;
uint? moveToParameters = null;
float? moveToSpeed = null;
float? moveToRunRate = null;
List<MotionItem>? commands = null; List<MotionItem>? commands = null;
// 0 = Invalid is the only union variant we care about for static // 0 = Invalid is the only union variant we care about for static
@ -655,15 +664,62 @@ public static class CreateObject
} }
done:; done:;
} }
else if (movementType is 6 or 7)
{
TryParseMoveToPayload(
mv,
p,
movementType,
out moveToParameters,
out moveToSpeed,
out moveToRunRate);
}
return new ServerMotionState( return new ServerMotionState(
currentStyle, forwardCommand, forwardSpeed, commands, currentStyle, forwardCommand, forwardSpeed, commands,
sidestepCommand, sidestepSpeed, turnCommand, turnSpeed, sidestepCommand, sidestepSpeed, turnCommand, turnSpeed,
movementType); movementType,
moveToParameters,
moveToSpeed,
moveToRunRate);
} }
catch catch
{ {
return null; return null;
} }
} }
private static bool TryParseMoveToPayload(
ReadOnlySpan<byte> 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;
}
} }

View file

@ -127,6 +127,9 @@ public static class UpdateMotion
float? sidestepSpeed = null; float? sidestepSpeed = null;
ushort? turnCommand = null; ushort? turnCommand = null;
float? turnSpeed = null; float? turnSpeed = null;
uint? moveToParameters = null;
float? moveToSpeed = null;
float? moveToRunRate = null;
List<CreateObject.MotionItem>? commands = null; List<CreateObject.MotionItem>? commands = null;
if (movementType == 0) if (movementType == 0)
@ -221,15 +224,69 @@ public static class UpdateMotion
} }
done:; 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( return new Parsed(guid, new CreateObject.ServerMotionState(
currentStyle, forwardCommand, forwardSpeed, commands, currentStyle, forwardCommand, forwardSpeed, commands,
sidestepCommand, sidestepSpeed, turnCommand, turnSpeed, sidestepCommand, sidestepSpeed, turnCommand, turnSpeed,
movementType)); movementType,
moveToParameters,
moveToSpeed,
moveToRunRate));
} }
catch catch
{ {
return null; return null;
} }
} }
private static bool TryParseMoveToPayload(
ReadOnlySpan<byte> 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;
}
} }

View file

@ -34,9 +34,21 @@ public static class ServerControlledLocomotion
// Retail MoveToManager::BeginMoveForward -> MovementParameters::get_command // Retail MoveToManager::BeginMoveForward -> MovementParameters::get_command
// (0x0052AA00) seeds forward motion before the next position update. // (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) public static LocomotionCycle PlanFromVelocity(Vector3 worldVelocity)
@ -67,4 +79,9 @@ public static class ServerControlledLocomotion
uint Motion, uint Motion,
float SpeedMod, float SpeedMod,
bool IsMoving); bool IsMoving);
private static float SanitizePositive(float value)
{
return float.IsFinite(value) && value > 0f ? value : 1f;
}
} }

View file

@ -185,7 +185,8 @@ public class UpdateMotionTests
[Fact] [Fact]
public void HandlesNonInvalidMovementType_GracefullyReturnsOuterStance() 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 // The parser must still return a valid Parsed with the outer stance
// and a null ForwardCommand rather than failing the whole message. // and a null ForwardCommand rather than failing the whole message.
var body = new byte[4 + 4 + 2 + 6 + 4]; 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.Equal((byte)7, result.Value.MotionState.MovementType);
Assert.True(result.Value.MotionState.IsServerControlledMoveTo); 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);
}
} }

View file

@ -16,6 +16,32 @@ public sealed class ServerControlledLocomotionTests
Assert.Equal(1.0f, plan.SpeedMod); 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] [Fact]
public void PlanFromVelocity_StopsBelowRetailNoiseThreshold() public void PlanFromVelocity_StopsBelowRetailNoiseThreshold()
{ {