diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs
index 168f1662..3f4caacc 100644
--- a/src/AcDream.Core.Net/Messages/CreateObject.cs
+++ b/src/AcDream.Core.Net/Messages/CreateObject.cs
@@ -217,6 +217,21 @@ public static class CreateObject
/// 0x40=TurnSpeed.
///
///
+ ///
+ /// R4-V3 deliverable D — the trailing f32 runRate on MoveToObject
+ /// (6) / MoveToPosition (7) payloads. Retail's unpack_movement
+ /// writes this straight onto CMotionInterp::my_run_rate
+ /// (r4-moveto-decomp.md §2f: this->motion_interpreter->my_run_rate =
+ /// read_float(), both @300603 case 6 and @300660 case 7 — SAME
+ /// write for both types, immediately after UnPackNet). Today this
+ /// field only seeds PlanMoveToStart's local heuristic (plan M13);
+ /// the interp's actual
+ /// 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: Motion.MyRunRate = MoveToRunRate) — this record is
+ /// wire-primitive only; it does not write MotionInterpreter state
+ /// itself.
+ ///
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)
{
///
/// ACE/retail movement types 6 and 7 are server-controlled
@@ -240,6 +272,13 @@ public static class CreateObject
///
public bool IsServerControlledMoveTo => MovementType is 6 or 7;
+ ///
+ /// R4-V3: movement types 8 (TurnToObject) and 9 (TurnToHeading) —
+ /// the turn-only sibling of .
+ /// Neither carries an InterpretedMotionState.ForwardCommand either.
+ ///
+ 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);
+ ///
+ /// R4-V3 (closes M7) — path-control payload of a server-controlled
+ /// TurnTo packet (movementType 8 TurnToObject or 9 TurnToHeading).
+ /// Sibling of : kept as a SEPARATE record
+ /// rather than widening MoveToPathData in place, because the two
+ /// wire forms genuinely diverge (7-dword UnPackNet with an
+ /// Origin+optional-guid head for move types vs. the 3-dword
+ /// UnPackNet 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
+ /// first.
+ ///
+ ///
+ /// - type 8 (TurnToObject) only: u32 TargetGuid, f32
+ /// WireHeading — the STANDALONE heading field (ACE
+ /// TurnToObject.DesiredHeading, distinct from
+ /// 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: if (GetObjectA(object_id) == 0) {
+ /// params.desired_heading = wire_heading; goto TurnToHeading; })
+ /// — the resolved-object path never reads it.
+ /// - TurnToParameters (0xc bytes, exact retail order —
+ /// MovementParameters::UnPackNet 3-dword TurnTo form, decomp
+ /// §2g): u32 Bitfield, f32 Speed, f32
+ /// DesiredHeading. Present for BOTH type 8 and type 9 (type 9
+ /// has no guid/WireHeading head — and
+ /// are null).
+ ///
+ ///
+ /// Feeds
+ /// at the (future) App-layer consumer — this record stays wire-primitive
+ /// (no domain-object construction in the Net-layer parser).
+ ///
+ public readonly record struct TurnToPathData(
+ uint? TargetGuid,
+ float? WireHeading,
+ uint Bitfield,
+ float Speed,
+ float DesiredHeading);
+
///
/// One entry in the InterpretedMotionState's Commands list (MotionItem).
/// The server packs 0..many of these per broadcast: emotes, attacks,
diff --git a/src/AcDream.Core.Net/Messages/UpdateMotion.cs b/src/AcDream.Core.Net/Messages/UpdateMotion.cs
index 97e215df..e061b4da 100644
--- a/src/AcDream.Core.Net/Messages/UpdateMotion.cs
+++ b/src/AcDream.Core.Net/Messages/UpdateMotion.cs
@@ -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.
+/// turn speed f32), commands list, align; THEN — only when
+/// motionFlags & 0x1 (StickToObject) — one trailing u32
+/// sticky object guid (ACE MovementInvalid.Write; R4-V3).
+/// - MoveToObject (6) / MoveToPosition (7): [u32 target guid,
+/// MoveToObject only] Origin (u32 cell + 3×f32), MoveToParameters /
+/// UnPackNet 7-dword form (u32 bitfield + 6×f32), f32
+/// runRate. R4-V3: exposed via
+/// .
+/// - TurnToObject (8) / TurnToHeading (9): [u32 target guid, f32
+/// standalone wire heading, TurnToObject only] TurnToParameters /
+/// UnPackNet 3-dword form (u32 bitfield, f32 speed, f32
+/// desired heading). R4-V3 (closes M7): exposed via
+///
+/// — previously dropped end-to-end.
///
///
///
@@ -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? 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;
}
+
+ ///
+ /// 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: TurnToObjectExtensions.Write,
+ /// TurnToParametersExtensions.Write,
+ /// TurnToHeadingExtensions.Write —
+ /// references/ACE/Source/ACE.Server/Network/Motion/):
+ ///
+ /// - type 8 (TurnToObject) only: u32 object guid, f32
+ /// wire_heading (the STANDALONE heading field — ACE
+ /// TurnToObject.DesiredHeading, "used instead of the
+ /// DesiredHeading in the TurnToParameters" per its own field
+ /// comment).
+ /// - TurnToParameters / MovementParameters::UnPackNet
+ /// 3-dword TurnTo form (0xc bytes, decomp §2g), present for BOTH
+ /// types: u32 bitfield, f32 speed, f32
+ /// desired_heading.
+ ///
+ /// Matches retail's read order exactly (decomp §2f case 8:
+ /// object_id = read_dword(); wire_heading = read_dword();
+ /// UnPackNet(...)). The unresolvable-object fallback (substitute
+ /// wire_heading into params.desired_heading, 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).
+ ///
+ private static bool TryParseTurnToPayload(
+ ReadOnlySpan 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;
+ }
}
diff --git a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs
index a33158e2..70feca2c 100644
--- a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs
+++ b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs
@@ -1,6 +1,7 @@
using System;
using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
+using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
@@ -382,4 +383,336 @@ public class UpdateMotionTests
Assert.Equal(7f, path.OriginZ);
Assert.Equal(1.25f, result.Value.MotionState.MoveToRunRate);
}
+
+ // ─────────────────────────────────────────────────────────────────
+ // R4-V3 (closes M7): mt 8 (TurnToObject) / mt 9 (TurnToHeading).
+ //
+ // Golden bytes assembled from ACE's own writers (V0-pins.md P6):
+ // MovementDataExtensions.Write (references/ACE/Source/ACE.Server/
+ // Network/Motion/MovementData.cs:184-229) writes the common header
+ // (movementType u8, motionFlags u8, currentStyle u16) then dispatches
+ // on MovementType to:
+ // TurnToObjectExtensions.Write (TurnToObject.cs:25-30):
+ // writer.WriteGuid(Target); // u32
+ // writer.Write(DesiredHeading); // f32 — the STANDALONE
+ // // "wire_heading" field
+ // writer.Write(TurnToParameters); // 3-dword UnPackNet form
+ // TurnToParametersExtensions.Write (TurnToParameters.cs:23-28):
+ // writer.Write((uint)MovementParams); // u32 bitfield
+ // writer.Write(Speed); // f32
+ // writer.Write(DesiredHeading); // f32 — TurnToParameters'
+ // // OWN desired_heading
+ // TurnToHeadingExtensions.Write (TurnToHeading.cs:18-21):
+ // writer.Write(TurnToParameters); // 3-dword UnPackNet form only
+ //
+ // P6's fixture caveat: ACE always populates field2 (TurnToObject.
+ // DesiredHeading) and field5 (TurnToParameters.DesiredHeading) from the
+ // SAME motion.DesiredHeading source, so a byte-faithful ACE capture
+ // would have field2 == field5. To prove the parser distinguishes the
+ // two fields by OFFSET (not by coincidentally-equal value), these
+ // fixtures hand-vary the two headings.
+ // ─────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void ParsesTurnToObject_GuidWireHeadingAndParams()
+ {
+ // Header (20 bytes) + guid (4) + wireHeading (4) + TurnToParameters (12) = 40.
+ var body = new byte[20 + 4 + 4 + 12];
+ int p = 0;
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80005678u); p += 4;
+ BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
+ p += 6; // MovementData header padding
+
+ body[p++] = 8; // TurnToObject
+ body[p++] = 0; // motionFlags
+ BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2;
+
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80009999u); p += 4; // target guid
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 42.0f); p += 4; // standalone wire_heading (field2)
+
+ const uint flags = 0x1u | 0x2u | 0x200u; // can_walk | can_run | move_towards
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), flags); p += 4; // TurnToParameters.bitfield
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.5f); p += 4; // TurnToParameters.speed
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 199.0f); p += 4; // TurnToParameters.desired_heading (field5) — DELIBERATELY != field2
+
+ var result = UpdateMotion.TryParse(body);
+
+ Assert.NotNull(result);
+ Assert.Equal((byte)8, result!.Value.MotionState.MovementType);
+ Assert.True(result.Value.MotionState.IsServerControlledTurnTo);
+ Assert.False(result.Value.MotionState.IsServerControlledMoveTo);
+ Assert.NotNull(result.Value.MotionState.TurnToPath);
+
+ var path = result.Value.MotionState.TurnToPath!.Value;
+ Assert.Equal(0x80009999u, path.TargetGuid);
+ Assert.Equal(42.0f, path.WireHeading); // field2 — distinguished by OFFSET
+ Assert.Equal(flags, path.Bitfield);
+ Assert.Equal(1.5f, path.Speed);
+ Assert.Equal(199.0f, path.DesiredHeading); // field5 — distinct from field2
+
+ // The consumer feeds this straight into FromWireTurnTo (App-layer,
+ // out of scope here) — verify the fixture is round-trippable.
+ var mp = MovementParameters.FromWireTurnTo(path.Bitfield, path.Speed, path.DesiredHeading);
+ Assert.True(mp.CanRun);
+ Assert.Equal(1.5f, mp.Speed);
+ Assert.Equal(199.0f, mp.DesiredHeading);
+ }
+
+ [Fact]
+ public void ParsesTurnToObject_UnresolvableFallback_BothHeadingsSurvivedDistinctly()
+ {
+ // Retail's degrade-to-TurnToHeading fallback (decomp §2f case 8) only
+ // fires when GetObjectA(object_id) == 0 — a runtime/consumer-side
+ // resolution the wire parser has no visibility into. The parser's
+ // job is just to expose BOTH heading fields so the (future) V4/V5
+ // consumer can implement: "if unresolvable, params.DesiredHeading =
+ // wireHeading, then degrade to TurnToHeading". Confirm both survive
+ // even when they'd trigger the fallback (i.e. even when they differ).
+ var body = new byte[20 + 4 + 4 + 12];
+ int p = 0;
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x8000AAAAu); p += 4;
+ BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
+ p += 6;
+
+ body[p++] = 8;
+ body[p++] = 0;
+ BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
+
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xDEADBEEFu); p += 4; // unresolvable guid
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 270.0f); p += 4; // wire_heading — the fallback source
+
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x1u); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.0f); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.0f); p += 4; // params.desired_heading (would be overwritten by wire_heading on fallback)
+
+ var result = UpdateMotion.TryParse(body);
+
+ Assert.NotNull(result);
+ var path = result!.Value.MotionState.TurnToPath!.Value;
+ Assert.Equal(0xDEADBEEFu, path.TargetGuid);
+ Assert.Equal(270.0f, path.WireHeading);
+ Assert.Equal(0.0f, path.DesiredHeading);
+ Assert.NotEqual(path.WireHeading, path.DesiredHeading);
+ }
+
+ [Fact]
+ public void ParsesTurnToHeading_ThreeDwordFormOnly_NoGuidOrWireHeading()
+ {
+ // Header (20 bytes) + TurnToParameters (12) = 32. No guid, no
+ // standalone heading field — TurnToHeadingExtensions.Write emits
+ // ONLY the 3-dword UnPackNet form (TurnToHeading.cs:18-21).
+ var body = new byte[20 + 12];
+ int p = 0;
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x8000BBBBu); p += 4;
+ BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
+ p += 6;
+
+ body[p++] = 9; // TurnToHeading
+ body[p++] = 0;
+ BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2;
+
+ const uint flags = 0x2u | 0x800u; // can_run | set_hold_key
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), flags); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 2.0f); p += 4; // speed
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 315.0f); p += 4; // desired_heading
+
+ var result = UpdateMotion.TryParse(body);
+
+ Assert.NotNull(result);
+ Assert.Equal((byte)9, result!.Value.MotionState.MovementType);
+ Assert.True(result.Value.MotionState.IsServerControlledTurnTo);
+ Assert.NotNull(result.Value.MotionState.TurnToPath);
+
+ var path = result.Value.MotionState.TurnToPath!.Value;
+ Assert.Null(path.TargetGuid);
+ Assert.Null(path.WireHeading);
+ Assert.Equal(flags, path.Bitfield);
+ Assert.Equal(2.0f, path.Speed);
+ Assert.Equal(315.0f, path.DesiredHeading);
+ }
+
+ [Theory]
+ [InlineData(0u)] // no flags
+ [InlineData(0x1u | 0x2u)] // can_walk | can_run
+ [InlineData(0x10u)] // can_charge (fast-path bit)
+ [InlineData(0x3FFFFu)] // every A4 bit through 0x20000
+ public void ParsesTurnToHeading_FlagPermutations_BitfieldRoundTrips(uint bitfield)
+ {
+ var body = new byte[20 + 12];
+ int p = 0;
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80001111u); p += 4;
+ BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
+ p += 6;
+
+ body[p++] = 9;
+ body[p++] = 0;
+ BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
+
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), bitfield); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.0f); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.0f); p += 4;
+
+ var result = UpdateMotion.TryParse(body);
+
+ Assert.NotNull(result);
+ Assert.Equal(bitfield, result!.Value.MotionState.TurnToPath!.Value.Bitfield);
+ }
+
+ // ─────────────────────────────────────────────────────────────────
+ // R4-V3 deliverable B: mt 6/7 widened exposure. MoveToPathData already
+ // carries every UnPackNet field (V0/V1 shipped that); this proves the
+ // fixture round-trips end-to-end through MovementParameters.FromWire —
+ // i.e. that ALL seven UnPackNet fields (not just the three ad-hoc bool
+ // properties MoveToCanRun/MoveTowards/CanCharge) reach a consumer.
+ // ─────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void MoveToPositionPath_FeedsFromWire_AllSevenFieldsSurvive()
+ {
+ var body = new byte[20 + 16 + 28 + 4];
+ int p = 0;
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80002222u); 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), 11f); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 22f); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 33f); p += 4;
+
+ const uint flags = 0x1u | 0x2u | 0x4u | 0x8u | 0x10u | 0x200u | 0x400u; // incl. can_charge + use_spheres
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), flags); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.6f); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.1f); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 50.0f); 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), 123.0f); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 2.75f); p += 4; // runRate
+
+ var result = UpdateMotion.TryParse(body);
+ Assert.NotNull(result);
+ var path = result!.Value.MotionState.MoveToPath!.Value;
+
+ var mp = MovementParameters.FromWire(
+ flags,
+ path.DistanceToObject,
+ path.MinDistance,
+ path.FailDistance,
+ result.Value.MotionState.MoveToSpeed!.Value,
+ path.WalkRunThreshold,
+ path.DesiredHeading);
+
+ Assert.True(mp.CanWalk);
+ Assert.True(mp.CanRun);
+ Assert.True(mp.CanSidestep);
+ Assert.True(mp.CanWalkBackwards);
+ Assert.True(mp.CanCharge);
+ Assert.True(mp.MoveTowards);
+ Assert.True(mp.UseSpheres);
+ Assert.Equal(0.6f, mp.DistanceToObject);
+ Assert.Equal(0.1f, mp.MinDistance);
+ Assert.Equal(50.0f, mp.FailDistance);
+ Assert.Equal(1.25f, mp.Speed);
+ Assert.Equal(15.0f, mp.WalkRunThreshhold);
+ Assert.Equal(123.0f, mp.DesiredHeading);
+ Assert.Equal(2.75f, result.Value.MotionState.MoveToRunRate);
+ }
+
+ // ─────────────────────────────────────────────────────────────────
+ // R4-V3 deliverable C: the 0xF74C motionFlags sticky-guid trailer
+ // (mt=0/Invalid only — ACE MovementInvalid.Write gates the trailing
+ // guid on MotionFlags.StickToObject 0x1; decomp §2f case 0
+ // @0052455d). Cursor-honesty test: bytes AFTER the trailer must still
+ // parse correctly (i.e. the trailer's 4 bytes were actually consumed,
+ // not left dangling / double-read).
+ // ─────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void ParsesStickyGuidTrailer_WhenMotionFlagsBitSet()
+ {
+ // motionFlags byte1&0x1 (StickToObject) set; InterpretedMotionState
+ // flags = 0 (no fields), so the sticky guid dword immediately
+ // follows the packed flags dword.
+ var body = new byte[4 + 4 + 2 + 6 + 4 + 4 + 4];
+ int p = 0;
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80003333u); p += 4;
+ BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
+ p += 6;
+
+ body[p++] = 0; // movementType = Invalid
+ body[p++] = 0x1; // motionFlags = StickToObject
+ BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2;
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0u); p += 4; // no IMS flags
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80004444u); p += 4; // sticky object guid
+
+ var result = UpdateMotion.TryParse(body);
+
+ Assert.NotNull(result);
+ Assert.Equal(0x80004444u, result!.Value.MotionState.StickyObjectGuid);
+ }
+
+ [Fact]
+ public void SkipsStickyGuidTrailer_WhenMotionFlagsBitClear()
+ {
+ 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), 0x80005555u); p += 4;
+ BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
+ p += 6;
+
+ body[p++] = 0; // movementType = Invalid
+ body[p++] = 0; // motionFlags = none
+ BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2;
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0u); p += 4; // no IMS flags
+
+ var result = UpdateMotion.TryParse(body);
+
+ Assert.NotNull(result);
+ Assert.Null(result!.Value.MotionState.StickyObjectGuid);
+ }
+
+ [Fact]
+ public void ParsesStickyGuidTrailer_CursorHonesty_BytesAfterTrailerStillParseCorrectly()
+ {
+ // Sticky trailer with the ForwardCommand flag ALSO set, so there are
+ // bytes both BEFORE (forwardCommand u16) and the sticky dword AFTER
+ // the flags dword — the trailer must be read at the right offset
+ // (after ForwardCommand + its own 2-byte read), not glued onto the
+ // packed-flags dword itself. Cross-checks against ACE's actual field
+ // order: MovementInvalid.Write emits `State` (the whole
+ // InterpretedMotionState, incl. Commands list) THEN the sticky guid
+ // — decomp confirms the same order (UnPack first, sticky guid read
+ // after, r4-moveto-decomp.md:274-275).
+ var body = new byte[4 + 4 + 2 + 6 + 4 + 4 + 2 + 4];
+ int p = 0;
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80006666u); p += 4;
+ BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
+ p += 6;
+
+ body[p++] = 0; // movementType = Invalid
+ body[p++] = 0x1; // motionFlags = StickToObject
+ BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; // outer stance
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x2u); p += 4; // IMS flags = ForwardCommand only
+ BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0007); p += 2; // ForwardCommand = RunForward
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80007777u); p += 4; // sticky object guid — AFTER ForwardCommand
+
+ var result = UpdateMotion.TryParse(body);
+
+ Assert.NotNull(result);
+ Assert.Equal((ushort)0x0007, result!.Value.MotionState.ForwardCommand);
+ Assert.Equal(0x80007777u, result.Value.MotionState.StickyObjectGuid);
+ }
}