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>
319 lines
16 KiB
C#
319 lines
16 KiB
C#
using System;
|
||
using System.Buffers.Binary;
|
||
using AcDream.Core.Net.Messages;
|
||
using Xunit;
|
||
|
||
namespace AcDream.Core.Net.Tests.Messages;
|
||
|
||
/// <summary>
|
||
/// Covers <see cref="UpdateMotion.TryParse"/> — the 0xF74C GameMessage the
|
||
/// server sends when an entity's motion state changes (NPC starts walking,
|
||
/// creature enters combat, door opens, etc). The parser shares the inner
|
||
/// MovementData decoder with CreateObject but reaches it through a
|
||
/// different outer layout, so we need standalone coverage.
|
||
/// </summary>
|
||
public class UpdateMotionTests
|
||
{
|
||
[Fact]
|
||
public void RejectsWrongOpcode()
|
||
{
|
||
var body = new byte[32];
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body, 0xDEADBEEFu);
|
||
Assert.Null(UpdateMotion.TryParse(body));
|
||
}
|
||
|
||
[Fact]
|
||
public void RejectsTruncated()
|
||
{
|
||
Assert.Null(UpdateMotion.TryParse(new byte[3]));
|
||
Assert.Null(UpdateMotion.TryParse(Array.Empty<byte>()));
|
||
}
|
||
|
||
[Fact]
|
||
public void ParsesStanceOnly_WhenForwardCommandFlagUnset()
|
||
{
|
||
// Layout:
|
||
// u32 opcode = 0xF74C
|
||
// u32 guid
|
||
// u16 instanceSeq
|
||
// u16 movementSeq + u16 serverControlSeq + u8 isAutonomous + 1 pad (= 6 bytes total header, per ACE Align())
|
||
// u8 movementType = 0 (Invalid)
|
||
// u8 motionFlags = 0
|
||
// u16 currentStyle (outer MovementData field) = 0x0042
|
||
// u32 packed = CurrentStyle flag (0x1) only
|
||
// u16 inner currentStyle = 0x0005 (overrides outer per InterpretedMotionState semantics)
|
||
var body = new byte[4 + 4 + 2 + 6 + 4 + 4 + 2];
|
||
int p = 0;
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x12345678u); p += 4;
|
||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0001); p += 2;
|
||
// 8-byte header slot — leave zero
|
||
p += 6;
|
||
body[p++] = 0; // movementType = Invalid
|
||
body[p++] = 0; // motionFlags
|
||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0042); p += 2;
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x1u); p += 4; // flags = CurrentStyle only
|
||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0005); p += 2;
|
||
|
||
var result = UpdateMotion.TryParse(body);
|
||
Assert.NotNull(result);
|
||
Assert.Equal(0x12345678u, result!.Value.Guid);
|
||
Assert.Equal((ushort)0x0005, result.Value.MotionState.Stance);
|
||
Assert.Null(result.Value.MotionState.ForwardCommand);
|
||
}
|
||
|
||
[Fact]
|
||
public void ParsesStanceAndForwardCommand()
|
||
{
|
||
// Flags = CurrentStyle (0x1) | ForwardCommand (0x2)
|
||
var body = new byte[4 + 4 + 2 + 6 + 4 + 4 + 2 + 2];
|
||
int p = 0;
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xABCDEF01u); p += 4;
|
||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0010); p += 2;
|
||
p += 6; // MovementData header slot
|
||
body[p++] = 0;
|
||
body[p++] = 0;
|
||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0000); p += 2; // outer style = 0
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x3u); p += 4; // CurrentStyle + ForwardCommand
|
||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x000D); p += 2; // stance = 0xD
|
||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0007); p += 2; // forward command = 0x7 (Run)
|
||
|
||
var result = UpdateMotion.TryParse(body);
|
||
Assert.NotNull(result);
|
||
Assert.Equal(0xABCDEF01u, result!.Value.Guid);
|
||
Assert.Equal((ushort)0x000D, result.Value.MotionState.Stance);
|
||
Assert.Equal((ushort)0x0007, result.Value.MotionState.ForwardCommand);
|
||
}
|
||
|
||
[Fact]
|
||
public void ParsesNoFlagsSet_KeepsOuterStance()
|
||
{
|
||
// When the InterpretedMotionState flags are zero, neither the inner
|
||
// currentStyle nor the forward command are present in the payload,
|
||
// so the parser should fall back to the MovementData outer stance
|
||
// field and leave ForwardCommand null.
|
||
var body = new byte[4 + 4 + 2 + 6 + 4 + 4];
|
||
int p = 0;
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x55555555u); p += 4;
|
||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
|
||
p += 6;
|
||
body[p++] = 0;
|
||
body[p++] = 0;
|
||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x00AA); p += 2;
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0u); p += 4; // no flags
|
||
|
||
var result = UpdateMotion.TryParse(body);
|
||
Assert.NotNull(result);
|
||
Assert.Equal((ushort)0x00AA, result!.Value.MotionState.Stance);
|
||
Assert.Null(result.Value.MotionState.ForwardCommand);
|
||
}
|
||
|
||
[Fact]
|
||
public void ParsesForwardSpeed_WhenSpeedFlagSet()
|
||
{
|
||
// Flags = CurrentStyle | ForwardCommand | ForwardSpeed
|
||
// = 0x1 | 0x2 | 0x4 = 0x7
|
||
// (Per ACE MovementStateFlag enum — ForwardSpeed is bit 0x4,
|
||
// NOT 0x10. The earlier test had the wrong mapping; see
|
||
// references/ACE/Source/ACE.Entity/Enum/MovementStateFlag.cs)
|
||
// Test value: 1.5× speed — matches a typical RunRate broadcast.
|
||
var body = new byte[4 + 4 + 2 + 6 + 4 + 4 + 2 + 2 + 4];
|
||
int p = 0;
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x1A2B3C4Du); p += 4;
|
||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
|
||
p += 6; // MovementData header
|
||
body[p++] = 0;
|
||
body[p++] = 0;
|
||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x7u); p += 4;
|
||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2; // NonCombat
|
||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0007); p += 2; // RunForward
|
||
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.5f); p += 4; // speed
|
||
|
||
var result = UpdateMotion.TryParse(body);
|
||
Assert.NotNull(result);
|
||
Assert.Equal((ushort)0x003D, result!.Value.MotionState.Stance);
|
||
Assert.Equal((ushort)0x0007, result.Value.MotionState.ForwardCommand);
|
||
Assert.Equal(1.5f, result.Value.MotionState.ForwardSpeed);
|
||
}
|
||
|
||
[Fact]
|
||
public void ParsesCommandsList_Wave()
|
||
{
|
||
// A typical NPC wave broadcast:
|
||
// - stance NonCombat (0x003D)
|
||
// - ForwardCommand flag set, command = 0x0003 (Ready)
|
||
// - numCommands = 1, with a single MotionItem{ cmd=0x0087 Wave, seq=0, speed=1.0 }
|
||
//
|
||
// Packed u32 = (flags | numCommands << 7)
|
||
// flags = 0x01 (CurrentStyle) | 0x02 (ForwardCommand) = 0x03
|
||
// numCommands << 7 = 1 << 7 = 0x80
|
||
// total = 0x83
|
||
var body = new byte[4 + 4 + 2 + 6 + 4 + 4 + 2 + 2 + 8];
|
||
int p = 0;
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xDEADBEEFu); p += 4;
|
||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
|
||
p += 6;
|
||
body[p++] = 0;
|
||
body[p++] = 0;
|
||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x83u); p += 4; // flags=0x3 + numCommands=1
|
||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2; // stance
|
||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0003); p += 2; // fwd cmd = Ready
|
||
|
||
// MotionItem: u16 command + u16 packedSeq + f32 speed
|
||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0087); p += 2; // Wave
|
||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0001); p += 2;
|
||
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.0f); p += 4;
|
||
|
||
var result = UpdateMotion.TryParse(body);
|
||
Assert.NotNull(result);
|
||
Assert.Equal((ushort)0x003D, result!.Value.MotionState.Stance);
|
||
Assert.Equal((ushort)0x0003, result.Value.MotionState.ForwardCommand);
|
||
|
||
Assert.NotNull(result.Value.MotionState.Commands);
|
||
Assert.Single(result.Value.MotionState.Commands!);
|
||
var wave = result.Value.MotionState.Commands![0];
|
||
Assert.Equal((ushort)0x0087, wave.Command);
|
||
Assert.Equal(1.0f, wave.Speed);
|
||
}
|
||
|
||
[Fact]
|
||
public void HandlesNonInvalidMovementType_GracefullyReturnsOuterStance()
|
||
{
|
||
// movementType != 0 means one of the Move* variants; a truncated
|
||
// non-Invalid payload still returns the outer state.
|
||
// The parser must still return a valid Parsed with the outer stance
|
||
// and a null ForwardCommand rather than failing the whole message.
|
||
var body = new byte[4 + 4 + 2 + 6 + 4];
|
||
int p = 0;
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x99999999u); p += 4;
|
||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
|
||
p += 6;
|
||
body[p++] = 7; // movementType = MoveToPosition (non-Invalid)
|
||
body[p++] = 0;
|
||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x00CC); p += 2;
|
||
|
||
var result = UpdateMotion.TryParse(body);
|
||
Assert.NotNull(result);
|
||
Assert.Equal((ushort)0x00CC, result!.Value.MotionState.Stance);
|
||
Assert.Null(result.Value.MotionState.ForwardCommand);
|
||
Assert.Equal((byte)7, result.Value.MotionState.MovementType);
|
||
Assert.True(result.Value.MotionState.IsServerControlledMoveTo);
|
||
}
|
||
|
||
[Fact]
|
||
public void ParsesMoveToPositionSpeedAndRunRate()
|
||
{
|
||
// Layout after MovementData's movementType/motionFlags/currentStyle:
|
||
// Origin: cell + xyz (16 bytes)
|
||
// MoveToParameters: flags, distance, min, fail, speed,
|
||
// walk/run threshold, desired heading (28 bytes)
|
||
// runRate: f32
|
||
var body = new byte[4 + 4 + 2 + 6 + 4 + 16 + 28 + 4];
|
||
int p = 0;
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80001234u); p += 4;
|
||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
|
||
p += 6;
|
||
body[p++] = 7; // MoveToPosition
|
||
body[p++] = 0;
|
||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2;
|
||
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xA8B4000Eu); p += 4;
|
||
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 10f); p += 4;
|
||
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 20f); p += 4;
|
||
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 30f); p += 4;
|
||
|
||
const uint canWalkCanRunMoveTowards = 0x1u | 0x2u | 0x200u;
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), canWalkCanRunMoveTowards); 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.25f); p += 4;
|
||
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 15.0f); p += 4;
|
||
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 90.0f); p += 4;
|
||
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.5f); p += 4;
|
||
|
||
var result = UpdateMotion.TryParse(body);
|
||
|
||
Assert.NotNull(result);
|
||
Assert.Equal((byte)7, result!.Value.MotionState.MovementType);
|
||
Assert.True(result.Value.MotionState.IsServerControlledMoveTo);
|
||
Assert.Equal((ushort)0x003D, result.Value.MotionState.Stance);
|
||
Assert.Null(result.Value.MotionState.ForwardCommand);
|
||
Assert.Equal(canWalkCanRunMoveTowards, result.Value.MotionState.MoveToParameters);
|
||
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);
|
||
}
|
||
}
|