using System; using AcDream.Core.Net.Messages; using Xunit; namespace AcDream.Core.Net.Tests.Messages; /// /// Golden-byte conformance for the local/ranged speech GameMessages, /// HearSpeech (0x02BB) and HearRangedSpeech (0x02BC). /// /// Oracle derivation. Bytes are generated by /// , a faithful mirror of ACE's /// Extensions.cs writers, driven in the exact order ACE writes them. /// /// /// GameMessages/Messages/GameMessageHearSpeech.cs: /// /// Writer.WriteString16L(messageText); /// Writer.WriteString16L(senderName); /// Writer.Write(senderID); // u32 /// Writer.Write((uint)chatMessageType); // u32 /// /// /// GameMessages/Messages/GameMessageHearRangedSpeech.cs — note /// the extra range float, which is the whole reason these two opcodes /// cannot share a decode path: /// /// Writer.WriteString16L(messageText); /// Writer.WriteString16L(senderName); /// Writer.Write(senderID); // u32 /// Writer.Write(range); // f32 <-- 0x02BC ONLY /// Writer.Write((uint)chatMessageType); // u32 /// /// /// Cross-checked against holtburger /// (crates/holtburger-protocol/src/messages/chat/types.rs), whose /// HearRangedSpeechData reads sender, range: f32, /// chat_type while HearSpeechData reads only sender, /// chat_type. /// public class HearSpeechGoldenTests { // ACE ChatMessageType values used below: 0x0B Speech, 0x10 Tell. private const uint Speech = 0x0Bu; private const uint Tell = 0x10u; private static byte[] LocalGolden(string text, string sender, uint guid, uint chatType) => AceWireWriter.GameMessage(HearSpeech.LocalOpcode) .WriteString16L(text) .WriteString16L(sender) .Write(guid) .Write(chatType) .ToArray(); private static byte[] RangedGolden( string text, string sender, uint guid, float range, uint chatType) => AceWireWriter.GameMessage(HearSpeech.RangedOpcode) .WriteString16L(text) .WriteString16L(sender) .Write(guid) .Write(range) .Write(chatType) .ToArray(); // ---- 0x02BB HearSpeech --------------------------------------------------- public static TheoryData LocalCases() => new() { { "Hello, Dereth!", "Barris", 0x50000001u, Speech }, // Empty strings still occupy 4 bytes each (2 length + 2 pad). { "", "", 0u, 0u }, // Lengths 1..3 exercise every residue of the 4-byte padding rule. { "a", "bb", 0x7C95B01Au, Tell }, { "ccc", "dddd", 0x800114C0u, Speech }, // CP1252 round-trip: retail names carry accented characters. { "Café time", "Seán", 0xA9B40001u, Speech }, }; [Theory] [MemberData(nameof(LocalCases))] public void Local_AceGoldenBytes_DecodesEveryFieldExactly( string text, string sender, uint guid, uint chatType) { byte[] body = LocalGolden(text, sender, guid, chatType); HearSpeech.Parsed? parsed = HearSpeech.TryParse(body); Assert.NotNull(parsed); Assert.Equal(text, parsed!.Value.Text); Assert.Equal(sender, parsed.Value.SenderName); Assert.Equal(guid, parsed.Value.SenderGuid); Assert.Equal(chatType, parsed.Value.ChatType); Assert.False(parsed.Value.IsRanged); // Local speech has no range field on the wire. Assert.Equal(0f, parsed.Value.Range); } // ---- 0x02BC HearRangedSpeech -------------------------------------------- public static TheoryData RangedCases() => new() { { "HELP!", "Barris", 0x50000001u, 60f, Speech }, { "", "", 0u, 0f, 0u }, { "a", "bb", 0x7C95B01Au, 12.5f, Tell }, { "ccc", "dddd", 0x800114C0u, 100f, Speech }, }; [Theory] [MemberData(nameof(RangedCases))] public void Ranged_AceGoldenBytes_DecodesRangeAndChatTypeSeparately( string text, string sender, uint guid, float range, uint chatType) { byte[] body = RangedGolden(text, sender, guid, range, chatType); HearSpeech.Parsed? parsed = HearSpeech.TryParse(body); Assert.NotNull(parsed); Assert.Equal(text, parsed!.Value.Text); Assert.Equal(sender, parsed.Value.SenderName); Assert.Equal(guid, parsed.Value.SenderGuid); Assert.Equal(range, parsed.Value.Range); Assert.Equal(chatType, parsed.Value.ChatType); Assert.True(parsed.Value.IsRanged); } /// /// Regression pin for the decode bug this file was written to catch: a /// shared 0x02BB layout made the ranged parser read range's float /// bits as the chat type. With range = 60.0f those bits are 0x42700000, /// so the symptom was a chat type in the billions rather than 0x0B. /// [Fact] public void Ranged_ChatType_IsNotTheRangeFloatBits() { const float range = 60f; byte[] body = RangedGolden("HELP!", "Barris", 0x50000001u, range, Speech); HearSpeech.Parsed? parsed = HearSpeech.TryParse(body); Assert.NotNull(parsed); uint rangeBits = (uint)BitConverter.SingleToInt32Bits(range); Assert.Equal(0x42700000u, rangeBits); Assert.NotEqual(rangeBits, parsed!.Value.ChatType); Assert.Equal(Speech, parsed.Value.ChatType); } /// /// A ranged body that is four bytes short is a local-speech-sized tail. /// It must be rejected rather than silently decoded with the chat type /// taken from whatever follows. /// [Fact] public void Ranged_MissingRangeField_ReturnsNull() { byte[] truncated = AceWireWriter.GameMessage(HearSpeech.RangedOpcode) .WriteString16L("HELP!") .WriteString16L("Barris") .Write(0x50000001u) .Write(Speech) .ToArray(); Assert.Null(HearSpeech.TryParse(truncated)); } [Fact] public void WrongOpcode_ReturnsNull() { byte[] body = AceWireWriter.GameMessage(0x02BDu) .WriteString16L("x").WriteString16L("y").Write(1u).Write(1u) .ToArray(); Assert.Null(HearSpeech.TryParse(body)); } [Fact] public void Local_TruncatedTail_ReturnsNull() { byte[] body = LocalGolden("Hello", "Barris", 1u, Speech); Assert.Null(HearSpeech.TryParse(body.AsSpan(0, body.Length - 1))); } }