using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Text;
namespace AcDream.Core.Net.Messages;
///
/// TurbineChat (0xF7DE) — top-level GameMessage for retail's
/// global chat rooms (General / Trade / LFG / Roleplay / Society /
/// Olthoi). Carries three payload variants: server-to-client event,
/// client-to-server request, and server-to-client response (ack).
///
///
/// Wire layout — 9-u32 header (36 bytes) followed by a length-prefixed
/// payload. Strings inside the payload use a Turbine-specific
/// 1-or-2-byte-prefix UTF-16LE encoding (NOT the CP1252 String16L used
/// elsewhere).
///
///
///
/// Source: holtburger
/// references/holtburger/crates/holtburger-protocol/src/messages/chat/turbine.rs
/// lines 211-544 (struct, ProtocolUnpack, ProtocolPack,
/// read/write_turbine_string).
///
///
public static class TurbineChat
{
/// Top-level GameMessage opcode.
public const uint Opcode = 0xF7DEu;
///
/// ACE's hard-coded request id for SendToRoomById — server rejects any
/// other value (see turbine.rs:5,317-321).
///
private const uint SendToRoomByIdResponseId = 2;
///
/// ACE's hard-coded method id for SendToRoomById (see turbine.rs:6).
///
private const uint SendToRoomByIdMethodId = 2;
/// Header field count × u32 (= 36 bytes).
public const int HeaderSize = 36;
// ──────────────────────────────────────────────────────────────
// Enums (raw u32 values lifted from turbine.rs:8-95)
// ──────────────────────────────────────────────────────────────
public enum BlobType : uint
{
Unknown = 0,
EventBinary = 1,
EventXmlRpc = 2,
RequestBinary = 3,
RequestXmlRpc = 4,
ResponseBinary = 5,
ResponseXmlRpc = 6,
}
public enum DispatchType : uint
{
Unknown = 0,
SendToRoomByName = 1,
SendToRoomById = 2,
CreateRoom = 3,
InviteClientToRoomById = 4,
EjectClientFromRoomById = 5,
}
public enum ChatType : uint
{
Undef = 0,
Allegiance = 1,
General = 2,
Trade = 3,
Lfg = 4,
Roleplay = 5,
Society = 6,
SocietyCelHan = 7,
SocietyEldWeb = 8,
SocietyRadBlo = 9,
Olthoi = 10,
}
// ──────────────────────────────────────────────────────────────
// Payload variants (matches turbine.rs:223-250)
// ──────────────────────────────────────────────────────────────
/// Discriminated union over the three known payload shapes.
public abstract record Payload
{
/// S→C: server announces a chat message in a Turbine room.
public sealed record EventSendToRoom(
uint RoomId,
string SenderName,
string Message,
uint ExtraDataSize,
uint SenderId,
int HResult,
uint ChatType) : Payload;
/// C→S: client sends a chat message into a Turbine room by id.
public sealed record RequestSendToRoomById(
uint ContextId,
uint RoomId,
string Message,
uint ExtraDataSize,
uint SenderId,
int HResult,
uint ChatType) : Payload;
/// S→C ack for a previous client request (cookie echo + result).
public sealed record Response(
uint ContextId,
uint ResponseId,
uint MethodId,
int HResult) : Payload;
/// Catch-all for unknown blob_type / dispatch_type pairs.
public sealed record Unknown(byte[] Bytes) : Payload;
}
/// Parsed result of .
public readonly record struct Parsed(
BlobType BlobType,
DispatchType DispatchType,
uint TargetType,
uint TargetId,
uint TransportType,
uint TransportId,
uint Cookie,
Payload Body);
// ──────────────────────────────────────────────────────────────
// Parse — body INCLUDES the leading 0xF7DE opcode word.
// ──────────────────────────────────────────────────────────────
///
/// Parse a TurbineChat GameMessage body.
/// must include the leading u32. Returns null
/// for any structural error (truncation, unknown blob_type, ACE
/// id-mismatch in RequestSendToRoomById, malformed UTF-16 string).
///
public static Parsed? TryParse(byte[] body)
{
if (body is null || body.Length < 4 + HeaderSize) return null;
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body);
if (opcode != Opcode) return null;
try
{
int pos = 4;
uint sizeFirst = ReadU32(body, ref pos); // covers header+payload+4
uint blobTypeRaw = ReadU32(body, ref pos);
uint dispatchTypeRaw = ReadU32(body, ref pos);
uint targetType = ReadU32(body, ref pos);
uint targetId = ReadU32(body, ref pos);
uint transportType = ReadU32(body, ref pos);
uint transportId = ReadU32(body, ref pos);
uint cookie = ReadU32(body, ref pos);
uint sizeSecond = ReadU32(body, ref pos); // = 8 + payload.len
// sizeSecond - 8 = expected payload bytes per turbine.rs:373
if (sizeSecond < 8) return null;
int expectedPayload = checked((int)(sizeSecond - 8));
if (body.Length - pos < expectedPayload) return null;
// Validate blob_type / dispatch_type discriminants — Rust's
// from_repr would return None for unknown values; we mirror
// by treating those as "Unknown payload bytes" rather than
// hard-rejecting (be permissive on unrecognised servers).
if (!IsKnownBlobType(blobTypeRaw)) return null;
if (!IsKnownDispatchType(dispatchTypeRaw)) return null;
BlobType blobType = (BlobType)blobTypeRaw;
DispatchType dispatchType = (DispatchType)dispatchTypeRaw;
int payloadStart = pos;
Payload payload;
switch ((blobType, dispatchType))
{
case (BlobType.EventBinary, DispatchType.SendToRoomByName):
{
uint channelId = ReadU32(body, ref pos);
string senderName = ReadTurbineString(body, ref pos);
string message = ReadTurbineString(body, ref pos);
if (body.Length - pos < 16) return null;
uint extraDataSize = ReadU32(body, ref pos);
uint senderId = ReadU32(body, ref pos);
int hresult = (int)ReadU32(body, ref pos);
uint chatTypeRaw = ReadU32(body, ref pos);
payload = new Payload.EventSendToRoom(
RoomId: channelId,
SenderName: senderName,
Message: message,
ExtraDataSize: extraDataSize,
SenderId: senderId,
HResult: hresult,
ChatType: chatTypeRaw);
break;
}
case (BlobType.RequestBinary, DispatchType.SendToRoomById):
{
if (body.Length - pos < 16) return null;
uint contextId = ReadU32(body, ref pos);
uint responseId = ReadU32(body, ref pos);
uint methodId = ReadU32(body, ref pos);
uint roomId = ReadU32(body, ref pos);
// ACE rejects unless the inner request and method ids
// are the canonical SendToRoomById pair.
if (responseId != SendToRoomByIdResponseId
|| methodId != SendToRoomByIdMethodId)
return null;
string message = ReadTurbineString(body, ref pos);
if (body.Length - pos < 16) return null;
uint extraDataSize = ReadU32(body, ref pos);
uint senderId = ReadU32(body, ref pos);
int hresult = (int)ReadU32(body, ref pos);
uint chatTypeRaw = ReadU32(body, ref pos);
payload = new Payload.RequestSendToRoomById(
ContextId: contextId,
RoomId: roomId,
Message: message,
ExtraDataSize: extraDataSize,
SenderId: senderId,
HResult: hresult,
ChatType: chatTypeRaw);
break;
}
case (BlobType.ResponseBinary, _):
{
if (body.Length - pos < 16) return null;
uint contextId = ReadU32(body, ref pos);
uint responseId = ReadU32(body, ref pos);
uint methodId = ReadU32(body, ref pos);
int hresult = (int)ReadU32(body, ref pos);
payload = new Payload.Response(
ContextId: contextId,
ResponseId: responseId,
MethodId: methodId,
HResult: hresult);
break;
}
default:
{
// Unknown blob/dispatch combination — capture the
// payload bytes verbatim so callers can route or
// log without hard-rejecting the message.
int remaining = expectedPayload;
if (body.Length - pos < remaining) return null;
var bytes = new byte[remaining];
Array.Copy(body, pos, bytes, 0, remaining);
pos += remaining;
payload = new Payload.Unknown(bytes);
break;
}
}
// Skip any trailing padding the server tacked on (per
// turbine.rs:372-376 — consumed may be less than expected).
int consumed = pos - payloadStart;
if (consumed < expectedPayload)
{
int slack = expectedPayload - consumed;
if (body.Length - pos < slack) return null;
pos += slack;
}
// size_first should equal 40 + payload.len; sanity-check but
// don't reject if the server padded oddly.
_ = sizeFirst;
return new Parsed(
BlobType: blobType,
DispatchType: dispatchType,
TargetType: targetType,
TargetId: targetId,
TransportType: transportType,
TransportId: transportId,
Cookie: cookie,
Body: payload);
}
catch
{
return null;
}
}
// ──────────────────────────────────────────────────────────────
// Build — produces the full GameMessage body (with the opcode).
// ──────────────────────────────────────────────────────────────
///
/// Build a TurbineChat GameMessage body. The returned bytes include
/// the leading u32, so they can be sent
/// directly through WorldSession.SendGameMessage.
///
///
/// Sizes (size_first, size_second) are computed from
/// ; callers should not pre-size them.
///
///
public static byte[] Build(
BlobType blobType,
DispatchType dispatchType,
uint targetType,
uint targetId,
uint transportType,
uint transportId,
uint cookie,
Payload payload)
{
ArgumentNullException.ThrowIfNull(payload);
// Step 1: serialise the payload to its own buffer so we know its size.
var payloadBuf = new List(64);
WritePayload(payloadBuf, payload);
uint payloadLen = (uint)payloadBuf.Count;
// ACE: first_size = 40 + payload.len; second_size = 8 + payload.len.
// (per turbine.rs:447-449 — covers the span plus an additional 4 bytes.)
uint sizeFirst = 40u + payloadLen;
uint sizeSecond = 8u + payloadLen;
// Step 2: assemble the framed GameMessage: opcode (4) + header (36) + payload.
var buf = new byte[4 + HeaderSize + payloadBuf.Count];
var span = buf.AsSpan();
int pos = 0;
WriteU32(span, ref pos, Opcode);
WriteU32(span, ref pos, sizeFirst);
WriteU32(span, ref pos, (uint)blobType);
WriteU32(span, ref pos, (uint)dispatchType);
WriteU32(span, ref pos, targetType);
WriteU32(span, ref pos, targetId);
WriteU32(span, ref pos, transportType);
WriteU32(span, ref pos, transportId);
WriteU32(span, ref pos, cookie);
WriteU32(span, ref pos, sizeSecond);
for (int i = 0; i < payloadBuf.Count; i++)
buf[pos + i] = payloadBuf[i];
return buf;
}
private static void WritePayload(List buf, Payload payload)
{
switch (payload)
{
case Payload.EventSendToRoom e:
AppendU32(buf, e.RoomId);
WriteTurbineString(buf, e.SenderName);
WriteTurbineString(buf, e.Message);
AppendU32(buf, e.ExtraDataSize);
AppendU32(buf, e.SenderId);
AppendU32(buf, unchecked((uint)e.HResult));
AppendU32(buf, e.ChatType);
break;
case Payload.RequestSendToRoomById r:
AppendU32(buf, r.ContextId);
AppendU32(buf, SendToRoomByIdResponseId);
AppendU32(buf, SendToRoomByIdMethodId);
AppendU32(buf, r.RoomId);
WriteTurbineString(buf, r.Message);
AppendU32(buf, r.ExtraDataSize);
AppendU32(buf, r.SenderId);
AppendU32(buf, unchecked((uint)r.HResult));
AppendU32(buf, r.ChatType);
break;
case Payload.Response resp:
AppendU32(buf, resp.ContextId);
AppendU32(buf, resp.ResponseId);
AppendU32(buf, resp.MethodId);
AppendU32(buf, unchecked((uint)resp.HResult));
break;
case Payload.Unknown u:
buf.AddRange(u.Bytes);
break;
}
}
// ──────────────────────────────────────────────────────────────
// UTF-16LE turbine string codec (turbine.rs:502-544)
// ──────────────────────────────────────────────────────────────
///
/// Read a Turbine UTF-16LE string with the 1-or-2-byte length
/// prefix. The length is in UTF-16 code units (NOT bytes); when
/// the high bit of the first byte is set the prefix is two bytes
/// (high 7 bits of byte 0 + all 8 bits of byte 1).
///
public static string ReadTurbineString(byte[] data, ref int pos)
{
if (data.Length - pos < 1) throw new FormatException("turbine str: truncated len");
int chars = data[pos];
pos += 1;
if ((chars & 0x80) != 0)
{
if (data.Length - pos < 1) throw new FormatException("turbine str: truncated len2");
chars = ((chars & 0x7F) << 8) | data[pos];
pos += 1;
}
long bytesLen = (long)chars * 2L;
if (bytesLen > int.MaxValue || data.Length - pos < bytesLen)
throw new FormatException("turbine str: truncated body");
// String.from_utf16 in Rust validates surrogate pairs; .NET's
// Encoding.Unicode.GetString matches that semantics.
string s = Encoding.Unicode.GetString(data, pos, (int)bytesLen);
pos += (int)bytesLen;
return s;
}
///
/// Write a Turbine UTF-16LE string. Strings shorter than 0x80 code
/// units use a 1-byte prefix; longer strings use a 2-byte prefix
/// with the high bit of byte 0 set as the discriminator.
///
public static void WriteTurbineString(List buf, string s)
{
ArgumentNullException.ThrowIfNull(buf);
ArgumentNullException.ThrowIfNull(s);
// UTF-16 code-unit count (NOT char count for surrogate pairs).
// s.Length on a .NET string is exactly the UTF-16 code-unit count.
int chars = s.Length;
if (chars >= 0x8000)
throw new ArgumentException(
"turbine string exceeds 2-byte length prefix range (max 0x7FFF code units)",
nameof(s));
if (chars < 0x80)
{
buf.Add((byte)chars);
}
else
{
buf.Add((byte)(0x80 | ((chars >> 8) & 0x7F)));
buf.Add((byte)(chars & 0xFF));
}
byte[] utf16 = Encoding.Unicode.GetBytes(s);
buf.AddRange(utf16);
}
// ──────────────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────────────
private static uint ReadU32(byte[] data, ref int pos)
{
uint v = BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(pos, 4));
pos += 4;
return v;
}
private static void WriteU32(Span buf, ref int pos, uint value)
{
BinaryPrimitives.WriteUInt32LittleEndian(buf.Slice(pos, 4), value);
pos += 4;
}
private static void AppendU32(List buf, uint value)
{
Span tmp = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(tmp, value);
buf.Add(tmp[0]);
buf.Add(tmp[1]);
buf.Add(tmp[2]);
buf.Add(tmp[3]);
}
private static bool IsKnownBlobType(uint v) => v <= 6;
private static bool IsKnownDispatchType(uint v) => v <= 5;
}