feat(anim): Phase L.1c port MoveTo path data + per-tick steer

Root-causing the user-reported "monsters disappearing some time +
laggy/jittery locomotion" via systematic-debugging Phase 1: our
UpdateMotion parser kept only speed/runRate/flags from a movementType
6/7 packet and discarded Origin (destination), targetGuid, and the
distance/walkRunThreshold/desiredHeading half of MovementParameters.
The integrator consequently held Body.Velocity at zero during MoveTo
("incomplete state" stabilizer 882a07c), so the body froze with legs
animating until UpdatePosition snap-teleported it — sometimes outside
the visible window (disappearing) — and constant-velocity drift along
the old heading between snaps produced jitter on every UP correction.

The 882a07c stabilizer was deliberately conservative because the state
WAS incomplete. Completing the data plumbing makes its restriction
moot: with the full MoveTo payload captured, the body solver has every
field retail's MoveToManager::HandleMoveToPosition (0x00529d80) reads.

Why: server re-emits MoveTo packets ~1 Hz with refreshed Origin while
chasing — verified in the live log (guid 0x800003B5 seq 0x01FE→0x0204
all show different cell/xyz floats). Those are heading updates we'd
been throwing away. With the full payload retained, the per-tick driver
steers body orientation toward Origin (±20° snap tolerance, π/2 rad/s
turn rate above tolerance) and lets apply_current_movement fill in
Velocity from the existing RunForward cycle — no new motion path,
just the right heading.

Scope is the minimum viable subset: target re-tracking, sticky/StickTo,
fail-distance progress detector, and sphere-cylinder distance are
server-side concerns we don't need (server's emit cadence handles all
of them). MoveToObject_Internal target-guid resolution is also skipped
— Origin is refreshed each packet, so the effective target tracks the
real entity even without a guid lookup.

Cross-references:
- docs/research/named-retail/acclient_2013_pseudo_c.txt — MoveToManager
  + MovementParameters::UnPackNet (0x0052ac50) + apply_run_to_command
  (0x00527be0). 18,366 named PDB symbols make this the primary oracle.
- references/ACE/Source/ACE.Server/Physics/Animation/MoveToManager.cs
  — port aid; flagged divergences (WalkRunThreshold default, set_heading
  snap, inRange one-shot) called out in the new pseudocode doc.
- docs/research/2026-04-28-remote-moveto-pseudocode.md — pseudocode +
  ACE divergence flags + out-of-scope list per CLAUDE.md mandatory
  workflow (decompile → cross-reference → pseudocode → port).

Tests: 1404 → 1412 (parser type-7 path retention + type-6 target guid
retention; driver arrival, in-tolerance snap, beyond-tolerance step,
behind-target shortest-path turn, arrival preserves orientation,
Origin→world landblock-grid arithmetic).

Pending visual sign-off — handoff stabilizer 882a07c was the last
commit the user tested.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-28 21:49:22 +02:00
parent 882a07cfde
commit 186a584404
7 changed files with 917 additions and 19 deletions

View file

@ -0,0 +1,204 @@
using System;
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Per-tick steering for server-controlled remote creatures while a
/// MoveToObject (movementType 6) or MoveToPosition (movementType 7) packet
/// is the active locomotion source.
///
/// <para>
/// Replaces the 882a07c-era "hold body Velocity at zero during MoveTo"
/// stabilizer. With the full MoveTo path payload now captured on
/// <see cref="AcDream.Core.Net.Messages.CreateObject.MoveToPathData"/>,
/// the body solver has the destination + heading + thresholds it needs to
/// run the retail per-tick loop instead of waiting for sparse
/// UpdatePosition snap corrections.
/// </para>
///
/// <para>
/// Retail references:
/// <list type="bullet">
/// <item><description>
/// <c>MoveToManager::HandleMoveToPosition</c> (<c>0x00529d80</c>) — the
/// per-tick driver. Computes heading-to-target, fires an aux
/// <c>TurnLeft</c>/<c>TurnRight</c> command when |delta| &gt; 20°, snaps
/// orientation when within tolerance, and tests arrival via
/// <c>dist &lt;= min_distance</c> (chase) or
/// <c>dist &gt;= distance_to_object</c> (flee).
/// </description></item>
/// <item><description>
/// <c>MoveToManager::_DoMotion</c> / <c>_StopMotion</c> route turn
/// commands through <c>CMotionInterp::DoInterpretedMotion</c> — i.e.
/// MoveToManager itself does NOT touch the body. The body's actual
/// velocity comes from <c>CMotionInterp::apply_current_movement</c>
/// reading <c>InterpretedState.ForwardCommand = RunForward</c> and
/// emitting <c>velocity.Y = RunAnimSpeed × speedMod</c>, transformed by
/// the body's orientation.
/// </description></item>
/// </list>
/// </para>
///
/// <para>
/// Acdream port scope: minimum viable subset. We skip target re-tracking
/// (server re-emits MoveTo every ~1 s with refreshed Origin), sticky/
/// StickTo, fail-distance progress detector, and the sphere-cylinder
/// distance variant — all server-side concerns the local body doesn't need
/// to model. We DO port heading-to-target, the ±20° aux-turn tolerance
/// (with ACE's <c>set_heading(true)</c> snap-on-aligned fudge), and
/// arrival detection via <c>min_distance</c>.
/// </para>
///
/// <para>
/// ACE divergence: ACE swaps the chase/flee arrival predicates
/// (<c>dist &lt;= DistanceToObject</c> vs retail's <c>dist &lt;= MinDistance</c>).
/// We follow retail.
/// </para>
/// </summary>
public static class RemoteMoveToDriver
{
/// <summary>
/// Heading tolerance below which we snap orientation directly to the
/// target heading (ACE's <c>set_heading(target, true)</c>
/// server-tic-rate fudge). Above tolerance we rotate at
/// <see cref="TurnRateRadPerSec"/>. Retail value (line 307251 of
/// <c>acclient_2013_pseudo_c.txt</c>) is 20°.
/// </summary>
public const float HeadingSnapToleranceRad = 20.0f * MathF.PI / 180.0f;
/// <summary>
/// Default angular rate for in-motion heading correction when delta
/// exceeds <see cref="HeadingSnapToleranceRad"/>. Picked to match
/// ACE's <c>TurnSpeed</c> default of <c>π/2</c> rad/s for monsters;
/// when the per-creature value differs, the future port can wire it
/// in via the <c>TurnSpeed</c> field on InterpretedMotionState.
/// </summary>
public const float TurnRateRadPerSec = MathF.PI / 2.0f;
/// <summary>
/// Float-comparison slack for the arrival predicate. With
/// <c>min_distance == 0</c> in a chase packet, exact equality is
/// unreachable due to integration wobble; this epsilon prevents the
/// driver from over-shooting by a sub-meter and snap-flipping back.
/// </summary>
public const float ArrivalEpsilon = 0.05f;
public enum DriveResult
{
/// <summary>Within arrival window — caller should zero velocity.</summary>
Arrived,
/// <summary>Steering active — caller should let
/// <c>apply_current_movement</c> set body velocity from the cycle.</summary>
Steering,
}
/// <summary>
/// Steer body orientation toward <paramref name="destinationWorld"/>
/// and report whether the body has arrived (within
/// <paramref name="minDistance"/>) or should keep running. Pure
/// function — emits the updated orientation via
/// <paramref name="newOrientation"/> (the input is not mutated; the
/// caller assigns the new value back to its body).
/// </summary>
public static DriveResult Drive(
Vector3 bodyPosition,
Quaternion bodyOrientation,
Vector3 destinationWorld,
float minDistance,
float dt,
bool moveTowards,
out Quaternion newOrientation)
{
// Horizontal distance only — server owns Z, our body Z is
// hard-snapped to the latest UpdatePosition.
float dx = destinationWorld.X - bodyPosition.X;
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)
{
newOrientation = bodyOrientation;
return DriveResult.Arrived;
}
// Degenerate — already on target horizontally; preserve heading.
if (dist < 1e-4f)
{
newOrientation = bodyOrientation;
return DriveResult.Steering;
}
// Body's local-forward is +Y (see MotionInterpreter.get_state_velocity
// at line 605-616: velocity.Y = (Walk/Run)AnimSpeed × ForwardSpeed).
// World forward = Transform((0,1,0), orientation). Yaw extracted
// via atan2(-worldFwd.X, worldFwd.Y) so yaw = 0 ↔ orientation = Identity.
var localForward = new Vector3(0f, 1f, 0f);
var worldForward = Vector3.Transform(localForward, bodyOrientation);
float currentYaw = MathF.Atan2(-worldForward.X, worldForward.Y);
// Desired heading: face the target. (dx, dy) is the world-space
// offset to the target. With local-forward=+Y we want yaw such
// that Transform((0,1,0), R_Z(yaw)) = (dx, dy)/dist; that solves
// to yaw = atan2(-dx, dy).
float desiredYaw = MathF.Atan2(-dx, dy);
float delta = WrapPi(desiredYaw - currentYaw);
if (MathF.Abs(delta) <= HeadingSnapToleranceRad)
{
// ACE's set_heading(target, true) — sync to server-tic-rate.
// We have the same sparse-UP problem ACE does, so the same
// fudge applies.
newOrientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, desiredYaw);
}
else
{
// Retail BeginTurnToHeading / HandleMoveToPosition aux turn:
// rotate at TurnRate clamped to dt, in the shorter direction.
float maxStep = TurnRateRadPerSec * dt;
float step = MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep);
// Apply incremental yaw around world +Z (preserving any
// server-supplied pitch/roll from the latest UpdatePosition).
var deltaQuat = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, step);
newOrientation = Quaternion.Normalize(deltaQuat * bodyOrientation);
}
return DriveResult.Steering;
}
/// <summary>
/// Convert a landblock-local Origin from a MoveTo packet
/// (<see cref="AcDream.Core.Net.Messages.CreateObject.MoveToPathData"/>)
/// into acdream's render world space using the same arithmetic as
/// <c>OnLivePositionUpdated</c>: shift by the landblock-grid offset
/// from the live-mode center.
/// </summary>
public static Vector3 OriginToWorld(
uint originCellId,
float originX,
float originY,
float originZ,
int liveCenterLandblockX,
int liveCenterLandblockY)
{
int lbX = (int)((originCellId >> 24) & 0xFFu);
int lbY = (int)((originCellId >> 16) & 0xFFu);
return new Vector3(
originX + (lbX - liveCenterLandblockX) * 192f,
originY + (lbY - liveCenterLandblockY) * 192f,
originZ);
}
/// <summary>Wrap an angle in radians to [-π, π].</summary>
private static float WrapPi(float r)
{
const float TwoPi = MathF.PI * 2f;
r %= TwoPi;
if (r > MathF.PI) r -= TwoPi;
if (r < -MathF.PI) r += TwoPi;
return r;
}
}