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);