fix(anim): Phase L.1c chase arrival + stale destination

User-observed regressions on commit 186a584:

1. "Monster keeps running in different directions when it should be
   attacking" — chase oscillates around the player at melee range
   instead of stopping. Root cause: arrival check used MinDistance
   only (retail's algorithm), but ACE puts the melee threshold in
   DistanceToObject (default 0.6) and leaves MinDistance at 0. So
   our check was never satisfied; body kept re-targeting around the
   player as each MoveTo refresh moved the destination.

   Fix: arrival = dist <= max(MinDistance, DistanceToObject) + epsilon.
   Honors retail when retail sets MinDistance > 0; falls through to
   ACE's DistanceToObject when MinDistance is 0. Confirmed by
   independent research (named retail decomp, ACE wire writers,
   holtburger client) that DistanceToObject is the documented chase
   threshold in ACE; retail's MinDistance is only meaningful when
   server config overrides the default 0.

2. "Monster disappears, then runs in place" — entity left our
   streaming view, server stopped emitting MoveTo, last destination
   stayed cached. When entity re-entered view, body still steered
   toward the stale point, eventually arrived (V=0), animation kept
   playing → "running on the spot."

   Fix: 1.5 s stale-destination timeout. ACE re-emits MoveTo at
   ~1 Hz during active chase; if no fresh packet for 1.5 s, the
   entity has either left view, transitioned off MoveTo without us
   seeing the cancel UM, or had its move cancelled server-side.
   Clear destination + zero velocity so the next interpreted-motion
   UM (or fresh MoveTo) drives the body cleanly.

Also confirmed (via dispatched research subagent against ACE writer
side, named retail MovementManager::PerformMovement, and holtburger):
the wire's "Origin" field IS the destination, not the start position.
My driver's interpretation was correct; the symptoms were arrival
threshold + staleness, not a misread of the wire.

Tests: 1412 → 1414 (ACE-melee arrival, retail-MinDistance arrival).

Origin-stale lag during active chase remains — server's Origin is
the target's position at packet-emit time, ~1 s behind the player.
For type 6 MoveToObject, the retail-faithful fix is target-guid
live resolution per HandleUpdateTarget @ 0x0052a7d0; deferred per
the pseudocode doc's "out of scope" list. For type 7 there's no
fix without target-velocity prediction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-29 08:32:54 +02:00
parent 186a584404
commit d247aef2e4
3 changed files with 150 additions and 36 deletions

View file

@ -270,6 +270,16 @@ public sealed class GameWindow : IDisposable
/// chase. False = flee (<c>move_away</c>) or static target. /// chase. False = flee (<c>move_away</c>) or static target.
/// </summary> /// </summary>
public bool MoveToMoveTowards; public bool MoveToMoveTowards;
/// <summary>
/// Seconds-since-epoch timestamp of the most recent MoveTo packet
/// for this entity. Used by the per-tick driver to give up
/// steering when no refresh has arrived for
/// <see cref="AcDream.Core.Physics.RemoteMoveToDriver.StaleDestinationSeconds"/>
/// — typically because the entity left our streaming view and
/// the server stopped broadcasting its MoveTo updates.
/// </summary>
public double LastMoveToPacketTime;
/// <summary> /// <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.
@ -2514,6 +2524,8 @@ public sealed class GameWindow : IDisposable
remoteMot.MoveToDistanceToObject = path.DistanceToObject; remoteMot.MoveToDistanceToObject = path.DistanceToObject;
remoteMot.MoveToMoveTowards = update.MotionState.MoveTowards; remoteMot.MoveToMoveTowards = update.MotionState.MoveTowards;
remoteMot.HasMoveToDestination = true; remoteMot.HasMoveToDestination = true;
remoteMot.LastMoveToPacketTime =
(System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
} }
else if (!update.MotionState.IsServerControlledMoveTo) else if (!update.MotionState.IsServerControlledMoveTo)
{ {
@ -5121,35 +5133,54 @@ public sealed class GameWindow : IDisposable
// server-supplied destination, then let // server-supplied destination, then let
// apply_current_movement set Velocity from the // apply_current_movement set Velocity from the
// RunForward cycle through the now-correct heading. // RunForward cycle through the now-correct heading.
var driveResult = AcDream.Core.Physics.RemoteMoveToDriver
.Drive(
rm.Body.Position,
rm.Body.Orientation,
rm.MoveToDestinationWorld,
rm.MoveToMinDistance,
(float)dt,
rm.MoveToMoveTowards,
out var steeredOrientation);
rm.Body.Orientation = steeredOrientation;
if (driveResult == AcDream.Core.Physics.RemoteMoveToDriver // Stale-destination guard (2026-04-28): if no
.DriveResult.Arrived) // MoveTo packet has refreshed the destination
// recently, the entity has likely left our
// streaming view or the server cancelled the
// move without us seeing the cancel UM. Continuing
// to steer toward a stale point produces the
// "monster runs in place after popping back into
// view" symptom. Clear and stand down.
double moveToAge = nowSec - rm.LastMoveToPacketTime;
if (moveToAge > AcDream.Core.Physics.RemoteMoveToDriver.StaleDestinationSeconds)
{ {
// Within arrival window — zero velocity until the rm.HasMoveToDestination = false;
// next MoveTo packet refreshes the destination
// (or the server explicitly stops us with an
// interpreted-motion UM cmd=Ready).
rm.Body.Velocity = System.Numerics.Vector3.Zero; rm.Body.Velocity = System.Numerics.Vector3.Zero;
} }
else else
{ {
// Steering active — apply_current_movement reads var driveResult = AcDream.Core.Physics.RemoteMoveToDriver
// InterpretedState.ForwardCommand=RunForward (set .Drive(
// when the MoveTo packet arrived) and emits rm.Body.Position,
// velocity along +Y in body local space. Our rm.Body.Orientation,
// updated orientation rotates that into the right rm.MoveToDestinationWorld,
// world direction toward the target. rm.MoveToMinDistance,
rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false); rm.MoveToDistanceToObject,
(float)dt,
rm.MoveToMoveTowards,
out var steeredOrientation);
rm.Body.Orientation = steeredOrientation;
if (driveResult == AcDream.Core.Physics.RemoteMoveToDriver
.DriveResult.Arrived)
{
// Within arrival window — zero velocity until the
// next MoveTo packet refreshes the destination
// (or the server explicitly stops us with an
// interpreted-motion UM cmd=Ready).
rm.Body.Velocity = System.Numerics.Vector3.Zero;
}
else
{
// Steering active — apply_current_movement reads
// InterpretedState.ForwardCommand=RunForward (set
// when the MoveTo packet arrived) and emits
// velocity along +Y in body local space. Our
// updated orientation rotates that into the right
// world direction toward the target.
rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
}
} }
} }
else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive) else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive)

View file

@ -84,6 +84,21 @@ public static class RemoteMoveToDriver
/// </summary> /// </summary>
public const float ArrivalEpsilon = 0.05f; public const float ArrivalEpsilon = 0.05f;
/// <summary>
/// Maximum staleness (seconds) of the most recent MoveTo packet
/// before the driver gives up steering. ACE re-emits MoveTo at ~1 Hz
/// during active chase; if no fresh packet arrives for this long,
/// the entity has likely either left our streaming view, switched
/// to a non-MoveTo motion the server's broadcast didn't reach us
/// for, or had its move cancelled server-side without our seeing
/// the cancel UM. In any of those cases, continuing to drive the
/// body toward a stale destination produces the "monster runs in
/// place after popping back into view" symptom (2026-04-28).
/// 1.5 s gives us comfortable margin over the ~1 s emit cadence
/// while still failing fast on real loss-of-state.
/// </summary>
public const double StaleDestinationSeconds = 1.5;
public enum DriveResult public enum DriveResult
{ {
/// <summary>Within arrival window — caller should zero velocity.</summary> /// <summary>Within arrival window — caller should zero velocity.</summary>
@ -95,17 +110,33 @@ public static class RemoteMoveToDriver
/// <summary> /// <summary>
/// Steer body orientation toward <paramref name="destinationWorld"/> /// Steer body orientation toward <paramref name="destinationWorld"/>
/// and report whether the body has arrived (within /// and report whether the body has arrived or should keep running.
/// <paramref name="minDistance"/>) or should keep running. Pure /// Pure function — emits the updated orientation via
/// function — emits the updated orientation via
/// <paramref name="newOrientation"/> (the input is not mutated; the /// <paramref name="newOrientation"/> (the input is not mutated; the
/// caller assigns the new value back to its body). /// caller assigns the new value back to its body).
/// </summary> /// </summary>
/// <param name="minDistance">
/// <c>min_distance</c> from the wire's MovementParameters block —
/// retail's <c>HandleMoveToPosition</c> chase-arrival threshold.
/// </param>
/// <param name="distanceToObject">
/// <c>distance_to_object</c> from the wire — ACE's chase-arrival
/// threshold (default 0.6 m, the melee range). The actual arrival
/// gate is <c>max(minDistance, distanceToObject)</c>: retail-faithful
/// when retail sends <c>min_distance</c> &gt; 0, ACE-compatible when
/// ACE puts the value in <c>distance_to_object</c> with
/// <c>min_distance == 0</c>. Without this, ACE's <c>min_distance==0</c>
/// chase packets never arrive — the body keeps re-targeting around
/// the player at melee range and visibly oscillates between facings,
/// which is the user-reported "monster keeps running in different
/// directions when it should be attacking" symptom (2026-04-28).
/// </param>
public static DriveResult Drive( public static DriveResult Drive(
Vector3 bodyPosition, Vector3 bodyPosition,
Quaternion bodyOrientation, Quaternion bodyOrientation,
Vector3 destinationWorld, Vector3 destinationWorld,
float minDistance, float minDistance,
float distanceToObject,
float dt, float dt,
bool moveTowards, bool moveTowards,
out Quaternion newOrientation) out Quaternion newOrientation)
@ -116,10 +147,15 @@ public static class RemoteMoveToDriver
float dy = destinationWorld.Y - bodyPosition.Y; float dy = destinationWorld.Y - bodyPosition.Y;
float dist = MathF.Sqrt(dx * dx + dy * dy); float dist = MathF.Sqrt(dx * dx + dy * dy);
// Arrival predicate per retail MoveToManager::HandleMoveToPosition // Arrival predicate. Retail (named decomp): dist ≤ min_distance.
// (chase: dist ≤ min_distance; flee branch is unused here, but // ACE port: dist ≤ DistanceToObject. ACE's wire put the melee
// we honor the moveTowards flag for symmetry). // threshold in DistanceToObject (default 0.6) and left
if (moveTowards && dist <= minDistance + ArrivalEpsilon) // MinDistance=0; retail server config presumably set MinDistance.
// Defensive port: take the larger so we honor whichever field is
// populated. Flee branch is unused here but we honor the
// moveTowards flag for symmetry.
float arrivalThreshold = MathF.Max(minDistance, distanceToObject);
if (moveTowards && dist <= arrivalThreshold + ArrivalEpsilon)
{ {
newOrientation = bodyOrientation; newOrientation = bodyOrientation;
return DriveResult.Arrived; return DriveResult.Arrived;

View file

@ -30,13 +30,55 @@ public class RemoteMoveToDriverTests
var result = RemoteMoveToDriver.Drive( var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest, bodyPos, bodyRot, dest,
minDistance: 0.5f, dt: 0.016f, moveTowards: true, minDistance: 0.5f, distanceToObject: 0.6f,
dt: 0.016f, moveTowards: true,
out var newOrient); out var newOrient);
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result); Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
Assert.Equal(bodyRot, newOrient); // orientation untouched Assert.Equal(bodyRot, newOrient); // orientation untouched
} }
[Fact]
public void Drive_AceMeleePacket_UsesDistanceToObjectAsArrival()
{
// ACE chase packet: MinDistance=0, DistanceToObject=0.6 (melee).
// Body at 0.5m from target should ARRIVE — not keep oscillating
// around the target the way it did pre-fix when only MinDistance
// was the gate. This is the "monster keeps running in different
// directions when it should be attacking" regression fix.
var bodyPos = new Vector3(0f, 0f, 0f);
var bodyRot = Quaternion.Identity;
var dest = new Vector3(0f, 0.5f, 0f);
var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest,
minDistance: 0f, distanceToObject: 0.6f,
dt: 0.016f, moveTowards: true,
out _);
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
}
[Fact]
public void Drive_RetailMinDistanceWins_WhenLargerThanDistanceToObject()
{
// Hypothetical retail packet: MinDistance=2.0 (set explicitly),
// DistanceToObject=0.6 (default). Arrival should fire at 2 m
// because retail's algorithm uses MinDistance and it's the larger
// of the two.
var bodyPos = new Vector3(0f, 0f, 0f);
var bodyRot = Quaternion.Identity;
var dest = new Vector3(0f, 1.5f, 0f);
var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest,
minDistance: 2.0f, distanceToObject: 0.6f,
dt: 0.016f, moveTowards: true,
out _);
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
}
[Fact] [Fact]
public void Drive_ChasingButNotInRange_ReportsSteering() public void Drive_ChasingButNotInRange_ReportsSteering()
{ {
@ -46,7 +88,8 @@ public class RemoteMoveToDriverTests
var result = RemoteMoveToDriver.Drive( var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest, bodyPos, bodyRot, dest,
minDistance: 0f, dt: 0.016f, moveTowards: true, minDistance: 0f, distanceToObject: 0f,
dt: 0.016f, moveTowards: true,
out var newOrient); out var newOrient);
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result); Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
@ -65,7 +108,8 @@ public class RemoteMoveToDriverTests
var result = RemoteMoveToDriver.Drive( var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest, bodyPos, bodyRot, dest,
minDistance: 0f, dt: 0.016f, moveTowards: true, minDistance: 0f, distanceToObject: 0f,
dt: 0.016f, moveTowards: true,
out var newOrient); out var newOrient);
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result); Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
@ -91,7 +135,8 @@ public class RemoteMoveToDriverTests
var result = RemoteMoveToDriver.Drive( var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest, bodyPos, bodyRot, dest,
minDistance: 0f, dt: dt, moveTowards: true, minDistance: 0f, distanceToObject: 0f,
dt: dt, moveTowards: true,
out var newOrient); out var newOrient);
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result); Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
@ -114,7 +159,8 @@ public class RemoteMoveToDriverTests
var result = RemoteMoveToDriver.Drive( var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest, bodyPos, bodyRot, dest,
minDistance: 0f, dt: dt, moveTowards: true, minDistance: 0f, distanceToObject: 0f,
dt: dt, moveTowards: true,
out var newOrient); out var newOrient);
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result); Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
@ -131,7 +177,8 @@ public class RemoteMoveToDriverTests
var result = RemoteMoveToDriver.Drive( var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest, bodyPos, bodyRot, dest,
minDistance: 0.5f, dt: 0.016f, moveTowards: true, minDistance: 0.5f, distanceToObject: 0.6f,
dt: 0.016f, moveTowards: true,
out var newOrient); out var newOrient);
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result); Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);