From fe1c94977590f123794d281a397c3ded78103c81 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 12 Apr 2026 14:28:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(net):=20Phase=20B.2=20=E2=80=94=20MoveToSt?= =?UTF-8?q?ate=20+=20AutonomousPosition=20message=20builders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outbound GameAction message builders for player movement: - MoveToState (0xF61C): sent on motion state changes (start/stop walking, turn, speed change). Carries RawMotionState (flag-driven variable fields) + WorldPosition + sequence numbers. - AutonomousPosition (0xF753): periodic position heartbeat sent every ~200ms while moving. No RawMotionState — just WorldPosition + sequences + contact byte. Both follow the GameAction envelope pattern (0xF7B1 + sequence + action type) established by GameActionLoginComplete. Wire format ported from references/holtburger movement protocol — field order and alignment match exactly (contact byte + pad_to_4). Also: - Adds WriteFloat to PacketWriter (needed by both builders) - Adds SendGameAction + NextGameActionSequence to WorldSession (public wrappers for PlayerMovementController in Task 2) 11 new tests, 265 total, all green. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Messages/AutonomousPosition.cs | 88 +++++++++ src/AcDream.Core.Net/Messages/MoveToState.cs | 151 +++++++++++++++ src/AcDream.Core.Net/Packets/PacketWriter.cs | 7 + src/AcDream.Core.Net/WorldSession.cs | 28 +++ .../Messages/AutonomousPositionTests.cs | 131 +++++++++++++ .../Messages/MoveToStateTests.cs | 172 ++++++++++++++++++ 6 files changed, 577 insertions(+) create mode 100644 src/AcDream.Core.Net/Messages/AutonomousPosition.cs create mode 100644 src/AcDream.Core.Net/Messages/MoveToState.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs diff --git a/src/AcDream.Core.Net/Messages/AutonomousPosition.cs b/src/AcDream.Core.Net/Messages/AutonomousPosition.cs new file mode 100644 index 0000000..dae8c5e --- /dev/null +++ b/src/AcDream.Core.Net/Messages/AutonomousPosition.cs @@ -0,0 +1,88 @@ +using System.Numerics; +using AcDream.Core.Net.Packets; + +namespace AcDream.Core.Net.Messages; + +/// +/// Outbound GameAction(AutonomousPosition) message — opcode +/// 0xF753. Sent roughly every 200ms while the player is moving to +/// give the server a periodic ground-truth position heartbeat. Simpler than +/// : no RawMotionState, just the GameAction +/// envelope, a WorldPosition, sequence numbers, a contact byte, and 4-byte +/// alignment padding. +/// +/// +/// Wire layout (ported from +/// references/holtburger/crates/holtburger-protocol/src/messages/movement/actions.rs +/// AutonomousPositionActionData::pack): +/// +/// +/// GameAction envelope: u32 0xF7B1, u32 sequence, u32 0xF753 +/// WorldPosition: u32 cellId, f32 x, f32 y, f32 z, +/// f32 rotW, f32 rotX, f32 rotY, f32 rotZ +/// Sequences: u16 instance, u16 serverControl, +/// u16 teleport, u16 forcePosition +/// Contact byte: u8 (1 = on ground, 0 = airborne) +/// Align to 4 bytes +/// +/// +public static class AutonomousPosition +{ + public const uint GameActionOpcode = 0xF7B1u; + public const uint AutonomousPositionAction = 0xF753u; + + /// + /// Build an AutonomousPosition GameAction body. + /// + /// Monotonically increasing counter from + /// . + /// Landblock cell ID (u32). + /// World-space position relative to the landblock. + /// Rotation quaternion. AC wire order is W, X, Y, Z. + /// Instance sequence number from the server. + /// Server-control sequence number. + /// Teleport sequence number. + /// Force-position sequence number. + /// 1 if the character was last on the ground, 0 if airborne. + public static byte[] Build( + uint gameActionSequence, + uint cellId, + Vector3 position, + Quaternion rotation, + ushort instanceSequence, + ushort serverControlSequence, + ushort teleportSequence, + ushort forcePositionSequence, + byte lastContact = 1) + { + var w = new PacketWriter(64); + + // --- GameAction envelope --- + w.WriteUInt32(GameActionOpcode); + w.WriteUInt32(gameActionSequence); + w.WriteUInt32(AutonomousPositionAction); + + // --- WorldPosition (32 bytes) --- + w.WriteUInt32(cellId); + w.WriteFloat(position.X); + w.WriteFloat(position.Y); + w.WriteFloat(position.Z); + // Quaternion wire order: W, X, Y, Z + w.WriteFloat(rotation.W); + w.WriteFloat(rotation.X); + w.WriteFloat(rotation.Y); + w.WriteFloat(rotation.Z); + + // --- Sequence numbers --- + w.WriteUInt16(instanceSequence); + w.WriteUInt16(serverControlSequence); + w.WriteUInt16(teleportSequence); + w.WriteUInt16(forcePositionSequence); + + // --- Contact byte + 4-byte align --- + w.WriteByte(lastContact); + w.AlignTo4(); + + return w.ToArray(); + } +} diff --git a/src/AcDream.Core.Net/Messages/MoveToState.cs b/src/AcDream.Core.Net/Messages/MoveToState.cs new file mode 100644 index 0000000..5569d25 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/MoveToState.cs @@ -0,0 +1,151 @@ +using System.Numerics; +using AcDream.Core.Net.Packets; + +namespace AcDream.Core.Net.Messages; + +/// +/// Outbound GameAction(MoveToState) message — opcode 0xF61C. +/// Sent whenever the client's motion state changes: starting or stopping +/// walking, switching direction, changing speed, entering or leaving combat +/// stance. The server uses this to update the player's authoritative motion +/// state and to drive interpolated position for other nearby clients. +/// +/// +/// Wire layout (ported from +/// references/holtburger/crates/holtburger-protocol/src/messages/movement/actions.rs +/// MoveToStateActionData::pack and +/// types.rs RawMotionState::pack): +/// +/// +/// GameAction envelope: u32 0xF7B1, u32 sequence, u32 0xF61C +/// RawMotionState: u32 packed_flags (bits 0-10 = flag bits, +/// bits 11-31 = command list length), then conditional u32/f32 fields +/// in flag-bit order (see RawMotionFlags in types.rs) +/// WorldPosition: u32 cellId, f32 x, f32 y, f32 z, +/// f32 rotW, f32 rotX, f32 rotY, f32 rotZ +/// Sequences: u16 instance, u16 serverControl, +/// u16 teleport, u16 forcePosition +/// Contact byte: u8 (1 = on ground, 0 = airborne) +/// Align to 4 bytes +/// +/// +/// +/// The command list length is packed into bits 11-31 of the flags dword. +/// We always send 0 commands (no discrete motion events), so those bits stay 0. +/// +/// +public static class MoveToState +{ + public const uint GameActionOpcode = 0xF7B1u; + public const uint MoveToStateAction = 0xF61Cu; + + // RawMotionFlags bit positions (from holtburger types.rs) + private const uint FlagCurrentHoldKey = 0x001u; + private const uint FlagCurrentStyle = 0x002u; + private const uint FlagForwardCommand = 0x004u; + private const uint FlagForwardHoldKey = 0x008u; + private const uint FlagForwardSpeed = 0x010u; + private const uint FlagSidestepCommand = 0x020u; + private const uint FlagSidestepHoldKey = 0x040u; + private const uint FlagSidestepSpeed = 0x080u; + private const uint FlagTurnCommand = 0x100u; + private const uint FlagTurnHoldKey = 0x200u; + private const uint FlagTurnSpeed = 0x400u; + + /// + /// Build a MoveToState GameAction body. + /// + /// Monotonically increasing counter from + /// . + /// Raw motion command (u32 in AC's command space), + /// e.g. 0x45000005 = WalkForward. Null = no forward command. + /// Normalised speed scalar (0.0-1.0). Only written + /// when is non-null. + /// Sidestep command or null. + /// Sidestep speed or null. + /// Turn command or null. + /// Turn speed or null. + /// Hold-key state (1=None, 2=Run). Null = omit. + /// Landblock cell ID (u32). + /// World-space position relative to the landblock. + /// Rotation quaternion. AC wire order is W, X, Y, Z. + /// Instance sequence number from the server. + /// Server-control sequence number. + /// Teleport sequence number. + /// Force-position sequence number. + /// 1 if the character is on the ground, 0 if airborne. + public static byte[] Build( + uint gameActionSequence, + uint? forwardCommand, + float? forwardSpeed, + uint? sidestepCommand, + float? sidestepSpeed, + uint? turnCommand, + float? turnSpeed, + uint? holdKey, + uint cellId, + Vector3 position, + Quaternion rotation, + ushort instanceSequence, + ushort serverControlSequence, + ushort teleportSequence, + ushort forcePositionSequence, + byte contactLongJump = 1) + { + var w = new PacketWriter(128); + + // --- GameAction envelope --- + w.WriteUInt32(GameActionOpcode); + w.WriteUInt32(gameActionSequence); + w.WriteUInt32(MoveToStateAction); + + // --- RawMotionState --- + // Build the flags word. Command list length (bits 11-31) is always 0. + uint flags = 0u; + if (holdKey.HasValue) flags |= FlagCurrentHoldKey; + if (forwardCommand.HasValue) flags |= FlagForwardCommand; + if (forwardSpeed.HasValue) flags |= FlagForwardSpeed; + if (sidestepCommand.HasValue) flags |= FlagSidestepCommand; + if (sidestepSpeed.HasValue) flags |= FlagSidestepSpeed; + if (turnCommand.HasValue) flags |= FlagTurnCommand; + if (turnSpeed.HasValue) flags |= FlagTurnSpeed; + + w.WriteUInt32(flags); // bits 0-10 = flags, bits 11-31 = 0 (no command list) + + // Conditional fields in RawMotionFlags bit order: + if (holdKey.HasValue) w.WriteUInt32(holdKey.Value); + // FlagCurrentStyle (0x2): not sent — we don't track stance changes here + if (forwardCommand.HasValue) w.WriteUInt32(forwardCommand.Value); + // FlagForwardHoldKey (0x8): not sent + if (forwardSpeed.HasValue) w.WriteFloat(forwardSpeed.Value); + if (sidestepCommand.HasValue) w.WriteUInt32(sidestepCommand.Value); + // FlagSidestepHoldKey (0x40): not sent + if (sidestepSpeed.HasValue) w.WriteFloat(sidestepSpeed.Value); + if (turnCommand.HasValue) w.WriteUInt32(turnCommand.Value); + // FlagTurnHoldKey (0x200): not sent + if (turnSpeed.HasValue) w.WriteFloat(turnSpeed.Value); + + // --- WorldPosition (32 bytes) --- + w.WriteUInt32(cellId); + w.WriteFloat(position.X); + w.WriteFloat(position.Y); + w.WriteFloat(position.Z); + // Quaternion wire order: W, X, Y, Z + w.WriteFloat(rotation.W); + w.WriteFloat(rotation.X); + w.WriteFloat(rotation.Y); + w.WriteFloat(rotation.Z); + + // --- Sequence numbers --- + w.WriteUInt16(instanceSequence); + w.WriteUInt16(serverControlSequence); + w.WriteUInt16(teleportSequence); + w.WriteUInt16(forcePositionSequence); + + // --- Contact byte + 4-byte align --- + w.WriteByte(contactLongJump); + w.AlignTo4(); + + return w.ToArray(); + } +} diff --git a/src/AcDream.Core.Net/Packets/PacketWriter.cs b/src/AcDream.Core.Net/Packets/PacketWriter.cs index 7c7c7d9..f7edd92 100644 --- a/src/AcDream.Core.Net/Packets/PacketWriter.cs +++ b/src/AcDream.Core.Net/Packets/PacketWriter.cs @@ -88,6 +88,13 @@ public sealed class PacketWriter _position += bytes.Length; } + public void WriteFloat(float value) + { + EnsureCapacity(4); + BinaryPrimitives.WriteSingleLittleEndian(_buffer.AsSpan(_position), value); + _position += 4; + } + /// Pad with zeros so the buffer length is a multiple of 4. public void AlignTo4() { diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 089ac14..9172477 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -126,6 +126,16 @@ public sealed class WorldSession : IDisposable /// private bool _loginCompleteSent; + /// + /// Phase B.2: per-session game-action sequence counter. Monotonically + /// incremented by and embedded in + /// every outbound MoveToState / AutonomousPosition GameAction message. + /// ACE's GameActionPacket.HandleGameAction reads the sequence field but + /// currently only uses it for logging — however retail clients do + /// increment it, so we match that behaviour. + /// + private uint _gameActionSequence; + public WorldSession(IPEndPoint serverLogin) { _loginEndpoint = serverLogin; @@ -419,6 +429,24 @@ public sealed class WorldSession : IDisposable } } + /// + /// Phase B.2: send a pre-built GameAction body (which already contains + /// the 0xF7B1 envelope + sequence + action-type header). Used by the + /// PlayerMovementController for MoveToState and AutonomousPosition. + /// + public void SendGameAction(byte[] gameActionBody) + { + SendGameMessage(gameActionBody); + } + + /// + /// Phase B.2: get and increment the game-action sequence counter. + /// Call once per outbound movement message; pass the returned value + /// to or + /// . + /// + public uint NextGameActionSequence() => ++_gameActionSequence; + private void SendGameMessage(byte[] gameMessageBody) { var fragment = GameMessageFragment.BuildSingleFragment( diff --git a/tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs b/tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs new file mode 100644 index 0000000..7552d80 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs @@ -0,0 +1,131 @@ +using System; +using System.Buffers.Binary; +using System.Numerics; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +public class AutonomousPositionTests +{ + [Fact] + public void Build_ProducesValidGameAction() + { + var body = AutonomousPosition.Build( + gameActionSequence: 5, + cellId: 0xA9B40001u, + position: new Vector3(100f, 100f, 50f), + rotation: Quaternion.Identity, + instanceSequence: 0, + serverControlSequence: 0, + teleportSequence: 0, + forcePositionSequence: 0); + + uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0)); + Assert.Equal(0xF7B1u, opcode); + + uint seq = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)); + Assert.Equal(5u, seq); + + uint actionType = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)); + Assert.Equal(0xF753u, actionType); + } + + [Fact] + public void Build_ContainsCellIdAfterHeader() + { + var body = AutonomousPosition.Build( + gameActionSequence: 1, + cellId: 0xDEADBEEFu, + position: Vector3.Zero, + rotation: Quaternion.Identity, + instanceSequence: 0, + serverControlSequence: 0, + teleportSequence: 0, + forcePositionSequence: 0); + + // After the 12-byte GameAction header, the WorldPosition starts + // with u32 cell_id. + uint cellId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)); + Assert.Equal(0xDEADBEEFu, cellId); + } + + [Fact] + public void Build_ContainsPosition_AfterCellId() + { + var body = AutonomousPosition.Build( + gameActionSequence: 2, + cellId: 0xA9B40001u, + position: new Vector3(12.5f, 34.0f, 56.75f), + rotation: Quaternion.Identity, + instanceSequence: 0, + serverControlSequence: 0, + teleportSequence: 0, + forcePositionSequence: 0); + + // WorldPosition: cellId (4) + x (4) + y (4) + z (4) at offsets 12-27 + float x = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(16)); + float y = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(20)); + float z = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(24)); + Assert.Equal(12.5f, x); + Assert.Equal(34.0f, y); + Assert.Equal(56.75f, z); + } + + [Fact] + public void Build_IsAlignedTo4Bytes() + { + var body = AutonomousPosition.Build( + gameActionSequence: 3, + cellId: 0xA9B40001u, + position: Vector3.Zero, + rotation: Quaternion.Identity, + instanceSequence: 0, + serverControlSequence: 0, + teleportSequence: 0, + forcePositionSequence: 0); + + Assert.Equal(0, body.Length % 4); + } + + [Fact] + public void Build_TotalLengthIsCorrect_NoCommandsNoExtraFields() + { + // 12 (envelope) + 32 (WorldPosition) + 8 (4x u16 sequences) + 1 (contact) + 3 (align) = 56 + var body = AutonomousPosition.Build( + gameActionSequence: 4, + cellId: 0xA9B40001u, + position: Vector3.Zero, + rotation: Quaternion.Identity, + instanceSequence: 0, + serverControlSequence: 0, + teleportSequence: 0, + forcePositionSequence: 0); + + Assert.Equal(56, body.Length); + } + + [Fact] + public void Build_ContainsIdentityRotation_AfterPosition() + { + var body = AutonomousPosition.Build( + gameActionSequence: 6, + cellId: 0xA9B40001u, + position: Vector3.Zero, + rotation: Quaternion.Identity, // W=1, X=Y=Z=0 + instanceSequence: 0, + serverControlSequence: 0, + teleportSequence: 0, + forcePositionSequence: 0); + + // Rotation starts at offset 28: rotW(4), rotX(4), rotY(4), rotZ(4) + float rotW = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(28)); + float rotX = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(32)); + float rotY = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(36)); + float rotZ = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(40)); + Assert.Equal(1.0f, rotW); + Assert.Equal(0.0f, rotX); + Assert.Equal(0.0f, rotY); + Assert.Equal(0.0f, rotZ); + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs b/tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs new file mode 100644 index 0000000..8070cca --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs @@ -0,0 +1,172 @@ +using System; +using System.Buffers.Binary; +using System.Numerics; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +public class MoveToStateTests +{ + [Fact] + public void Build_IdleState_ProducesValidGameAction() + { + var body = MoveToState.Build( + gameActionSequence: 1, + forwardCommand: null, + forwardSpeed: null, + sidestepCommand: null, + sidestepSpeed: null, + turnCommand: null, + turnSpeed: null, + holdKey: null, + cellId: 0xA9B40001u, + position: new Vector3(96f, 96f, 50f), + rotation: Quaternion.Identity, + instanceSequence: 0, + serverControlSequence: 0, + teleportSequence: 0, + forcePositionSequence: 0); + + // First 4 bytes: GameAction opcode 0xF7B1 + uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0)); + Assert.Equal(0xF7B1u, opcode); + + // Bytes 4-7: game action sequence + uint seq = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)); + Assert.Equal(1u, seq); + + // Bytes 8-11: MoveToState action type 0xF61C + uint actionType = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)); + Assert.Equal(0xF61Cu, actionType); + } + + [Fact] + public void Build_WalkForward_IncludesForwardCommandInFlags() + { + var body = MoveToState.Build( + gameActionSequence: 2, + forwardCommand: 0x45000005u, // WalkForward + forwardSpeed: 1.0f, + sidestepCommand: null, + sidestepSpeed: null, + turnCommand: null, + turnSpeed: null, + holdKey: null, + cellId: 0xA9B40001u, + position: new Vector3(96f, 96f, 50f), + rotation: Quaternion.Identity, + instanceSequence: 0, + serverControlSequence: 0, + teleportSequence: 0, + forcePositionSequence: 0); + + // After the 12-byte GameAction header comes RawMotionState. + // First u32 is the packed flags word. ForwardCommand flag = 0x4. + uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)); + Assert.True((flags & 0x4u) != 0, "ForwardCommand flag (0x4) should be set"); + // ForwardSpeed flag = 0x10 + Assert.True((flags & 0x10u) != 0, "ForwardSpeed flag (0x10) should be set"); + } + + [Fact] + public void Build_IdleState_RawMotionFlagsAreZero() + { + var body = MoveToState.Build( + gameActionSequence: 3, + forwardCommand: null, + forwardSpeed: null, + sidestepCommand: null, + sidestepSpeed: null, + turnCommand: null, + turnSpeed: null, + holdKey: null, + cellId: 0xA9B40001u, + position: Vector3.Zero, + rotation: Quaternion.Identity, + instanceSequence: 0, + serverControlSequence: 0, + teleportSequence: 0, + forcePositionSequence: 0); + + uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)); + Assert.Equal(0u, flags); + } + + [Fact] + public void Build_IdleState_WorldPositionFollowsMotionState() + { + // With no motion state, flags = 0 and no conditional fields are written. + // So WorldPosition starts at offset 12 (envelope) + 4 (flags) = 16. + var body = MoveToState.Build( + gameActionSequence: 4, + forwardCommand: null, + forwardSpeed: null, + sidestepCommand: null, + sidestepSpeed: null, + turnCommand: null, + turnSpeed: null, + holdKey: null, + cellId: 0xDEADBEEFu, + position: Vector3.Zero, + rotation: Quaternion.Identity, + instanceSequence: 0, + serverControlSequence: 0, + teleportSequence: 0, + forcePositionSequence: 0); + + uint cellId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)); + Assert.Equal(0xDEADBEEFu, cellId); + } + + [Fact] + public void Build_IsAlignedTo4Bytes() + { + var body = MoveToState.Build( + gameActionSequence: 5, + forwardCommand: null, + forwardSpeed: null, + sidestepCommand: null, + sidestepSpeed: null, + turnCommand: null, + turnSpeed: null, + holdKey: null, + cellId: 0xA9B40001u, + position: Vector3.Zero, + rotation: Quaternion.Identity, + instanceSequence: 0, + serverControlSequence: 0, + teleportSequence: 0, + forcePositionSequence: 0); + + Assert.Equal(0, body.Length % 4); + } + + [Fact] + public void Build_WithHoldKey_IncludesHoldKeyFlag() + { + var body = MoveToState.Build( + gameActionSequence: 6, + forwardCommand: null, + forwardSpeed: null, + sidestepCommand: null, + sidestepSpeed: null, + turnCommand: null, + turnSpeed: null, + holdKey: 2u, // Run + cellId: 0xA9B40001u, + position: Vector3.Zero, + rotation: Quaternion.Identity, + instanceSequence: 0, + serverControlSequence: 0, + teleportSequence: 0, + forcePositionSequence: 0); + + uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)); + Assert.True((flags & 0x1u) != 0, "CurrentHoldKey flag (0x1) should be set"); + + // The hold key value (u32 = 2) should immediately follow the flags + uint holdKeyValue = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)); + Assert.Equal(2u, holdKeyValue); + } +}