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