diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index aae7478..300919e 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -270,6 +270,16 @@ public sealed class GameWindow : IDisposable
/// chase. False = flee (move_away) or static target.
///
public bool MoveToMoveTowards;
+
+ ///
+ /// 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
+ ///
+ /// — typically because the entity left our streaming view and
+ /// the server stopped broadcasting its MoveTo updates.
+ ///
+ public double LastMoveToPacketTime;
///
/// Legacy field — no longer used for slerp (retail hard-snaps
/// per FUN_00514b90 set_frame). Kept to avoid churn.
@@ -2514,6 +2524,8 @@ public sealed class GameWindow : IDisposable
remoteMot.MoveToDistanceToObject = path.DistanceToObject;
remoteMot.MoveToMoveTowards = update.MotionState.MoveTowards;
remoteMot.HasMoveToDestination = true;
+ remoteMot.LastMoveToPacketTime =
+ (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
}
else if (!update.MotionState.IsServerControlledMoveTo)
{
@@ -5121,35 +5133,54 @@ public sealed class GameWindow : IDisposable
// server-supplied destination, then let
// apply_current_movement set Velocity from the
// 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
- .DriveResult.Arrived)
+ // Stale-destination guard (2026-04-28): if no
+ // 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
- // next MoveTo packet refreshes the destination
- // (or the server explicitly stops us with an
- // interpreted-motion UM cmd=Ready).
+ rm.HasMoveToDestination = false;
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);
+ var driveResult = AcDream.Core.Physics.RemoteMoveToDriver
+ .Drive(
+ rm.Body.Position,
+ rm.Body.Orientation,
+ rm.MoveToDestinationWorld,
+ rm.MoveToMinDistance,
+ 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)
diff --git a/src/AcDream.Core/Physics/RemoteMoveToDriver.cs b/src/AcDream.Core/Physics/RemoteMoveToDriver.cs
index 0b6a675..54152b0 100644
--- a/src/AcDream.Core/Physics/RemoteMoveToDriver.cs
+++ b/src/AcDream.Core/Physics/RemoteMoveToDriver.cs
@@ -84,6 +84,21 @@ public static class RemoteMoveToDriver
///
public const float ArrivalEpsilon = 0.05f;
+ ///
+ /// 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.
+ ///
+ public const double StaleDestinationSeconds = 1.5;
+
public enum DriveResult
{
/// Within arrival window — caller should zero velocity.
@@ -95,17 +110,33 @@ public static class RemoteMoveToDriver
///
/// Steer body orientation toward
- /// and report whether the body has arrived (within
- /// ) or should keep running. Pure
- /// function — emits the updated orientation via
+ /// and report whether the body has arrived or should keep running.
+ /// Pure function — emits the updated orientation via
/// (the input is not mutated; the
/// caller assigns the new value back to its body).
///
+ ///
+ /// min_distance from the wire's MovementParameters block —
+ /// retail's HandleMoveToPosition chase-arrival threshold.
+ ///
+ ///
+ /// distance_to_object from the wire — ACE's chase-arrival
+ /// threshold (default 0.6 m, the melee range). The actual arrival
+ /// gate is max(minDistance, distanceToObject): retail-faithful
+ /// when retail sends min_distance > 0, ACE-compatible when
+ /// ACE puts the value in distance_to_object with
+ /// min_distance == 0. Without this, ACE's min_distance==0
+ /// 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).
+ ///
public static DriveResult Drive(
Vector3 bodyPosition,
Quaternion bodyOrientation,
Vector3 destinationWorld,
float minDistance,
+ float distanceToObject,
float dt,
bool moveTowards,
out Quaternion newOrientation)
@@ -116,10 +147,15 @@ public static class RemoteMoveToDriver
float dy = destinationWorld.Y - bodyPosition.Y;
float dist = MathF.Sqrt(dx * dx + dy * dy);
- // Arrival predicate per retail MoveToManager::HandleMoveToPosition
- // (chase: dist ≤ min_distance; flee branch is unused here, but
- // we honor the moveTowards flag for symmetry).
- if (moveTowards && dist <= minDistance + ArrivalEpsilon)
+ // Arrival predicate. Retail (named decomp): dist ≤ min_distance.
+ // ACE port: dist ≤ DistanceToObject. ACE's wire put the melee
+ // threshold in DistanceToObject (default 0.6) and left
+ // 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;
return DriveResult.Arrived;
diff --git a/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs b/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs
index 7624734..3274702 100644
--- a/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs
@@ -30,13 +30,55 @@ public class RemoteMoveToDriverTests
var result = RemoteMoveToDriver.Drive(
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);
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
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]
public void Drive_ChasingButNotInRange_ReportsSteering()
{
@@ -46,7 +88,8 @@ public class RemoteMoveToDriverTests
var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest,
- minDistance: 0f, dt: 0.016f, moveTowards: true,
+ minDistance: 0f, distanceToObject: 0f,
+ dt: 0.016f, moveTowards: true,
out var newOrient);
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
@@ -65,7 +108,8 @@ public class RemoteMoveToDriverTests
var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest,
- minDistance: 0f, dt: 0.016f, moveTowards: true,
+ minDistance: 0f, distanceToObject: 0f,
+ dt: 0.016f, moveTowards: true,
out var newOrient);
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
@@ -91,7 +135,8 @@ public class RemoteMoveToDriverTests
var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest,
- minDistance: 0f, dt: dt, moveTowards: true,
+ minDistance: 0f, distanceToObject: 0f,
+ dt: dt, moveTowards: true,
out var newOrient);
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
@@ -114,7 +159,8 @@ public class RemoteMoveToDriverTests
var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest,
- minDistance: 0f, dt: dt, moveTowards: true,
+ minDistance: 0f, distanceToObject: 0f,
+ dt: dt, moveTowards: true,
out var newOrient);
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
@@ -131,7 +177,8 @@ public class RemoteMoveToDriverTests
var result = RemoteMoveToDriver.Drive(
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);
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);