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); + } }