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

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