Decode headers, optional fields, fragments, and single-fragment messages directly over pooled datagrams. Copy only fragment state that crosses a datagram lifetime, preserve synchronous dispatch and ACK ordering, and lock the path to the owned decoder with differential and zero-allocation tests.
88 lines
3.1 KiB
C#
88 lines
3.1 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>
|
|
/// Wire layout:
|
|
/// <code>
|
|
/// u32 opcode // 0x02BB or 0x02BC
|
|
/// string16L text
|
|
/// string16L senderName
|
|
/// u32 senderGuid
|
|
/// 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;
|
|
|
|
public readonly record struct Parsed(
|
|
string Text,
|
|
string SenderName,
|
|
uint SenderGuid,
|
|
uint ChatType,
|
|
bool IsRanged);
|
|
|
|
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);
|
|
if (body.Length - pos < 8) 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);
|
|
}
|
|
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;
|
|
}
|
|
}
|