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>
119 lines
4.3 KiB
C#
119 lines
4.3 KiB
C#
using System;
|
|
using System.Buffers.Binary;
|
|
using System.Text;
|
|
|
|
namespace AcDream.Core.Net.Messages;
|
|
|
|
/// <summary>
|
|
/// Inbound <c>0x02BB HearSpeech</c> + <c>0x02BC HearRangedSpeech</c>
|
|
/// GameMessages. Local-area / shout chat heard by the player. These
|
|
/// do NOT ride the 0xF7B0 GameEvent envelope — they're standalone
|
|
/// GameMessages dispatched the same way as CreateObject / UpdateMotion.
|
|
///
|
|
/// <para>
|
|
/// The two opcodes do NOT share a payload: ranged speech carries an extra
|
|
/// <c>f32 range</c> between the sender guid and the chat type. Both oracles
|
|
/// agree — ACE's
|
|
/// <c>GameMessages/Messages/GameMessageHearRangedSpeech.cs</c> writes
|
|
/// <c>senderID, range, chatMessageType</c> where
|
|
/// <c>GameMessageHearSpeech.cs</c> writes only <c>senderID,
|
|
/// chatMessageType</c>, and holtburger's
|
|
/// <c>crates/holtburger-protocol/src/messages/chat/types.rs</c> declares
|
|
/// <c>HearRangedSpeechData</c> with a <c>range: f32</c> that
|
|
/// <c>HearSpeechData</c> lacks.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Wire layout:
|
|
/// <code>
|
|
/// u32 opcode // 0x02BB or 0x02BC
|
|
/// string16L text
|
|
/// string16L senderName
|
|
/// u32 senderGuid
|
|
/// f32 range // 0x02BC ONLY
|
|
/// u32 chatType
|
|
/// </code>
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// ChatType (from ACE):
|
|
/// <list type="bullet">
|
|
/// <item><description>0x01 = Broadcast</description></item>
|
|
/// <item><description>0x02 = Combat</description></item>
|
|
/// <item><description>0x0B = Speech</description></item>
|
|
/// <item><description>0x0F = Emote</description></item>
|
|
/// <item><description>0x10 = Tell</description></item>
|
|
/// <item><description>0x11 = Syllables (spell casting)</description></item>
|
|
/// <item><description>other values in ACE ChatMessageType.cs</description></item>
|
|
/// </list>
|
|
/// </para>
|
|
/// </summary>
|
|
public static class HearSpeech
|
|
{
|
|
public const uint LocalOpcode = 0x02BBu;
|
|
public const uint RangedOpcode = 0x02BCu;
|
|
|
|
/// <param name="Range">
|
|
/// Audible radius carried only by <c>0x02BC HearRangedSpeech</c>. Local
|
|
/// speech (<c>0x02BB</c>) has no such field on the wire and reports 0.
|
|
/// </param>
|
|
public readonly record struct Parsed(
|
|
string Text,
|
|
string SenderName,
|
|
uint SenderGuid,
|
|
uint ChatType,
|
|
bool IsRanged,
|
|
float Range);
|
|
|
|
public static Parsed? TryParse(ReadOnlySpan<byte> body)
|
|
{
|
|
if (body.Length < 16) return null;
|
|
|
|
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body);
|
|
bool isRanged;
|
|
if (opcode == LocalOpcode) isRanged = false;
|
|
else if (opcode == RangedOpcode) isRanged = true;
|
|
else return null;
|
|
|
|
int pos = 4;
|
|
try
|
|
{
|
|
string text = ReadString16L(body, ref pos);
|
|
string sender = ReadString16L(body, ref pos);
|
|
|
|
// 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;
|
|
|
|
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; }
|
|
}
|
|
|
|
private static string ReadString16L(ReadOnlySpan<byte> source, ref int pos)
|
|
{
|
|
if (source.Length - pos < 2) throw new FormatException("truncated String16L length");
|
|
ushort length = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(pos));
|
|
pos += 2;
|
|
if (source.Length - pos < length) throw new FormatException("truncated String16L body");
|
|
// Windows-1252 matches retail (and holtburger's encoding_rs::WINDOWS_1252).
|
|
// ASCII would munge any non-ASCII byte into a '?' which corrupts player
|
|
// names and chat with accented characters (e.g. "Café").
|
|
string result = Encoding.GetEncoding(1252).GetString(source.Slice(pos, length));
|
|
pos += length;
|
|
int recordSize = 2 + length;
|
|
int padding = (4 - (recordSize & 3)) & 3;
|
|
pos += padding;
|
|
return result;
|
|
}
|
|
}
|