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:
parent
882a07cfde
commit
186a584404
7 changed files with 917 additions and 19 deletions
|
|
@ -251,5 +251,69 @@ public class UpdateMotionTests
|
|||
Assert.Equal(1.25f, result.Value.MotionState.MoveToSpeed);
|
||||
Assert.Equal(1.5f, result.Value.MotionState.MoveToRunRate);
|
||||
Assert.True(result.Value.MotionState.MoveToCanRun);
|
||||
Assert.True(result.Value.MotionState.MoveTowards);
|
||||
|
||||
// Phase L.1c (2026-04-28): full path payload retained.
|
||||
Assert.NotNull(result.Value.MotionState.MoveToPath);
|
||||
var path = result.Value.MotionState.MoveToPath!.Value;
|
||||
Assert.Null(path.TargetGuid);
|
||||
Assert.Equal(0xA8B4000Eu, path.OriginCellId);
|
||||
Assert.Equal(10f, path.OriginX);
|
||||
Assert.Equal(20f, path.OriginY);
|
||||
Assert.Equal(30f, path.OriginZ);
|
||||
Assert.Equal(0.6f, path.DistanceToObject);
|
||||
Assert.Equal(0.0f, path.MinDistance);
|
||||
Assert.Equal(float.MaxValue, path.FailDistance);
|
||||
Assert.Equal(15.0f, path.WalkRunThreshold);
|
||||
Assert.Equal(90.0f, path.DesiredHeading);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsesMoveToObjectTargetGuidAndOrigin()
|
||||
{
|
||||
// Type 6 (MoveToObject) prepends a u32 target guid before the
|
||||
// standard Origin + MovementParameters + runRate payload.
|
||||
// Body size: 20 (header) + 4 (guid) + 16 (origin) + 28 (params) + 4 (runRate) = 72.
|
||||
var body = new byte[20 + 4 + 16 + 28 + 4];
|
||||
int p = 0;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80004321u); p += 4;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
|
||||
p += 6; // MovementData header padding
|
||||
|
||||
body[p++] = 6; // MoveToObject
|
||||
body[p++] = 0;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2;
|
||||
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80001234u); p += 4; // target guid
|
||||
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xA8B4000Eu); p += 4; // cell
|
||||
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 5f); p += 4; // origin x
|
||||
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 6f); p += 4; // origin y
|
||||
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 7f); p += 4; // origin z
|
||||
|
||||
const uint flags = 0x1u | 0x2u | 0x200u; // can_walk | can_run | move_towards
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), flags); p += 4;
|
||||
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.6f); p += 4;
|
||||
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.0f); p += 4;
|
||||
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), float.MaxValue); p += 4;
|
||||
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.0f); p += 4;
|
||||
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 15.0f); p += 4;
|
||||
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.57f); p += 4;
|
||||
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.25f); p += 4; // runRate
|
||||
|
||||
var result = UpdateMotion.TryParse(body);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal((byte)6, result!.Value.MotionState.MovementType);
|
||||
Assert.True(result.Value.MotionState.IsServerControlledMoveTo);
|
||||
Assert.NotNull(result.Value.MotionState.MoveToPath);
|
||||
var path = result.Value.MotionState.MoveToPath!.Value;
|
||||
Assert.Equal(0x80001234u, path.TargetGuid);
|
||||
Assert.Equal(0xA8B4000Eu, path.OriginCellId);
|
||||
Assert.Equal(5f, path.OriginX);
|
||||
Assert.Equal(6f, path.OriginY);
|
||||
Assert.Equal(7f, path.OriginZ);
|
||||
Assert.Equal(1.25f, result.Value.MotionState.MoveToRunRate);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
159
tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs
Normal file
159
tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Phase L.1c (2026-04-28). Covers <see cref="RemoteMoveToDriver"/> — the
|
||||
/// per-tick steering port of retail
|
||||
/// <c>MoveToManager::HandleMoveToPosition</c> for server-controlled remote
|
||||
/// creatures.
|
||||
/// </summary>
|
||||
public class RemoteMoveToDriverTests
|
||||
{
|
||||
private const float Epsilon = 1e-3f;
|
||||
|
||||
private static float Yaw(Quaternion q)
|
||||
{
|
||||
var fwd = Vector3.Transform(new Vector3(0, 1, 0), q);
|
||||
return MathF.Atan2(-fwd.X, fwd.Y);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drive_AlreadyAtTarget_ReportsArrived()
|
||||
{
|
||||
var bodyPos = new Vector3(10f, 20f, 0f);
|
||||
var bodyRot = Quaternion.Identity;
|
||||
var dest = new Vector3(10f, 20.3f, 0f);
|
||||
|
||||
var result = RemoteMoveToDriver.Drive(
|
||||
bodyPos, bodyRot, dest,
|
||||
minDistance: 0.5f, dt: 0.016f, moveTowards: true,
|
||||
out var newOrient);
|
||||
|
||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
|
||||
Assert.Equal(bodyRot, newOrient); // orientation untouched
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drive_ChasingButNotInRange_ReportsSteering()
|
||||
{
|
||||
var bodyPos = new Vector3(0f, 0f, 0f);
|
||||
var bodyRot = Quaternion.Identity; // facing +Y
|
||||
var dest = new Vector3(0f, 50f, 0f); // straight ahead
|
||||
|
||||
var result = RemoteMoveToDriver.Drive(
|
||||
bodyPos, bodyRot, dest,
|
||||
minDistance: 0f, dt: 0.016f, moveTowards: true,
|
||||
out var newOrient);
|
||||
|
||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
|
||||
// Already facing target → snap branch keeps yaw at 0.
|
||||
Assert.InRange(Yaw(newOrient), -Epsilon, Epsilon);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drive_TargetSlightlyOffAxis_SnapsWithinTolerance()
|
||||
{
|
||||
// Body facing +Y; target at (1, 10, 0) — that's a small angle
|
||||
// (about 5.7°), well within the 20° snap tolerance.
|
||||
var bodyPos = Vector3.Zero;
|
||||
var bodyRot = Quaternion.Identity;
|
||||
var dest = new Vector3(1f, 10f, 0f);
|
||||
|
||||
var result = RemoteMoveToDriver.Drive(
|
||||
bodyPos, bodyRot, dest,
|
||||
minDistance: 0f, dt: 0.016f, moveTowards: true,
|
||||
out var newOrient);
|
||||
|
||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
|
||||
// Snap should land us pointing at (1, 10): yaw = atan2(-1, 10) ≈ -0.0997 rad.
|
||||
float expectedYaw = MathF.Atan2(-1f, 10f);
|
||||
Assert.InRange(Yaw(newOrient), expectedYaw - Epsilon, expectedYaw + Epsilon);
|
||||
|
||||
// Verify orientation actually transforms +Y onto the (1,10) line.
|
||||
var worldFwd = Vector3.Transform(new Vector3(0, 1, 0), newOrient);
|
||||
Assert.InRange(worldFwd.X / worldFwd.Y, 0.1f - 1e-3f, 0.1f + 1e-3f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drive_TargetBeyondTolerance_RotatesByLimitedStep()
|
||||
{
|
||||
// Body facing +Y; target at (-10, 0) — that's 90° to the left
|
||||
// (well beyond the 20° snap tolerance), so we turn by at most
|
||||
// TurnRateRadPerSec * dt this tick rather than snapping.
|
||||
var bodyPos = Vector3.Zero;
|
||||
var bodyRot = Quaternion.Identity; // yaw = 0
|
||||
var dest = new Vector3(-10f, 0f, 0f); // yaw = +π/2 (left)
|
||||
const float dt = 0.1f;
|
||||
|
||||
var result = RemoteMoveToDriver.Drive(
|
||||
bodyPos, bodyRot, dest,
|
||||
minDistance: 0f, dt: dt, moveTowards: true,
|
||||
out var newOrient);
|
||||
|
||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
|
||||
float expectedStep = RemoteMoveToDriver.TurnRateRadPerSec * dt;
|
||||
// We should turn LEFT (positive yaw) toward the target.
|
||||
Assert.InRange(Yaw(newOrient), expectedStep - Epsilon, expectedStep + Epsilon);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drive_TargetBehind_TurnsRightOrLeftViaShortestPath()
|
||||
{
|
||||
// Body facing +Y; target directly behind at (0, -10, 0).
|
||||
// |delta| = π, equally close either way; the implementation
|
||||
// picks one (sign depends on float wobble) — just assert
|
||||
// we made progress (yaw changed by exactly TurnRate * dt).
|
||||
var bodyPos = Vector3.Zero;
|
||||
var bodyRot = Quaternion.Identity;
|
||||
var dest = new Vector3(0f, -10f, 0f);
|
||||
const float dt = 0.1f;
|
||||
|
||||
var result = RemoteMoveToDriver.Drive(
|
||||
bodyPos, bodyRot, dest,
|
||||
minDistance: 0f, dt: dt, moveTowards: true,
|
||||
out var newOrient);
|
||||
|
||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
|
||||
float expectedStep = RemoteMoveToDriver.TurnRateRadPerSec * dt;
|
||||
Assert.InRange(MathF.Abs(Yaw(newOrient)), expectedStep - Epsilon, expectedStep + Epsilon);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drive_PreservesOrientationAtArrival()
|
||||
{
|
||||
var bodyPos = new Vector3(5f, 5f, 0f);
|
||||
var bodyRot = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 1.234f);
|
||||
var dest = new Vector3(5.01f, 5.01f, 0f);
|
||||
|
||||
var result = RemoteMoveToDriver.Drive(
|
||||
bodyPos, bodyRot, dest,
|
||||
minDistance: 0.5f, dt: 0.016f, moveTowards: true,
|
||||
out var newOrient);
|
||||
|
||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
|
||||
// Caller would zero velocity; orientation should be untouched
|
||||
// so the body settles facing whatever direction it was already.
|
||||
Assert.Equal(bodyRot, newOrient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginToWorld_AppliesLandblockGridShift()
|
||||
{
|
||||
// Cell ID 0xA8B4000E → landblock x=0xA8, y=0xB4. With live center
|
||||
// at (0xA9, 0xB4), that's one landblock west and zero north,
|
||||
// so origin (10, 20, 0) inside that landblock should map to
|
||||
// (10 - 192, 20 + 0, 0) = (-182, 20, 0) in render-world space.
|
||||
var w = RemoteMoveToDriver.OriginToWorld(
|
||||
originCellId: 0xA8B4000Eu,
|
||||
originX: 10f, originY: 20f, originZ: 0f,
|
||||
liveCenterLandblockX: 0xA9, liveCenterLandblockY: 0xB4);
|
||||
|
||||
Assert.Equal(-182f, w.X);
|
||||
Assert.Equal(20f, w.Y);
|
||||
Assert.Equal(0f, w.Z);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue