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>
This commit is contained in:
parent
f14296c75f
commit
ca968fc766
11 changed files with 1604 additions and 8 deletions
|
|
@ -36,7 +36,8 @@ public static class GameEventWiring
|
|||
CombatState combat,
|
||||
Spellbook spellbook,
|
||||
ChatLog chat,
|
||||
LocalPlayerState? localPlayer = null)
|
||||
LocalPlayerState? localPlayer = null,
|
||||
TurbineChatState? turbineChat = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dispatcher);
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
|
|
@ -66,6 +67,34 @@ public static class GameEventWiring
|
|||
if (s is not null) chat.OnPopup(s);
|
||||
});
|
||||
|
||||
// ── TurbineChat channel list (0x0295 SetTurbineChatChannels) ─────
|
||||
// Phase I.6: arrives once at login (and after chat-server reconnect)
|
||||
// listing the per-session room ids assigned to General / Trade /
|
||||
// LFG / Roleplay / Society / Olthoi (and the optional Allegiance
|
||||
// Turbine room). Without this the TurbineChat outbound path stays
|
||||
// disabled and the chat panel falls back to the legacy ChatChannel
|
||||
// GameAction. See holtburger client/messages.rs:220-223 for the
|
||||
// server-message handling pattern.
|
||||
if (turbineChat is not null)
|
||||
{
|
||||
dispatcher.Register(GameEventType.SetTurbineChatChannels, e =>
|
||||
{
|
||||
var p = SetTurbineChatChannels.TryParse(e.Payload.Span);
|
||||
if (p is null) return;
|
||||
turbineChat.OnChannelsReceived(
|
||||
allegianceRoom: p.Value.AllegianceRoom,
|
||||
generalRoom: p.Value.GeneralRoom,
|
||||
tradeRoom: p.Value.TradeRoom,
|
||||
lfgRoom: p.Value.LfgRoom,
|
||||
roleplayRoom: p.Value.RoleplayRoom,
|
||||
olthoiRoom: p.Value.OlthoiRoom,
|
||||
societyRoom: p.Value.SocietyRoom,
|
||||
societyCelestialHandRoom: p.Value.SocietyCelestialHandRoom,
|
||||
societyEldrytchWebRoom: p.Value.SocietyEldrytchWebRoom,
|
||||
societyRadiantBloodRoom: p.Value.SocietyRadiantBloodRoom);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Errors ───────────────────────────────────────────────
|
||||
// Phase I.5: WeenieError + WeenieErrorWithString parsers existed
|
||||
// (GameEvents.ParseWeenieError(WithString)) but were never registered.
|
||||
|
|
|
|||
116
src/AcDream.Core.Net/Messages/SetTurbineChatChannels.cs
Normal file
116
src/AcDream.Core.Net/Messages/SetTurbineChatChannels.cs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
using System;
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// <c>SetTurbineChatChannels (0x0295)</c> — GameEvent (0xF7B0 sub-opcode)
|
||||
/// the server fires once at login (and after a chat-server reconnect)
|
||||
/// listing the runtime room ids assigned to the global community
|
||||
/// channels (General, Trade, LFG, Roleplay, Society + sub-societies,
|
||||
/// Olthoi, plus the optional Allegiance Turbine room).
|
||||
///
|
||||
/// <para>
|
||||
/// Wire layout (10 sequential u32s, payload only — the 0xF7B0 envelope
|
||||
/// is parsed by <see cref="GameEventEnvelope"/>; this parser consumes
|
||||
/// the payload slice):
|
||||
/// <code>
|
||||
/// u32 allegianceRoom // 0 = None (server-side flag)
|
||||
/// u32 generalRoom
|
||||
/// u32 tradeRoom
|
||||
/// u32 lfgRoom
|
||||
/// u32 roleplayRoom
|
||||
/// u32 olthoiRoom
|
||||
/// u32 societyRoom // 0 = None (player not in a society)
|
||||
/// u32 societyCelestialHandRoom
|
||||
/// u32 societyEldrytchWebRoom
|
||||
/// u32 societyRadiantBloodRoom
|
||||
/// </code>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Source: holtburger
|
||||
/// <c>references/holtburger/crates/holtburger-protocol/src/messages/chat/turbine.rs</c>
|
||||
/// lines 132-209 (struct + ProtocolUnpack/ProtocolPack).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class SetTurbineChatChannels
|
||||
{
|
||||
/// <summary>GameEvent sub-opcode (NOT a top-level GameMessage opcode).</summary>
|
||||
public const uint EventType = 0x0295u;
|
||||
|
||||
/// <summary>Payload size in bytes (10 u32 fields).</summary>
|
||||
public const int PayloadSize = 40;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed channel ids. Optional fields (<see cref="AllegianceRoom"/>,
|
||||
/// <see cref="SocietyRoom"/>) carry <c>0u</c> when the server signals
|
||||
/// "not applicable" — callers should treat 0 as None.
|
||||
/// </summary>
|
||||
public readonly record struct Parsed(
|
||||
uint AllegianceRoom,
|
||||
uint GeneralRoom,
|
||||
uint TradeRoom,
|
||||
uint LfgRoom,
|
||||
uint RoleplayRoom,
|
||||
uint OlthoiRoom,
|
||||
uint SocietyRoom,
|
||||
uint SocietyCelestialHandRoom,
|
||||
uint SocietyEldrytchWebRoom,
|
||||
uint SocietyRadiantBloodRoom);
|
||||
|
||||
/// <summary>
|
||||
/// Parse the payload of a 0xF7B0 envelope whose event type is
|
||||
/// <see cref="EventType"/>. The slice must contain exactly the 10
|
||||
/// channel u32s (40 bytes); shorter buffers return null.
|
||||
/// </summary>
|
||||
public static Parsed? TryParse(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < PayloadSize) return null;
|
||||
|
||||
uint allegiance = BinaryPrimitives.ReadUInt32LittleEndian(payload[..4]);
|
||||
uint general = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4, 4));
|
||||
uint trade = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8, 4));
|
||||
uint lfg = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(12, 4));
|
||||
uint roleplay = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(16, 4));
|
||||
uint olthoi = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(20, 4));
|
||||
uint society = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(24, 4));
|
||||
uint societyCelHan = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(28, 4));
|
||||
uint societyEldWeb = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(32, 4));
|
||||
uint societyRadBlo = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(36, 4));
|
||||
|
||||
return new Parsed(
|
||||
AllegianceRoom: allegiance,
|
||||
GeneralRoom: general,
|
||||
TradeRoom: trade,
|
||||
LfgRoom: lfg,
|
||||
RoleplayRoom: roleplay,
|
||||
OlthoiRoom: olthoi,
|
||||
SocietyRoom: society,
|
||||
SocietyCelestialHandRoom: societyCelHan,
|
||||
SocietyEldrytchWebRoom: societyEldWeb,
|
||||
SocietyRadiantBloodRoom: societyRadBlo);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialize <paramref name="parsed"/> into a 40-byte payload buffer
|
||||
/// (the 0xF7B0 envelope is the caller's responsibility). Used by
|
||||
/// fixture round-trip tests.
|
||||
/// </summary>
|
||||
public static byte[] Serialize(Parsed parsed)
|
||||
{
|
||||
var buf = new byte[PayloadSize];
|
||||
var span = buf.AsSpan();
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span, parsed.AllegianceRoom);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(4, 4), parsed.GeneralRoom);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(8, 4), parsed.TradeRoom);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(12, 4), parsed.LfgRoom);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(16, 4), parsed.RoleplayRoom);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(20, 4), parsed.OlthoiRoom);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(24, 4), parsed.SocietyRoom);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(28, 4), parsed.SocietyCelestialHandRoom);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(32, 4), parsed.SocietyEldrytchWebRoom);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(36, 4), parsed.SocietyRadiantBloodRoom);
|
||||
return buf;
|
||||
}
|
||||
}
|
||||
491
src/AcDream.Core.Net/Messages/TurbineChat.cs
Normal file
491
src/AcDream.Core.Net/Messages/TurbineChat.cs
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -141,6 +141,24 @@ public sealed class WorldSession : IDisposable
|
|||
/// </summary>
|
||||
public event Action<PlayerKilled.Parsed>? PlayerKilledReceived;
|
||||
|
||||
/// <summary>
|
||||
/// Phase I.6: fires when a <c>TurbineChat (0xF7DE)</c> top-level
|
||||
/// GameMessage is received. Carries the unified
|
||||
/// <see cref="TurbineChat.Parsed"/> envelope (header + payload
|
||||
/// variant). Subscribers typically switch on the payload variant
|
||||
/// and route <c>EventSendToRoom</c> into <c>ChatLog.OnChannelBroadcast</c>.
|
||||
/// </summary>
|
||||
public event Action<TurbineChat.Parsed>? TurbineChatReceived;
|
||||
|
||||
/// <summary>
|
||||
/// Phase I.6: fires when a <c>SetTurbineChatChannels (0x0295)</c>
|
||||
/// GameEvent (sub-opcode of 0xF7B0) is received — listing the
|
||||
/// runtime room ids assigned to General / Trade / LFG / Roleplay /
|
||||
/// Society / Olthoi (and the optional Allegiance Turbine room).
|
||||
/// Subscribers typically feed <c>TurbineChatState.OnChannelsReceived</c>.
|
||||
/// </summary>
|
||||
public event Action<SetTurbineChatChannels.Parsed>? TurbineChannelsReceived;
|
||||
|
||||
/// <summary>
|
||||
/// Issue #5: fires when a <c>PrivateUpdateVital (0x02E7)</c> arrives
|
||||
/// — full per-vital snapshot (ranks / start / xp / current).
|
||||
|
|
@ -315,6 +333,17 @@ public sealed class WorldSession : IDisposable
|
|||
_loginEndpoint = serverLogin;
|
||||
_connectEndpoint = new IPEndPoint(serverLogin.Address, serverLogin.Port + 1);
|
||||
_net = new NetClient(serverLogin);
|
||||
|
||||
// Phase I.6: SetTurbineChatChannels (0x0295) is a GameEvent
|
||||
// sub-opcode of 0xF7B0, not a top-level opcode. Route it through
|
||||
// the dispatcher and surface a typed event so downstream wiring
|
||||
// (GameEventWiring → TurbineChatState) doesn't need to know the
|
||||
// GameEvent envelope encoding.
|
||||
GameEvents.Register(GameEventType.SetTurbineChatChannels, e =>
|
||||
{
|
||||
var parsed = SetTurbineChatChannels.TryParse(e.Payload.Span);
|
||||
if (parsed is not null) TurbineChannelsReceived?.Invoke(parsed.Value);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -680,13 +709,17 @@ public sealed class WorldSession : IDisposable
|
|||
if (parsed is not null)
|
||||
PlayerKilledReceived?.Invoke(parsed.Value);
|
||||
}
|
||||
else if (op == 0x0295u)
|
||||
else if (op == TurbineChat.Opcode)
|
||||
{
|
||||
// Phase I.5 stub: 0x0295 SetTurbineChatChannels lands the
|
||||
// global chat channel list (Trade, LFG, etc). I.6 will
|
||||
// land the parser here. For now we silently absorb the
|
||||
// packet so it doesn't show up in ACDREAM_DUMP_OPCODES.
|
||||
// TODO(I.6): parse + route to ChatLog.OnSetTurbineChatChannels.
|
||||
// Phase I.6: 0xF7DE TurbineChat — global community chat
|
||||
// (General / Trade / LFG / Roleplay / Society / Olthoi).
|
||||
// Three payload variants live inside the same opcode;
|
||||
// dispatch to subscribers by raising a typed event.
|
||||
// SetTurbineChatChannels (0x0295) is NOT here — it's a
|
||||
// sub-opcode of the 0xF7B0 GameEvent envelope and rides
|
||||
// the dispatcher path (registered in the ctor).
|
||||
var parsed = TurbineChat.TryParse(body);
|
||||
if (parsed is not null) TurbineChatReceived?.Invoke(parsed.Value);
|
||||
}
|
||||
else if (op == PrivateUpdateVital.FullOpcode)
|
||||
{
|
||||
|
|
@ -851,6 +884,60 @@ public sealed class WorldSession : IDisposable
|
|||
SendGameAction(body);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase I.6: send a TurbineChat <c>RequestSendToRoomById</c> to a
|
||||
/// global community room (General / Trade / LFG / Roleplay /
|
||||
/// Society / Olthoi). Unlike <see cref="SendChannel"/> this is a
|
||||
/// top-level GameMessage (0xF7DE), not a 0xF7B1 GameAction — so it
|
||||
/// rides <see cref="SendGameAction"/>'s capture seam (test-friendly)
|
||||
/// but skips the GameAction sequence counter.
|
||||
///
|
||||
/// <para>
|
||||
/// <paramref name="cookie"/> must come from the parent's
|
||||
/// <c>TurbineChatState.NextContextId()</c> — WorldSession does not
|
||||
/// own that state because it lives at the GameWindow / chat-runtime
|
||||
/// level. <paramref name="senderGuid"/> is the local player's guid
|
||||
/// (the server uses it to attribute messages on the chat-server side).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void SendTurbineChatTo(
|
||||
uint roomId,
|
||||
uint chatType,
|
||||
uint dispatchType,
|
||||
uint senderGuid,
|
||||
string text,
|
||||
uint cookie)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(text);
|
||||
|
||||
// Holtburger always sets target_type=1, target_id=0, transport_type=0,
|
||||
// transport_id=0 for outbound RequestSendToRoomById (commands.rs:288-291)
|
||||
// — those fields are populated by ACE on inbound events but are
|
||||
// semantically empty for client-issued requests.
|
||||
_ = dispatchType; // outbound is always RequestSendToRoomById; see comment
|
||||
|
||||
var payload = new TurbineChat.Payload.RequestSendToRoomById(
|
||||
ContextId: cookie,
|
||||
RoomId: roomId,
|
||||
Message: text,
|
||||
ExtraDataSize: 0x0Cu, // ACE-side magic per holtburger commands.rs:297
|
||||
SenderId: senderGuid,
|
||||
HResult: 0,
|
||||
ChatType: chatType);
|
||||
|
||||
byte[] body = TurbineChat.Build(
|
||||
blobType: TurbineChat.BlobType.RequestBinary,
|
||||
dispatchType: TurbineChat.DispatchType.SendToRoomById,
|
||||
targetType: 1u,
|
||||
targetId: 0u,
|
||||
transportType: 0u,
|
||||
transportId: 0u,
|
||||
cookie: 0u, // outer header cookie is 0; inner context_id is the user-visible cookie
|
||||
payload: payload);
|
||||
|
||||
SendGameAction(body);
|
||||
}
|
||||
|
||||
private void SendGameMessage(byte[] gameMessageBody)
|
||||
{
|
||||
var fragment = GameMessageFragment.BuildSingleFragment(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue