acdream/src/AcDream.Core.Net/Messages/TurbineChat.cs
Erik ca968fc766 feat(net+chat): #19 TurbineChat (0xF7DE) codec + ChatChannelInfo + SetTurbineChatChannels parser
Full port of holtburger's TurbineChat sidecar wire path:

- TurbineChat.cs: 0xF7DE codec with three payload variants
  (EventSendToRoom S->C, RequestSendToRoomById C->S, Response).
  10-field outer header (size_first/blob_type/dispatch_type/
  target_type/target_id/transport_type/transport_id/cookie/
  size_second + payload).
- UTF-16LE turbine string codec with 1-or-2 byte variable-length
  prefix (high bit on first byte signals 2-byte form). Mirrors
  holtburger's read_turbine_string / write_turbine_string at
  references/holtburger/.../messages/chat/turbine.rs:502-544.
- SetTurbineChatChannels.cs: 0x0295 GameEvent sub-opcode parser
  (10 x u32 channel ids). Wired through GameEventDispatcher in
  WorldSession ctor; routes to GameEventWiring + TurbineChatState.
- ChatChannelInfo.cs (Core): unified record union with Legacy
  (channel id + name) and Turbine (room id + chat type +
  dispatch type + name) variants, plus IsSelfEchoChannel
  predicate (Tells = false, channels = true so optimistic echo
  is suppressed where the server will echo).
- TurbineChatState.cs (Core): Enabled flag + 10 cached room ids
  + NextContextId() cookie counter starting at 1.
- WorldSession adds TurbineChatReceived + TurbineChannelsReceived
  events; SendTurbineChatTo outbound builds RequestSendToRoomById
  + sends through SendGameAction. ProcessDatagram dispatches
  0xF7DE at the top level.
- GameWindow constructs TurbineChatState, subscribes inbound
  EventSendToRoom -> ChatLog.OnChannelBroadcast; extends I.3's
  SendChatCmd handler to route Turbine kinds (General/Trade/Lfg/
  Roleplay/Society/Olthoi) through TurbineChat first, fall back
  to legacy ChatChannel send when state.Enabled == false.

Round-trip golden fixtures from holtburger source verified for
all three payload variants + UTF-16LE strings (short + long
prefix + non-ASCII Cafe + empty) + SetTurbineChatChannels.

26 new tests:
- TurbineChatTests, SetTurbineChatChannelsTests in Core.Net.Tests
- ChatChannelInfoTests, TurbineChatStateTests in Core.Tests

Solution total: 960 green (243 Core.Net + 625 Core + 92 UI).

ACE doesn't run a TurbineChat server, so codec is "ready when
needed" for retail-server-emulating setups. Legacy ChatChannel
fallback continues to work for current ACE-against-acdream play.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 19:44:56 +02:00

491 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Text;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// <c>TurbineChat (0xF7DE)</c> — 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).
///
/// <para>
/// 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).
/// </para>
///
/// <para>
/// Source: holtburger
/// <c>references/holtburger/crates/holtburger-protocol/src/messages/chat/turbine.rs</c>
/// lines 211-544 (struct, ProtocolUnpack, ProtocolPack,
/// read/write_turbine_string).
/// </para>
/// </summary>
public static class TurbineChat
{
/// <summary>Top-level GameMessage opcode.</summary>
public const uint Opcode = 0xF7DEu;
/// <summary>
/// ACE's hard-coded request id for SendToRoomById — server rejects any
/// other value (see turbine.rs:5,317-321).
/// </summary>
private const uint SendToRoomByIdResponseId = 2;
/// <summary>
/// ACE's hard-coded method id for SendToRoomById (see turbine.rs:6).
/// </summary>
private const uint SendToRoomByIdMethodId = 2;
/// <summary>Header field count × u32 (= 36 bytes).</summary>
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)
// ──────────────────────────────────────────────────────────────
/// <summary>Discriminated union over the three known payload shapes.</summary>
public abstract record Payload
{
/// <summary>S→C: server announces a chat message in a Turbine room.</summary>
public sealed record EventSendToRoom(
uint RoomId,
string SenderName,
string Message,
uint ExtraDataSize,
uint SenderId,
int HResult,
uint ChatType) : Payload;
/// <summary>C→S: client sends a chat message into a Turbine room by id.</summary>
public sealed record RequestSendToRoomById(
uint ContextId,
uint RoomId,
string Message,
uint ExtraDataSize,
uint SenderId,
int HResult,
uint ChatType) : Payload;
/// <summary>S→C ack for a previous client request (cookie echo + result).</summary>
public sealed record Response(
uint ContextId,
uint ResponseId,
uint MethodId,
int HResult) : Payload;
/// <summary>Catch-all for unknown blob_type / dispatch_type pairs.</summary>
public sealed record Unknown(byte[] Bytes) : Payload;
}
/// <summary>Parsed result of <see cref="TryParse"/>.</summary>
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.
// ──────────────────────────────────────────────────────────────
/// <summary>
/// Parse a TurbineChat GameMessage body. <paramref name="body"/>
/// must include the leading <see cref="Opcode"/> u32. Returns null
/// for any structural error (truncation, unknown blob_type, ACE
/// id-mismatch in RequestSendToRoomById, malformed UTF-16 string).
/// </summary>
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).
// ──────────────────────────────────────────────────────────────
/// <summary>
/// Build a TurbineChat GameMessage body. The returned bytes include
/// the leading <see cref="Opcode"/> u32, so they can be sent
/// directly through <c>WorldSession.SendGameMessage</c>.
///
/// <para>
/// Sizes (<c>size_first</c>, <c>size_second</c>) are computed from
/// <paramref name="payload"/>; callers should not pre-size them.
/// </para>
/// </summary>
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<byte>(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<byte> 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)
// ──────────────────────────────────────────────────────────────
/// <summary>
/// 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).
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
public static void WriteTurbineString(List<byte> 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<byte> buf, ref int pos, uint value)
{
BinaryPrimitives.WriteUInt32LittleEndian(buf.Slice(pos, 4), value);
pos += 4;
}
private static void AppendU32(List<byte> buf, uint value)
{
Span<byte> 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;
}