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>
332 lines
14 KiB
C#
332 lines
14 KiB
C#
using System.Buffers.Binary;
|
|
using System.Collections.Generic;
|
|
|
|
namespace AcDream.Core.Net.Messages;
|
|
|
|
/// <summary>
|
|
/// Inbound <c>UpdateMotion</c> GameMessage (opcode <c>0xF74C</c>). The server
|
|
/// sends this whenever an already-spawned entity changes its motion state —
|
|
/// NPCs starting a walk cycle, creatures switching to attack stance, doors
|
|
/// opening, a player waving, etc. acdream's animation system needs to
|
|
/// consume these so the motion tick can switch the entity's cycle to the
|
|
/// new (stance, forward-command) pair instead of sitting on whatever the
|
|
/// initial CreateObject said.
|
|
///
|
|
/// <para>
|
|
/// Wire layout (see
|
|
/// <c>references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageUpdateMotion.cs</c>
|
|
/// and <c>references/ACE/Source/ACE.Server/Network/Motion/MovementData.cs::Write</c>
|
|
/// with <c>header = true</c>):
|
|
/// </para>
|
|
/// <list type="bullet">
|
|
/// <item><b>u32 opcode</b> — 0xF74C</item>
|
|
/// <item><b>u32 objectGuid</b> — which entity this update is for</item>
|
|
/// <item><b>u16 instanceSequence</b> — Sequences.ObjectInstance, tracked but not used for pose</item>
|
|
/// <item><b>MovementData with header</b>:
|
|
/// <list type="bullet">
|
|
/// <item>u16 movementSequence</item>
|
|
/// <item>u16 serverControlSequence</item>
|
|
/// <item>u8 isAutonomous, then align to 4 bytes</item>
|
|
/// <item>u8 movementType</item>
|
|
/// <item>u8 motionFlags</item>
|
|
/// <item>u16 currentStyle (MotionStance)</item>
|
|
/// <item>InterpretedMotionState when movementType == Invalid (0):
|
|
/// u32 flagsAndCommandCount, then each present field in flag order
|
|
/// (CurrentStyle u16, ForwardCommand u16, SidestepCommand u16,
|
|
/// TurnCommand u16, forward speed f32, sidestep speed f32,
|
|
/// turn speed f32), commands list, align.</item>
|
|
/// </list>
|
|
/// </item>
|
|
/// </list>
|
|
///
|
|
/// <para>
|
|
/// We only extract the two fields the animation system actually consumes:
|
|
/// the current <c>Stance</c> and the <c>ForwardCommand</c>. Everything else
|
|
/// is skipped. The outer message doesn't carry a length for MovementData,
|
|
/// so our parser reads exactly as far as it needs and leaves subsequent
|
|
/// bytes untouched.
|
|
/// </para>
|
|
/// </summary>
|
|
public static class UpdateMotion
|
|
{
|
|
public const uint Opcode = 0xF74Cu;
|
|
|
|
/// <summary>
|
|
/// Extracted payload: the guid of the entity whose motion changed and
|
|
/// the (stance, forward-command) pair describing its new pose. The
|
|
/// command is nullable because the <c>ForwardCommand</c> flag may be
|
|
/// unset in the InterpretedMotionState; the stance is always present
|
|
/// (even if 0, meaning "no specific stance").
|
|
/// </summary>
|
|
public readonly record struct Parsed(
|
|
uint Guid,
|
|
CreateObject.ServerMotionState MotionState);
|
|
|
|
/// <summary>
|
|
/// Parse a reassembled UpdateMotion body. <paramref name="body"/> must
|
|
/// start with the 4-byte opcode. Returns null on malformed input
|
|
/// (truncated fields, wrong opcode, malformed InterpretedMotionState).
|
|
/// </summary>
|
|
public static Parsed? TryParse(ReadOnlySpan<byte> body)
|
|
{
|
|
try
|
|
{
|
|
int pos = 0;
|
|
|
|
if (body.Length - pos < 4) return null;
|
|
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
|
pos += 4;
|
|
if (opcode != Opcode) return null;
|
|
|
|
if (body.Length - pos < 4) return null;
|
|
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
|
pos += 4;
|
|
|
|
// ObjectInstance sequence (u16) — tracked but not used for pose.
|
|
if (body.Length - pos < 2) return null;
|
|
pos += 2;
|
|
|
|
// MovementData header: u16 movementSequence, u16 serverControlSequence,
|
|
// u8 isAutonomous, then Align().
|
|
//
|
|
// ACE's Align() (Network/Extensions.cs:55) uses
|
|
// CalculatePadMultiple(BaseStream.Length, 4) — i.e. it pads based on
|
|
// the ABSOLUTE stream length, not a relative offset within the
|
|
// MovementData block.
|
|
//
|
|
// At this point the absolute stream has: opcode (4) + guid (4) +
|
|
// objectInstance (2) + movSeq (2) + srvSeq (2) + isAut (1) = 15.
|
|
// Align(4) rounds 15 → 16, so ONE pad byte is written.
|
|
// MovementData header = 2+2+1+1 = 6 bytes.
|
|
//
|
|
// Previous version mistakenly reserved 8 bytes here, which shifted
|
|
// every subsequent field by 2 and made every remote-char UpdateMotion
|
|
// decode as garbage (stance read from the packed-flags dword).
|
|
if (body.Length - pos < 6) return null;
|
|
pos += 6;
|
|
|
|
// movementType u8, motionFlags u8, currentStyle u16
|
|
if (body.Length - pos < 4) return null;
|
|
byte movementType = body[pos]; pos += 1;
|
|
byte _motionFlags = body[pos]; pos += 1;
|
|
ushort currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
|
pos += 2;
|
|
|
|
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
|
{
|
|
int preHex = Math.Min(body.Length, 32);
|
|
var hex = new System.Text.StringBuilder();
|
|
for (int i = 0; i < preHex; i++) hex.Append($"{body[i]:X2} ");
|
|
System.Console.WriteLine(
|
|
$" UM raw: mt=0x{movementType:X2} mf=0x{_motionFlags:X2} cs=0x{currentStyle:X4} | {hex}");
|
|
}
|
|
|
|
ushort? forwardCommand = null;
|
|
float? forwardSpeed = null;
|
|
ushort? sidestepCommand = null;
|
|
float? sidestepSpeed = null;
|
|
ushort? turnCommand = null;
|
|
float? turnSpeed = null;
|
|
uint? moveToParameters = null;
|
|
float? moveToSpeed = null;
|
|
float? moveToRunRate = null;
|
|
CreateObject.MoveToPathData? moveToPath = null;
|
|
List<CreateObject.MotionItem>? commands = null;
|
|
|
|
if (movementType == 0)
|
|
{
|
|
// InterpretedMotionState — same layout as in CreateObject's
|
|
// MovementInvalid branch, just reached via the header'd path.
|
|
// Includes the Commands list (MotionItem[]) that carries
|
|
// Actions, emotes, and other one-shots not in ForwardCommand.
|
|
if (body.Length - pos < 4) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType));
|
|
uint packed = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
|
pos += 4;
|
|
uint flags = packed & 0x7Fu;
|
|
uint numCommands = packed >> 7;
|
|
|
|
// Flag-bit layout + write order (ACE
|
|
// InterpretedMotionState.Write @ line 127 + MovementStateFlag
|
|
// enum — note the bit values are NOT in write order):
|
|
// CurrentStyle = 0x01 written first (ushort)
|
|
// ForwardCommand = 0x02 written second (ushort)
|
|
// SideStepCommand = 0x08 written third (ushort)
|
|
// TurnCommand = 0x20 written fourth (ushort)
|
|
// ForwardSpeed = 0x04 written fifth (float)
|
|
// SideStepSpeed = 0x10 written sixth (float)
|
|
// TurnSpeed = 0x40 written seventh (float)
|
|
// Our earlier version had the bit-to-field mapping wrong
|
|
// (treated Side/Turn commands as floats and ForwardSpeed as
|
|
// the wrong bit) — that's why every remote's ForwardSpeed
|
|
// was reading as "absent" (HasValue=False).
|
|
|
|
if ((flags & 0x1u) != 0)
|
|
{
|
|
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType));
|
|
currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
|
pos += 2;
|
|
}
|
|
if ((flags & 0x2u) != 0)
|
|
{
|
|
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType));
|
|
forwardCommand = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
|
pos += 2;
|
|
}
|
|
// SideStepCommand — ushort, bit 0x8
|
|
if ((flags & 0x8u) != 0)
|
|
{
|
|
if (body.Length - pos < 2) goto done;
|
|
sidestepCommand = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
|
pos += 2;
|
|
}
|
|
// TurnCommand — ushort, bit 0x20
|
|
if ((flags & 0x20u) != 0)
|
|
{
|
|
if (body.Length - pos < 2) goto done;
|
|
turnCommand = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
|
pos += 2;
|
|
}
|
|
// ForwardSpeed — float, bit 0x4
|
|
if ((flags & 0x4u) != 0)
|
|
{
|
|
if (body.Length - pos < 4) goto done;
|
|
forwardSpeed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
|
pos += 4;
|
|
}
|
|
// SideStepSpeed — float, bit 0x10
|
|
if ((flags & 0x10u) != 0)
|
|
{
|
|
if (body.Length - pos < 4) goto done;
|
|
sidestepSpeed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
|
pos += 4;
|
|
}
|
|
// TurnSpeed — float, bit 0x40
|
|
if ((flags & 0x40u) != 0)
|
|
{
|
|
if (body.Length - pos < 4) goto done;
|
|
turnSpeed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
|
pos += 4;
|
|
}
|
|
|
|
// Commands list: actions/emotes/attacks. Guard against a
|
|
// malformed numCommands by capping at a sane max.
|
|
if (numCommands > 0 && numCommands < 1024)
|
|
{
|
|
commands = new List<CreateObject.MotionItem>((int)numCommands);
|
|
for (int i = 0; i < numCommands; i++)
|
|
{
|
|
if (body.Length - pos < 8) break;
|
|
ushort cmd = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
|
ushort seq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos + 2));
|
|
float speed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos + 4));
|
|
pos += 8;
|
|
commands.Add(new CreateObject.MotionItem(cmd, seq, speed));
|
|
}
|
|
}
|
|
done:;
|
|
}
|
|
else if (movementType is 6 or 7)
|
|
{
|
|
TryParseMoveToPayload(
|
|
body,
|
|
pos,
|
|
movementType,
|
|
out moveToParameters,
|
|
out moveToSpeed,
|
|
out moveToRunRate,
|
|
out moveToPath);
|
|
}
|
|
|
|
return new Parsed(guid, new CreateObject.ServerMotionState(
|
|
currentStyle, forwardCommand, forwardSpeed, commands,
|
|
sidestepCommand, sidestepSpeed, turnCommand, turnSpeed,
|
|
movementType,
|
|
moveToParameters,
|
|
moveToSpeed,
|
|
moveToRunRate,
|
|
moveToPath));
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static bool TryParseMoveToPayload(
|
|
ReadOnlySpan<byte> body,
|
|
int pos,
|
|
byte movementType,
|
|
out uint? movementParameters,
|
|
out float? speed,
|
|
out float? runRate,
|
|
out CreateObject.MoveToPathData? path)
|
|
{
|
|
movementParameters = null;
|
|
speed = null;
|
|
runRate = null;
|
|
path = null;
|
|
|
|
// Retail MovementManager::PerformMovement (0x00524440) consumes
|
|
// MoveToObject/MoveToPosition as:
|
|
// [object guid, for MoveToObject only]
|
|
// Origin(cell + xyz)
|
|
// MovementParameters::UnPackNet (0x0052AC50): flags, distance,
|
|
// min, fail, speed, walk/run threshold, desired heading
|
|
// f32 runRate copied into CMotionInterp::my_run_rate.
|
|
//
|
|
// Phase L.1c (2026-04-28): the full path payload is now retained on
|
|
// <see cref="CreateObject.MoveToPathData"/> so the per-tick remote
|
|
// body driver can steer toward Origin instead of holding velocity at
|
|
// zero between sparse UpdatePosition snaps. The 882a07c stabilizer
|
|
// was deliberately conservative because we only had speed+runRate;
|
|
// with the rest of the packet captured, the body solver has full
|
|
// path data and can run faithfully.
|
|
uint? targetGuid = null;
|
|
if (movementType == 6)
|
|
{
|
|
if (body.Length - pos < 4) return false;
|
|
targetGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
|
pos += 4;
|
|
}
|
|
|
|
if (body.Length - pos < 16 + 28 + 4) return false;
|
|
|
|
uint originCellId = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
|
pos += 4;
|
|
float originX = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
|
pos += 4;
|
|
float originY = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
|
pos += 4;
|
|
float originZ = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
|
pos += 4;
|
|
|
|
movementParameters = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
|
pos += 4;
|
|
float distanceToObject = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
|
pos += 4;
|
|
float minDistance = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
|
pos += 4;
|
|
float failDistance = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
|
pos += 4;
|
|
speed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
|
pos += 4;
|
|
float walkRunThreshold = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
|
pos += 4;
|
|
float desiredHeading = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
|
pos += 4;
|
|
runRate = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
|
|
|
path = new CreateObject.MoveToPathData(
|
|
targetGuid,
|
|
originCellId,
|
|
originX,
|
|
originY,
|
|
originZ,
|
|
distanceToObject,
|
|
minDistance,
|
|
failDistance,
|
|
walkRunThreshold,
|
|
desiredHeading);
|
|
return true;
|
|
}
|
|
}
|