using System;
using System.Buffers.Binary;
using System.Text;
namespace AcDream.Core.Net.Messages;
///
/// Inbound 0x02BB HearSpeech + 0x02BC HearRangedSpeech
/// 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.
///
///
/// 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
///
///
///
///
/// ChatType (from ACE):
///
/// - 0x01 = Broadcast
/// - 0x02 = Combat
/// - 0x0B = Speech
/// - 0x0F = Emote
/// - 0x10 = Tell
/// - 0x11 = Syllables (spell casting)
/// - other values in ACE ChatMessageType.cs
///
///
///
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,
float Range);
public static Parsed? TryParse(ReadOnlySpan 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 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;
}
}