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:
parent
186a584404
commit
d247aef2e4
3 changed files with 150 additions and 36 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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> > 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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue