feat(R4-V3): wire completion - mt 8/9 parsing + full params exposure + the mt-0 sticky trailer (closes M7, M13, M14-wire-note)

UpdateMotion now parses TurnToObject (mt 8: guid, standalone wire
heading, 3-dword UnPackNet) and TurnToHeading (mt 9: 3-dword UnPackNet)
into a new TurnToPathData sibling record (the two wire forms genuinely
diverge - 7-dword move UnPackNet with Origin head vs 3-dword turn
UnPackNet with guid+heading head; every consumer switches on
MovementType first, so no polymorphic shape was invented). mt 6/7
exposure widened additively so ALL UnPackNet fields reach
MovementParameters.FromWire. The mt=0 motionFlags sticky-guid trailer
(bit 0x1) is parsed for cursor honesty and carried unconsumed until R5
- scoped to mt=0 ONLY per both ACE's writer (MovementInvalid.Write) and
the decomp's case-0 read, tighter than the plan sketched; the
StandingLongJump bit (0x2) doc-noted as the R5 unpack_movement item.
MoveToRunRate doc-pointered as the V4/V5 MyRunRate write.

11 new golden-byte tests hand-assembled from ACE's writers
(MovementData/TurnToObject/TurnToParameters/TurnToHeading/
MoveToParameters/MovementInvalid) incl. flag-permutation round-trips
and the trailer cursor-honesty case; the existing 12 mt 6/7 fixtures
pass unchanged. Full suite: 3,972 passed.

Implemented by a dedicated agent against the V0-pinned spec (P6 order
confirmed exactly); scope + suite independently verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-03 11:53:53 +02:00
parent addc8e97a8
commit a144e87318
3 changed files with 542 additions and 5 deletions

View file

@ -217,6 +217,21 @@ public static class CreateObject
/// 0x40=TurnSpeed.
/// </para>
/// </summary>
/// <param name="MoveToRunRate">
/// R4-V3 deliverable D — the trailing <c>f32 runRate</c> on MoveToObject
/// (6) / MoveToPosition (7) payloads. Retail's <c>unpack_movement</c>
/// writes this straight onto <c>CMotionInterp::my_run_rate</c>
/// (r4-moveto-decomp.md §2f: <c>this->motion_interpreter->my_run_rate =
/// read_float()</c>, both @300603 case 6 and @300660 case 7 — SAME
/// write for both types, immediately after <c>UnPackNet</c>). Today this
/// field only seeds <c>PlanMoveToStart</c>'s local heuristic (plan M13);
/// the interp's actual <see cref="AcDream.Core.Physics.MotionInterpreter.MyRunRate"/>
/// field is a SEPARATE consumer write the V4/V5 MoveToManager cutover
/// performs at the GameWindow mt 6-9 routing site (r4-port-plan.md §4,
/// step 2: <c>Motion.MyRunRate = MoveToRunRate</c>) — this record is
/// wire-primitive only; it does not write MotionInterpreter state
/// itself.
/// </param>
public readonly record struct ServerMotionState(
ushort Stance,
ushort? ForwardCommand,
@ -230,7 +245,24 @@ public static class CreateObject
uint? MoveToParameters = null,
float? MoveToSpeed = null,
float? MoveToRunRate = null,
MoveToPathData? MoveToPath = null)
MoveToPathData? MoveToPath = null,
// R4-V3 (closes M7): movement types 8 (TurnToObject) and 9
// (TurnToHeading) — previously dropped end-to-end (UpdateMotion.cs
// only branched on `movementType is 6 or 7`). Carries the DECODED
// wire payload (guid + standalone wire_heading for type 8, plus the
// shared 3-dword UnPackNet triple for both types) per V0-pins.md P6.
TurnToPathData? TurnToPath = null,
// R4-V3 (closes M14-wire-note): the 0xF74C motionFlags sticky-guid
// trailer, mt=0 (Invalid) only — ACE MovementInvalid.Write gates the
// trailing guid on MotionFlags.StickToObject (0x1); the decomp's
// `unpack_movement` case 0 reads it right after
// InterpretedMotionState::UnPack (r4-moveto-decomp.md §2f
// @0052455d: `if (header & 0x100) sticky_object_guid = read_dword()`
// — bit 0x100 of the combined header word is motionFlags byte1&0x1).
// Carried unconsumed until R5's PositionManager::StickTo body binds
// it; parsing it here just keeps the buffer cursor honest past this
// field (deliverable C — no behavior).
uint? StickyObjectGuid = null)
{
/// <summary>
/// ACE/retail movement types 6 and 7 are server-controlled
@ -240,6 +272,13 @@ public static class CreateObject
/// </summary>
public bool IsServerControlledMoveTo => MovementType is 6 or 7;
/// <summary>
/// R4-V3: movement types 8 (TurnToObject) and 9 (TurnToHeading) —
/// the turn-only sibling of <see cref="IsServerControlledMoveTo"/>.
/// Neither carries an InterpretedMotionState.ForwardCommand either.
/// </summary>
public bool IsServerControlledTurnTo => MovementType is 8 or 9;
public bool MoveToCanRun => !MoveToParameters.HasValue
|| (MoveToParameters.Value & 0x2u) != 0;
@ -301,6 +340,50 @@ public static class CreateObject
float WalkRunThreshold,
float DesiredHeading);
/// <summary>
/// R4-V3 (closes M7) — path-control payload of a server-controlled
/// TurnTo packet (movementType 8 TurnToObject or 9 TurnToHeading).
/// Sibling of <see cref="MoveToPathData"/>: kept as a SEPARATE record
/// rather than widening <c>MoveToPathData</c> in place, because the two
/// wire forms genuinely diverge (7-dword <c>UnPackNet</c> with an
/// Origin+optional-guid head for move types vs. the 3-dword
/// <c>UnPackNet</c> with a guid+standalone-heading head for turn types —
/// V0-pins.md P6) and a single record would need every move-only field
/// nullable for turn payloads (and vice versa) for no reader benefit —
/// no code path ever needs "either a move or a turn path" polymorphically,
/// every consumer already switches on <see cref="ServerMotionState.MovementType"/>
/// first.
///
/// <list type="bullet">
/// <item>type 8 (TurnToObject) only: u32 <c>TargetGuid</c>, f32
/// <c>WireHeading</c> — the STANDALONE heading field (ACE
/// <c>TurnToObject.DesiredHeading</c>, distinct from
/// <see cref="DesiredHeading"/> below despite ACE always populating
/// both from the same source; V0-pins.md P6's fixture caveat: never
/// distinguish the two fields by value in a test, only by
/// OFFSET). Consumed ONLY in retail's unresolvable-object fallback
/// (decomp §2f case 8: <c>if (GetObjectA(object_id) == 0) {
/// params.desired_heading = wire_heading; goto TurnToHeading; }</c>)
/// — the resolved-object path never reads it.</item>
/// <item>TurnToParameters (0xc bytes, exact retail order —
/// <c>MovementParameters::UnPackNet</c> 3-dword TurnTo form, decomp
/// §2g): u32 <c>Bitfield</c>, f32 <c>Speed</c>, f32
/// <c>DesiredHeading</c>. Present for BOTH type 8 and type 9 (type 9
/// has no guid/WireHeading head — <see cref="TargetGuid"/> and
/// <see cref="WireHeading"/> are null).</item>
/// </list>
///
/// Feeds <see cref="AcDream.Core.Physics.Motion.MovementParameters.FromWireTurnTo"/>
/// at the (future) App-layer consumer — this record stays wire-primitive
/// (no domain-object construction in the Net-layer parser).
/// </summary>
public readonly record struct TurnToPathData(
uint? TargetGuid,
float? WireHeading,
uint Bitfield,
float Speed,
float DesiredHeading);
/// <summary>
/// One entry in the InterpretedMotionState's Commands list (MotionItem).
/// The server packs 0..many of these per broadcast: emotes, attacks,

View file

@ -34,7 +34,20 @@ namespace AcDream.Core.Net.Messages;
/// 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>
/// 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>
@ -132,7 +145,20 @@ public static class UpdateMotion
// 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;
// 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
// DOCUMENTED here but NOT consumed — retail's
// `unpack_movement` case 0 writes it onto
// `motion_interpreter->standing_longjump` (§2f @0052458e), an
// R5 unpack_movement item (this parser has no MotionInterpreter
// reference to write it onto; the bit is simply not read past
// this comment, matching "not consumed" rather than
// mis-consumed).
byte motionFlags = body[pos]; pos += 1;
ushort currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
@ -142,7 +168,7 @@ public static class UpdateMotion
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}");
$" UM raw: mt=0x{movementType:X2} mf=0x{motionFlags:X2} cs=0x{currentStyle:X4} | {hex}");
}
ushort? forwardCommand = null;
@ -155,6 +181,8 @@ public static class UpdateMotion
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)
@ -247,6 +275,23 @@ public static class UpdateMotion
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)
@ -260,6 +305,14 @@ public static class UpdateMotion
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,
@ -268,7 +321,9 @@ public static class UpdateMotion
moveToParameters,
moveToSpeed,
moveToRunRate,
moveToPath),
moveToPath,
turnToPath,
stickyObjectGuid),
instanceSequence, movementSequence, serverControlSequence, isAutonomous);
}
catch
@ -354,4 +409,70 @@ public static class UpdateMotion
desiredHeading);
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;
}
}