HearSpeech decoded 0x02BB and 0x02BC with one layout. They do not share one. ACE's GameMessageHearRangedSpeech writes senderID, range, chatMessageType where GameMessageHearSpeech writes only senderID, chatMessageType, and holtburger's HearRangedSpeechData declares the same range: f32 that HearSpeechData lacks. Two oracles, no ambiguity. The consequence was quiet rather than loud. The tail is twelve bytes, our guard demanded eight, so nothing ever failed to parse. We read the guid correctly, then read range's float bits as the chat type and discarded the real one. A shout at range 60.0f arrived with a chat type of 0x42700000 instead of 0x0B. Nothing downstream consumes ChatType for local speech today, which is why this survived, but the record is public and any future consumer would have inherited garbage. TryParse now branches its tail size on the opcode and Parsed gains Range, which stays zero for local speech because there is no such field on that wire. The existing ChatTests ranged case was itself built on the misreading, constructing a local-shaped tail; it is corrected to the oracle layout and now asserts both range and chat type rather than only the ranged flag. New golden tests drive both opcodes through AceWireWriter in ACE's write order, covering empty strings, string lengths one through four so every residue of the four-byte padding rule is exercised, CP1252 accented names, and a regression pin asserting the chat type is not the range float's bits. A ranged body four bytes short is now rejected instead of silently decoded. Core.Net tests go 617 to 630, all green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
181 lines
6.6 KiB
C#
181 lines
6.6 KiB
C#
using System;
|
|
using AcDream.Core.Net.Messages;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Net.Tests.Messages;
|
|
|
|
/// <summary>
|
|
/// Golden-byte conformance for the local/ranged speech GameMessages,
|
|
/// <c>HearSpeech (0x02BB)</c> and <c>HearRangedSpeech (0x02BC)</c>.
|
|
///
|
|
/// <para><b>Oracle derivation.</b> Bytes are generated by
|
|
/// <see cref="AceWireWriter"/>, a faithful mirror of ACE's
|
|
/// <c>Extensions.cs</c> writers, driven in the exact order ACE writes them.
|
|
/// </para>
|
|
///
|
|
/// <para><c>GameMessages/Messages/GameMessageHearSpeech.cs</c>:</para>
|
|
/// <code>
|
|
/// Writer.WriteString16L(messageText);
|
|
/// Writer.WriteString16L(senderName);
|
|
/// Writer.Write(senderID); // u32
|
|
/// Writer.Write((uint)chatMessageType); // u32
|
|
/// </code>
|
|
///
|
|
/// <para><c>GameMessages/Messages/GameMessageHearRangedSpeech.cs</c> — note
|
|
/// the extra <c>range</c> float, which is the whole reason these two opcodes
|
|
/// cannot share a decode path:</para>
|
|
/// <code>
|
|
/// Writer.WriteString16L(messageText);
|
|
/// Writer.WriteString16L(senderName);
|
|
/// Writer.Write(senderID); // u32
|
|
/// Writer.Write(range); // f32 <-- 0x02BC ONLY
|
|
/// Writer.Write((uint)chatMessageType); // u32
|
|
/// </code>
|
|
///
|
|
/// <para>Cross-checked against holtburger
|
|
/// (<c>crates/holtburger-protocol/src/messages/chat/types.rs</c>), whose
|
|
/// <c>HearRangedSpeechData</c> reads <c>sender</c>, <c>range: f32</c>,
|
|
/// <c>chat_type</c> while <c>HearSpeechData</c> reads only <c>sender</c>,
|
|
/// <c>chat_type</c>.</para>
|
|
/// </summary>
|
|
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<string, string, uint, uint> 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<string, string, uint, float, uint> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Regression pin for the decode bug this file was written to catch: a
|
|
/// shared 0x02BB layout made the ranged parser read <c>range</c>'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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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)));
|
|
}
|
|
}
|