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>
169 lines
6.9 KiB
C#
169 lines
6.9 KiB
C#
using System;
|
|
using System.Buffers.Binary;
|
|
using System.Text;
|
|
using AcDream.Core.Net.Messages;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Net.Tests.Messages;
|
|
|
|
public sealed class ChatTests
|
|
{
|
|
[Fact]
|
|
public void BuildTalk_EmitsOpcodeAndString16L()
|
|
{
|
|
byte[] body = ChatRequests.BuildTalk(gameActionSequence: 3, message: "hi");
|
|
|
|
Assert.Equal(ChatRequests.TalkOpcode,
|
|
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
|
|
|
// Verify the string16L starts at offset 12.
|
|
ushort len = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(12));
|
|
Assert.Equal(2, len);
|
|
Assert.Equal("hi", Encoding.ASCII.GetString(body.AsSpan(14, 2)));
|
|
|
|
// Record size = 2+2 = 4, no padding needed.
|
|
Assert.Equal(16, body.Length);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildTalk_EmitsPadding_WhenMessageLengthRequiresIt()
|
|
{
|
|
byte[] body = ChatRequests.BuildTalk(gameActionSequence: 3, message: "h");
|
|
// 2+1=3 bytes record → pad 1 byte.
|
|
// Total body = 12 (envelope) + 4 (str16L aligned) = 16.
|
|
Assert.Equal(16, body.Length);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildTell_WritesMessageFirstThenTarget()
|
|
{
|
|
// Wire order is message-then-target — ACE GameActionTell.Handle
|
|
// reads `var message = ...; var target = ...;` in that sequence.
|
|
// The previous (target-first) layout caused a 2026-04-25 live
|
|
// bug where every /tell failed with WeenieError 0x052B because
|
|
// ACE was looking up the message text as the recipient name.
|
|
byte[] body = ChatRequests.BuildTell(
|
|
gameActionSequence: 5, targetName: "Alice", message: "hey");
|
|
|
|
Assert.Equal(ChatRequests.TellOpcode,
|
|
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
|
|
|
int pos = 12;
|
|
ushort len1 = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(pos));
|
|
Assert.Equal(3, len1);
|
|
Assert.Equal("hey", Encoding.ASCII.GetString(body.AsSpan(pos + 2, 3)));
|
|
|
|
// "hey" record = 2+3=5, pad 3 → advance by 8.
|
|
pos += 8;
|
|
ushort len2 = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(pos));
|
|
Assert.Equal(5, len2);
|
|
Assert.Equal("Alice", Encoding.ASCII.GetString(body.AsSpan(pos + 2, 5)));
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildChatChannel_IncludesChannelId()
|
|
{
|
|
byte[] body = ChatRequests.BuildChatChannel(
|
|
gameActionSequence: 1, channelId: 42, message: "tell me the good dungeons");
|
|
|
|
Assert.Equal(ChatRequests.ChatChannelOpcode,
|
|
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
|
Assert.Equal(42u,
|
|
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
|
|
}
|
|
|
|
[Fact]
|
|
public void HearSpeech_TryParse_LocalRoundTrip()
|
|
{
|
|
// Build a 0x02BB message and re-parse it.
|
|
byte[] talkBody = ChatRequests.BuildTalk(gameActionSequence: 0, message: "hello");
|
|
|
|
// Now synthesize the inbound HearSpeech format.
|
|
byte[] msg = PackString16L("hello");
|
|
byte[] sender = PackString16L("Alice");
|
|
byte[] inbound = new byte[4 + msg.Length + sender.Length + 8];
|
|
int pos = 0;
|
|
BinaryPrimitives.WriteUInt32LittleEndian(inbound, HearSpeech.LocalOpcode);
|
|
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), 0xCAFEu); pos += 4;
|
|
BinaryPrimitives.WriteUInt32LittleEndian(inbound.AsSpan(pos), 0x0B); pos += 4; // Speech
|
|
|
|
var parsed = HearSpeech.TryParse(inbound);
|
|
Assert.NotNull(parsed);
|
|
Assert.Equal("hello", parsed!.Value.Text);
|
|
Assert.Equal("Alice", parsed.Value.SenderName);
|
|
Assert.Equal(0xCAFEu, parsed.Value.SenderGuid);
|
|
Assert.Equal(0x0Bu, parsed.Value.ChatType);
|
|
Assert.False(parsed.Value.IsRanged);
|
|
}
|
|
|
|
[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 + 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.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]
|
|
public void HearSpeech_TryParse_WrongOpcode_ReturnsNull()
|
|
{
|
|
byte[] body = new byte[16];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body, 0xDEADBEEFu);
|
|
Assert.Null(HearSpeech.TryParse(body));
|
|
}
|
|
|
|
[Fact]
|
|
public void HearSpeech_TryParse_PreservesWindows1252_RoundTrip()
|
|
{
|
|
// Phase I.5: ASCII would munge non-ASCII bytes into '?'. CP1252
|
|
// round-trips them. The high-byte 0xE9 = 'é' in Latin-1/CP1252.
|
|
byte[] msg = PackString16L("Café");
|
|
byte[] sender = PackString16L("Élise");
|
|
byte[] inbound = new byte[4 + msg.Length + sender.Length + 8];
|
|
int pos = 0;
|
|
BinaryPrimitives.WriteUInt32LittleEndian(inbound, HearSpeech.LocalOpcode);
|
|
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), 0u); pos += 4;
|
|
BinaryPrimitives.WriteUInt32LittleEndian(inbound.AsSpan(pos), 0u);
|
|
|
|
var parsed = HearSpeech.TryParse(inbound);
|
|
Assert.NotNull(parsed);
|
|
Assert.Equal("Café", parsed!.Value.Text);
|
|
Assert.Equal("Élise", parsed.Value.SenderName);
|
|
}
|
|
|
|
private static byte[] PackString16L(string s)
|
|
{
|
|
// Test helper now uses CP1252 to match the production codec.
|
|
byte[] data = Encoding.GetEncoding(1252).GetBytes(s);
|
|
int recordSize = 2 + data.Length;
|
|
int padding = (4 - (recordSize & 3)) & 3;
|
|
byte[] result = new byte[recordSize + padding];
|
|
BinaryPrimitives.WriteUInt16LittleEndian(result, (ushort)data.Length);
|
|
Array.Copy(data, 0, result, 2, data.Length);
|
|
return result;
|
|
}
|
|
}
|