acdream/src/AcDream.Core.Net/Messages/UpdateMotion.cs
Erik f423884bd1 feat(physics): R5-V4 behavioral slice — head stance dispatch (all mt) + #164 autonomy bit + mt-0 wire flags
Three unpack_movement parity items (facade deferred per the handoff's
own optional clause — see r5-wiring-handoff §V4 status):

1. HEAD style-on-change (0x00524440 @00524502-0052452c): both GameWindow
   routing heads (remote + player) now dispatch DoMotion(style, ctor
   defaults) when the UM's stance differs from the interp's current
   style, BEFORE the movement-type routing — for EVERY type. Previously
   style applied only through the mt-0 funnel copy, so a chase/turn UM
   (mt 6-9) carrying a stance change started the move in the OLD stance
   (a monster charged in NonCombat posture until the next mt-0). The
   RetailObserverTraceConformanceTests exclusion note updated — the
   trace filter stays (head calls can't appear in a
   MoveToInterpretedState replay) but the production gap it pointed at
   is closed.

2. #164 (closes): the action-replay loop threads each action's autonomy
   into the dispatch params (Autonomous = the 0x1000 splice, raw
   305982) — replayed actions enter the interpreted actions list with
   their real autonomy instead of ctor-default false.

3. mt-0 wire flags consumed (UpdateMotion parsed them since R4-V3):
   0x1 StickToObject → CPhysicsObj::stick_to_object port (0x005127e0:
   resolve target, PartArray radii — 0 when shapeless, guid-as-is for
   acdream's flat entity table — → PositionManager.StickTo; unresolvable
   target → no stick), at BOTH case-0 tails in retail order
   (@00524583-0052458e: funnel apply → stick → longjump flag);
   0x2 StandingLongJump → Motion.StandingLongJump, UNCONDITIONAL write
   (absent flag clears — retail @0052458e). ServerMotionState gains the
   StandingLongJump field.

Conformance: ChaseArm_WithStanceChange_AppliesStanceBeforeTheChase
(harness mirrors the routing-head dispatch) +
Actions_ReplayCarriesAutonomyIntoTheInterpretedList. Suite 4041 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 10:18:26 +02:00

481 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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; THEN — only when
/// <c>motionFlags &amp; 0x1</c> (StickToObject) — one trailing u32
/// sticky object guid (ACE <c>MovementInvalid.Write</c>; R4-V3).</item>
/// <item>MoveToObject (6) / MoveToPosition (7): [u32 target guid,
/// MoveToObject only] Origin (u32 cell + 3×f32), MoveToParameters /
/// <c>UnPackNet</c> 7-dword form (u32 bitfield + 6×f32), f32
/// runRate. R4-V3: exposed via
/// <see cref="AcDream.Core.Net.Messages.CreateObject.MoveToPathData"/>.</item>
/// <item>TurnToObject (8) / TurnToHeading (9): [u32 target guid, f32
/// standalone wire heading, TurnToObject only] TurnToParameters /
/// <c>UnPackNet</c> 3-dword form (u32 bitfield, f32 speed, f32
/// desired heading). R4-V3 (closes M7): exposed via
/// <see cref="AcDream.Core.Net.Messages.CreateObject.TurnToPathData"/>
/// — previously dropped end-to-end.</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").
///
/// <para>L.2g S1 (DEV-6): the three staleness stamps + autonomy flag are
/// exposed for the retail gate (<see cref="AcDream.Core.Physics"/>
/// <c>MotionSequenceGate</c>) — retail compares them against
/// <c>update_times[INSTANCE_TS/MOVEMENT_TS/SERVER_CONTROLLED_MOVE_TS]</c>
/// and stores <c>last_move_was_autonomous</c>
/// (<c>CPhysics::SetObjectMovement</c> 0x00509690).</para>
/// </summary>
public readonly record struct Parsed(
uint Guid,
CreateObject.ServerMotionState MotionState,
ushort InstanceSequence,
ushort MovementSequence,
ushort ServerControlSequence,
bool IsAutonomous);
/// <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) — retail's dispatch-level
// INSTANCE_TS staleness gate compares this against the object's
// known incarnation (DispatchSmartBoxEvent case 0xF74C).
if (body.Length - pos < 2) return null;
ushort instanceSequence = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
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).
//
// L.2g S1 (DEV-6): the header fields feed retail's
// CPhysics::SetObjectMovement staleness gates + the
// last_move_was_autonomous store, so parse them instead of
// skipping.
if (body.Length - pos < 6) return null;
ushort movementSequence = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
ushort serverControlSequence = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
bool isAutonomous = body[pos] != 0;
pos += 2; // u8 isAutonomous + Align(4) pad byte
// movementType u8, motionFlags u8, currentStyle u16
if (body.Length - pos < 4) return null;
byte movementType = body[pos]; pos += 1;
// MotionFlags (ACE ACE.Entity.Enum.MotionFlags, byte):
// 0x1 = StickToObject, 0x2 = StandingLongJump.
// R4-V3 (closes M14-wire-note): previously discarded as
// `_motionFlags`. StickToObject now drives the sticky-guid
// trailer parse below (mt=0 only, per ACE MovementInvalid.Write
// + decomp §2f case 0 @0052455d). StandingLongJump (0x2) is
// carried on ServerMotionState.StandingLongJump (R5-V4) and
// consumed at the GameWindow mt-0 tails — retail's
// `unpack_movement` case 0 writes it onto
// `motion_interpreter->standing_longjump` (§2f @0052458e,
// UNCONDITIONAL — an absent flag clears it).
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;
CreateObject.TurnToPathData? turnToPath = null;
uint? stickyObjectGuid = 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), instanceSequence, movementSequence, serverControlSequence, isAutonomous);
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), instanceSequence, movementSequence, serverControlSequence, isAutonomous);
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), instanceSequence, movementSequence, serverControlSequence, isAutonomous);
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));
}
}
// R4-V3 (closes M14-wire-note): the sticky-guid trailer.
// ACE MovementInvalid.Write (Motion/MovementInvalid.cs:41-47)
// writes the InterpretedMotionState FIRST (everything above,
// including the Commands list), THEN — only when
// MotionFlags.StickToObject (0x1) is set — one trailing u32
// guid. Decomp confirms the same order (§2f case 0:
// InterpretedMotionState::UnPack, then
// `if (header & 0x100) sticky_object_guid = read_dword()`).
// Parsed here purely to keep the cursor honest past this
// field; R5's PositionManager::StickTo body is the eventual
// consumer (M14 — "seam now, body R5").
if ((motionFlags & 0x1) != 0 && body.Length - pos >= 4)
{
stickyObjectGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
}
done:;
}
else if (movementType is 6 or 7)
{
TryParseMoveToPayload(
body,
pos,
movementType,
out moveToParameters,
out moveToSpeed,
out moveToRunRate,
out moveToPath);
}
else if (movementType is 8 or 9)
{
TryParseTurnToPayload(
body,
pos,
movementType,
out turnToPath);
}
return new Parsed(guid, new CreateObject.ServerMotionState(
currentStyle, forwardCommand, forwardSpeed, commands,
sidestepCommand, sidestepSpeed, turnCommand, turnSpeed,
movementType,
moveToParameters,
moveToSpeed,
moveToRunRate,
moveToPath,
turnToPath,
stickyObjectGuid,
// R5-V4: MotionFlags 0x2 — consumed at the GameWindow mt-0
// tails (retail unpack_movement @0052458e writes it onto
// minterp->standing_longjump unconditionally).
(motionFlags & 0x2) != 0),
instanceSequence, movementSequence, serverControlSequence, isAutonomous);
}
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,
movementParameters ?? 0u); // R4-V4: the raw flags dword -> FromWire
return true;
}
/// <summary>
/// R4-V3 (closes M7) — parses movementType 8 (TurnToObject) / 9
/// (TurnToHeading), byte-for-byte per V0-pins.md P6 (built + confirmed
/// against ACE's own writers: <c>TurnToObjectExtensions.Write</c>,
/// <c>TurnToParametersExtensions.Write</c>,
/// <c>TurnToHeadingExtensions.Write</c> —
/// <c>references/ACE/Source/ACE.Server/Network/Motion/</c>):
/// <list type="bullet">
/// <item>type 8 (TurnToObject) only: u32 <c>object guid</c>, f32
/// <c>wire_heading</c> (the STANDALONE heading field — ACE
/// <c>TurnToObject.DesiredHeading</c>, "used instead of the
/// DesiredHeading in the TurnToParameters" per its own field
/// comment).</item>
/// <item>TurnToParameters / <c>MovementParameters::UnPackNet</c>
/// 3-dword TurnTo form (0xc bytes, decomp §2g), present for BOTH
/// types: u32 <c>bitfield</c>, f32 <c>speed</c>, f32
/// <c>desired_heading</c>.</item>
/// </list>
/// Matches retail's read order exactly (decomp §2f case 8:
/// <c>object_id = read_dword(); wire_heading = read_dword();
/// UnPackNet(...)</c>). The unresolvable-object fallback (substitute
/// <c>wire_heading</c> into <c>params.desired_heading</c>, degrade to
/// TurnToHeading) is a CONSUMER decision (needs a live object-id
/// resolution the wire parser has no visibility into) — this method
/// only exposes both heading fields distinctly so that fallback can be
/// implemented at the call site (V4/V5, GameWindow routing per
/// r4-port-plan.md §4).
/// </summary>
private static bool TryParseTurnToPayload(
ReadOnlySpan<byte> body,
int pos,
byte movementType,
out CreateObject.TurnToPathData? path)
{
path = null;
uint? targetGuid = null;
float? wireHeading = null;
if (movementType == 8)
{
if (body.Length - pos < 8) return false;
targetGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
wireHeading = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
}
// TurnToParameters / UnPackNet 3-dword TurnTo form (0xc bytes):
// bitfield, speed, desired_heading.
if (body.Length - pos < 12) return false;
uint bitfield = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
float speed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
float desiredHeading = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
path = new CreateObject.TurnToPathData(
targetGuid,
wireHeading,
bitfield,
speed,
desiredHeading);
return true;
}
}