using System; using System.Buffers.Binary; using AcDream.Core.Net.Messages; using Xunit; namespace AcDream.Core.Net.Tests.Messages; /// /// Covers — the 0xF74C GameMessage the /// server sends when an entity's motion state changes (NPC starts walking, /// creature enters combat, door opens, etc). The parser shares the inner /// MovementData decoder with CreateObject but reaches it through a /// different outer layout, so we need standalone coverage. /// public class UpdateMotionTests { [Fact] public void RejectsWrongOpcode() { var body = new byte[32]; BinaryPrimitives.WriteUInt32LittleEndian(body, 0xDEADBEEFu); Assert.Null(UpdateMotion.TryParse(body)); } [Fact] public void RejectsTruncated() { Assert.Null(UpdateMotion.TryParse(new byte[3])); Assert.Null(UpdateMotion.TryParse(Array.Empty())); } [Fact] public void ParsesStanceOnly_WhenForwardCommandFlagUnset() { // Layout: // u32 opcode = 0xF74C // u32 guid // u16 instanceSeq // u16 movementSeq + u16 serverControlSeq + u8 isAutonomous + 1 pad (= 6 bytes total header, per ACE Align()) // u8 movementType = 0 (Invalid) // u8 motionFlags = 0 // u16 currentStyle (outer MovementData field) = 0x0042 // u32 packed = CurrentStyle flag (0x1) only // u16 inner currentStyle = 0x0005 (overrides outer per InterpretedMotionState semantics) var body = new byte[4 + 4 + 2 + 6 + 4 + 4 + 2]; int p = 0; BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4; BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x12345678u); p += 4; BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0001); p += 2; // 8-byte header slot — leave zero p += 6; body[p++] = 0; // movementType = Invalid body[p++] = 0; // motionFlags BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0042); p += 2; BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x1u); p += 4; // flags = CurrentStyle only BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0005); p += 2; var result = UpdateMotion.TryParse(body); Assert.NotNull(result); Assert.Equal(0x12345678u, result!.Value.Guid); Assert.Equal((ushort)0x0005, result.Value.MotionState.Stance); Assert.Null(result.Value.MotionState.ForwardCommand); } [Fact] public void ParsesStanceAndForwardCommand() { // Flags = CurrentStyle (0x1) | ForwardCommand (0x2) var body = new byte[4 + 4 + 2 + 6 + 4 + 4 + 2 + 2]; int p = 0; BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4; BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xABCDEF01u); p += 4; BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0010); p += 2; p += 6; // MovementData header slot body[p++] = 0; body[p++] = 0; BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0000); p += 2; // outer style = 0 BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x3u); p += 4; // CurrentStyle + ForwardCommand BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x000D); p += 2; // stance = 0xD BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0007); p += 2; // forward command = 0x7 (Run) var result = UpdateMotion.TryParse(body); Assert.NotNull(result); Assert.Equal(0xABCDEF01u, result!.Value.Guid); Assert.Equal((ushort)0x000D, result.Value.MotionState.Stance); Assert.Equal((ushort)0x0007, result.Value.MotionState.ForwardCommand); } [Fact] public void ParsesNoFlagsSet_KeepsOuterStance() { // When the InterpretedMotionState flags are zero, neither the inner // currentStyle nor the forward command are present in the payload, // so the parser should fall back to the MovementData outer stance // field and leave ForwardCommand null. 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), 0x55555555u); p += 4; BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; p += 6; body[p++] = 0; body[p++] = 0; BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x00AA); p += 2; BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0u); p += 4; // no flags var result = UpdateMotion.TryParse(body); Assert.NotNull(result); Assert.Equal((ushort)0x00AA, result!.Value.MotionState.Stance); Assert.Null(result.Value.MotionState.ForwardCommand); } [Fact] public void ParsesForwardSpeed_WhenSpeedFlagSet() { // Flags = CurrentStyle | ForwardCommand | ForwardSpeed // = 0x1 | 0x2 | 0x4 = 0x7 // (Per ACE MovementStateFlag enum — ForwardSpeed is bit 0x4, // NOT 0x10. The earlier test had the wrong mapping; see // references/ACE/Source/ACE.Entity/Enum/MovementStateFlag.cs) // Test value: 1.5× speed — matches a typical RunRate broadcast. var body = new byte[4 + 4 + 2 + 6 + 4 + 4 + 2 + 2 + 4]; int p = 0; BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4; BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x1A2B3C4Du); p += 4; BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; p += 6; // MovementData header body[p++] = 0; body[p++] = 0; BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x7u); p += 4; BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2; // NonCombat BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0007); p += 2; // RunForward BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.5f); p += 4; // speed var result = UpdateMotion.TryParse(body); Assert.NotNull(result); Assert.Equal((ushort)0x003D, result!.Value.MotionState.Stance); Assert.Equal((ushort)0x0007, result.Value.MotionState.ForwardCommand); Assert.Equal(1.5f, result.Value.MotionState.ForwardSpeed); } [Fact] public void ParsesCommandsList_Wave() { // A typical NPC wave broadcast: // - stance NonCombat (0x003D) // - ForwardCommand flag set, command = 0x0003 (Ready) // - numCommands = 1, with a single MotionItem{ cmd=0x0087 Wave, seq=0, speed=1.0 } // // Packed u32 = (flags | numCommands << 7) // flags = 0x01 (CurrentStyle) | 0x02 (ForwardCommand) = 0x03 // numCommands << 7 = 1 << 7 = 0x80 // total = 0x83 var body = new byte[4 + 4 + 2 + 6 + 4 + 4 + 2 + 2 + 8]; int p = 0; BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4; BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xDEADBEEFu); p += 4; BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; p += 6; body[p++] = 0; body[p++] = 0; BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x83u); p += 4; // flags=0x3 + numCommands=1 BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2; // stance BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0003); p += 2; // fwd cmd = Ready // MotionItem: u16 command + u16 packedSeq + f32 speed BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0087); p += 2; // Wave BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0001); p += 2; BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.0f); p += 4; var result = UpdateMotion.TryParse(body); Assert.NotNull(result); Assert.Equal((ushort)0x003D, result!.Value.MotionState.Stance); Assert.Equal((ushort)0x0003, result.Value.MotionState.ForwardCommand); Assert.NotNull(result.Value.MotionState.Commands); Assert.Single(result.Value.MotionState.Commands!); var wave = result.Value.MotionState.Commands![0]; Assert.Equal((ushort)0x0087, wave.Command); Assert.Equal(1.0f, wave.Speed); } [Fact] public void HandlesNonInvalidMovementType_GracefullyReturnsOuterStance() { // movementType != 0 means one of the Move* variants we don't parse. // The parser must still return a valid Parsed with the outer stance // and a null ForwardCommand rather than failing the whole message. var body = new byte[4 + 4 + 2 + 6 + 4]; int p = 0; BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4; BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x99999999u); p += 4; BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; p += 6; body[p++] = 1; // movementType = MoveToObject (non-Invalid) body[p++] = 0; BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x00CC); p += 2; var result = UpdateMotion.TryParse(body); Assert.NotNull(result); Assert.Equal((ushort)0x00CC, result!.Value.MotionState.Stance); Assert.Null(result.Value.MotionState.ForwardCommand); } }