diff --git a/src/AcDream.Core.Net/Messages/HearSpeech.cs b/src/AcDream.Core.Net/Messages/HearSpeech.cs index 75a709de..e0fc5669 100644 --- a/src/AcDream.Core.Net/Messages/HearSpeech.cs +++ b/src/AcDream.Core.Net/Messages/HearSpeech.cs @@ -11,12 +11,26 @@ namespace AcDream.Core.Net.Messages; /// GameMessages dispatched the same way as CreateObject / UpdateMotion. /// /// +/// The two opcodes do NOT share a payload: ranged speech carries an extra +/// f32 range between the sender guid and the chat type. Both oracles +/// agree — ACE's +/// GameMessages/Messages/GameMessageHearRangedSpeech.cs writes +/// senderID, range, chatMessageType where +/// GameMessageHearSpeech.cs writes only senderID, +/// chatMessageType, and holtburger's +/// crates/holtburger-protocol/src/messages/chat/types.rs declares +/// HearRangedSpeechData with a range: f32 that +/// HearSpeechData lacks. +/// +/// +/// /// Wire layout: /// /// u32 opcode // 0x02BB or 0x02BC /// string16L text /// string16L senderName /// u32 senderGuid +/// f32 range // 0x02BC ONLY /// u32 chatType /// /// @@ -39,12 +53,17 @@ public static class HearSpeech public const uint LocalOpcode = 0x02BBu; public const uint RangedOpcode = 0x02BCu; + /// + /// Audible radius carried only by 0x02BC HearRangedSpeech. Local + /// speech (0x02BB) has no such field on the wire and reports 0. + /// public readonly record struct Parsed( string Text, string SenderName, uint SenderGuid, uint ChatType, - bool IsRanged); + bool IsRanged, + float Range); public static Parsed? TryParse(ReadOnlySpan body) { @@ -61,10 +80,22 @@ public static class HearSpeech { string text = ReadString16L(body, ref pos); string sender = ReadString16L(body, ref pos); - if (body.Length - pos < 8) return null; + + // 0x02BB: guid + chatType. 0x02BC: guid + range + chatType. + int tailSize = isRanged ? 12 : 8; + if (body.Length - pos < tailSize) return null; + uint senderGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); pos += 4; - uint chatType = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); pos += 4; - return new Parsed(text, sender, senderGuid, chatType, isRanged); + + float range = 0f; + if (isRanged) + { + range = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); + pos += 4; + } + + uint chatType = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); pos += 4; + return new Parsed(text, sender, senderGuid, chatType, isRanged, range); } catch { return null; } } diff --git a/tests/AcDream.Core.Net.Tests/Messages/ChatTests.cs b/tests/AcDream.Core.Net.Tests/Messages/ChatTests.cs index 02bef46b..1ad668af 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/ChatTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/ChatTests.cs @@ -102,19 +102,27 @@ public sealed class ChatTests [Fact] public void HearSpeech_TryParse_RangedFlag() { + // 0x02BC's tail is 12 bytes, not 8: ACE's GameMessageHearRangedSpeech + // writes senderID, range (f32), chatMessageType, where the local + // 0x02BB message writes only senderID and chatMessageType. This test + // previously built the local tail and so passed against a parser that + // read range's float bits as the chat type. byte[] msg = PackString16L("X"); byte[] sender = PackString16L("Y"); - byte[] inbound = new byte[4 + msg.Length + sender.Length + 8]; + byte[] inbound = new byte[4 + msg.Length + sender.Length + 12]; BinaryPrimitives.WriteUInt32LittleEndian(inbound, HearSpeech.RangedOpcode); int pos = 4; Array.Copy(msg, 0, inbound, pos, msg.Length); pos += msg.Length; Array.Copy(sender, 0, inbound, pos, sender.Length); pos += sender.Length; BinaryPrimitives.WriteUInt32LittleEndian(inbound.AsSpan(pos), 0); pos += 4; - BinaryPrimitives.WriteUInt32LittleEndian(inbound.AsSpan(pos), 0); pos += 4; + BinaryPrimitives.WriteSingleLittleEndian(inbound.AsSpan(pos), 60f); pos += 4; + BinaryPrimitives.WriteUInt32LittleEndian(inbound.AsSpan(pos), 0x0Bu); pos += 4; var parsed = HearSpeech.TryParse(inbound); Assert.NotNull(parsed); Assert.True(parsed!.Value.IsRanged); + Assert.Equal(60f, parsed.Value.Range); + Assert.Equal(0x0Bu, parsed.Value.ChatType); } [Fact] diff --git a/tests/AcDream.Core.Net.Tests/Messages/HearSpeechGoldenTests.cs b/tests/AcDream.Core.Net.Tests/Messages/HearSpeechGoldenTests.cs new file mode 100644 index 00000000..1a7d9b80 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/HearSpeechGoldenTests.cs @@ -0,0 +1,181 @@ +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))); + } +}