diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index d49c456..cfefdc3 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -277,6 +277,10 @@ public sealed class GameWindow : IDisposable // Phase F.1-H.1 — client-side state classes fed by GameEventWiring. // Exposed publicly so plugins + UI panels can bind directly. public readonly AcDream.Core.Chat.ChatLog Chat = new(); + // Phase I.6 — runtime state for retail's TurbineChat (0xF7DE) global + // chat rooms. Empty/disabled until the server fires + // SetTurbineChatChannels (0x0295) shortly after EnterWorld. + public readonly AcDream.Core.Chat.TurbineChatState TurbineChat = new(); public readonly AcDream.Core.Combat.CombatState Combat = new(); // Issue #11 — load static spell metadata from data/spells.csv at startup. // Provides Family for buff stacking (issue #6) + names + icons + tooltips @@ -1217,7 +1221,8 @@ public sealed class GameWindow : IDisposable // notifications / spell learns / wield events all update // the corresponding client-side state without further glue. AcDream.Core.Net.GameEventWiring.WireAll( - _liveSession.GameEvents, Items, Combat, SpellBook, Chat, LocalPlayer); + _liveSession.GameEvents, Items, Combat, SpellBook, Chat, LocalPlayer, + TurbineChat); // Phase H.1: feed inbound HearSpeech into the chat log. _liveSession.SpeechHeard += speech => @@ -1227,6 +1232,24 @@ public sealed class GameWindow : IDisposable senderGuid: speech.SenderGuid, isRanged: speech.IsRanged); + // Phase I.6: feed inbound TurbineChat events into the chat log. + // The Response variant is fire-and-forget (server-side ack); + // EventSendToRoom is a real chat message broadcast to a room. + _liveSession.TurbineChatReceived += parsed => + { + if (parsed.Body is AcDream.Core.Net.Messages.TurbineChat.Payload.EventSendToRoom ev) + { + string label = TurbineRoomDisplayName(ev.RoomId, ev.ChatType); + Chat.OnChannelBroadcast( + channelId: ev.RoomId, + sender: ev.SenderName, + text: $"[{label}] {ev.Message}"); + } + // Response (server ack of an outbound RequestSendToRoomById) + // and Unknown payloads are intentionally not surfaced — the + // user already saw their optimistic local echo. + }; + // Phase I.3: real ICommandBus. Panels publish SendChatCmd here // and we route it to the right wire opcode (Talk / Tell / ChatChannel) // plus a local echo into ChatLog so the player sees their own @@ -1235,6 +1258,8 @@ public sealed class GameWindow : IDisposable var liveSession = _liveSession; var chat = Chat; _commandBus = new AcDream.UI.Abstractions.LiveCommandBus(); + var turbineChat = TurbineChat; + uint playerGuid = _playerServerGuid; _commandBus.Register(cmd => { if (string.IsNullOrEmpty(cmd.Text)) return; @@ -1252,6 +1277,35 @@ public sealed class GameWindow : IDisposable targetOrChannel: cmd.TargetName); break; default: + // Phase I.6: try TurbineChat first for the global + // community channels (General/Trade/LFG/Roleplay/ + // Society/Olthoi) — they ride 0xF7DE TurbineChat. + // Allegiance is double-routed: try TurbineChat first + // (when the player has a Turbine allegiance room) and + // fall back to the legacy 0x0147 ChatChannel. + var turbine = ResolveTurbineForKind(cmd.Channel, turbineChat); + if (turbine is not null) + { + uint cookie = turbineChat.NextContextId(); + // Use the live player guid if it's been captured; + // otherwise 0 (server treats unknown sender_id + // gracefully — the cookie is what we care about). + uint senderGuid = _playerServerGuid != 0u + ? _playerServerGuid + : playerGuid; + liveSession.SendTurbineChatTo( + roomId: turbine.Value.RoomId, + chatType: turbine.Value.ChatType, + dispatchType: (uint)AcDream.Core.Net.Messages.TurbineChat.DispatchType.SendToRoomById, + senderGuid: senderGuid, + text: cmd.Text, + cookie: cookie); + chat.OnSelfSent( + AcDream.Core.Chat.ChatKind.Channel, cmd.Text, + targetOrChannel: turbine.Value.DisplayName); + break; + } + var resolved = AcDream.UI.Abstractions.ChannelResolver.Resolve(cmd.Channel); if (resolved is null) return; liveSession.SendChannel(resolved.Value.ChannelId, cmd.Text); @@ -4896,4 +4950,74 @@ public sealed class GameWindow : IDisposable } public void Dispose() => _window?.Dispose(); + + // ── Phase I.6 — TurbineChat outbound helpers ────────────────── + + /// + /// Result of resolving a UI + /// to a runtime Turbine room. Returned by + /// when the player has access + /// to that Turbine channel; null otherwise. + /// + private readonly record struct TurbineResolution(uint RoomId, uint ChatType, string DisplayName); + + /// + /// Map a to a + /// runtime Turbine room id + chat-type. Returns null when + /// isn't + /// or the channel has no assigned room (e.g. player not in a society). + /// Mirrors holtburger's resolve_turbine_channel + /// (references/holtburger/.../client/commands.rs lines 64-98). + /// + private static TurbineResolution? ResolveTurbineForKind( + AcDream.UI.Abstractions.ChatChannelKind kind, + AcDream.Core.Chat.TurbineChatState state) + { + if (!state.Enabled) return null; + + var (room, chatType, name) = kind switch + { + AcDream.UI.Abstractions.ChatChannelKind.Allegiance => + (state.AllegianceRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Allegiance, "Allegiance"), + AcDream.UI.Abstractions.ChatChannelKind.General => + (state.GeneralRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.General, "General"), + AcDream.UI.Abstractions.ChatChannelKind.Trade => + (state.TradeRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Trade, "Trade"), + AcDream.UI.Abstractions.ChatChannelKind.Lfg => + (state.LfgRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Lfg, "LFG"), + AcDream.UI.Abstractions.ChatChannelKind.Roleplay => + (state.RoleplayRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Roleplay, "Roleplay"), + AcDream.UI.Abstractions.ChatChannelKind.Society => + (state.SocietyRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Society, "Society"), + AcDream.UI.Abstractions.ChatChannelKind.Olthoi => + (state.OlthoiRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Olthoi, "Olthoi"), + _ => (0u, 0u, string.Empty), + }; + + if (room == 0u) return null; + return new TurbineResolution(room, chatType, name); + } + + /// + /// Pick a human-readable label for a Turbine room broadcast. Uses + /// the chat-type when known (semantic name), falls back to the + /// numeric room id for unknown rooms. + /// + private static string TurbineRoomDisplayName(uint roomId, uint chatType) + { + return (AcDream.Core.Net.Messages.TurbineChat.ChatType)chatType switch + { + AcDream.Core.Net.Messages.TurbineChat.ChatType.Allegiance => "Allegiance", + AcDream.Core.Net.Messages.TurbineChat.ChatType.General => "General", + AcDream.Core.Net.Messages.TurbineChat.ChatType.Trade => "Trade", + AcDream.Core.Net.Messages.TurbineChat.ChatType.Lfg => "LFG", + AcDream.Core.Net.Messages.TurbineChat.ChatType.Roleplay => "Roleplay", + AcDream.Core.Net.Messages.TurbineChat.ChatType.Society => "Society", + AcDream.Core.Net.Messages.TurbineChat.ChatType.SocietyCelHan => "Celestial Hand", + AcDream.Core.Net.Messages.TurbineChat.ChatType.SocietyEldWeb => "Eldrytch Web", + AcDream.Core.Net.Messages.TurbineChat.ChatType.SocietyRadBlo => "Radiant Blood", + AcDream.Core.Net.Messages.TurbineChat.ChatType.Olthoi => "Olthoi", + _ => $"Room 0x{roomId:X8}", + }; + } } diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index 5848737..db1c3d7 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -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. diff --git a/src/AcDream.Core.Net/Messages/SetTurbineChatChannels.cs b/src/AcDream.Core.Net/Messages/SetTurbineChatChannels.cs new file mode 100644 index 0000000..3568e17 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/SetTurbineChatChannels.cs @@ -0,0 +1,116 @@ +using System; +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// SetTurbineChatChannels (0x0295) — 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). +/// +/// +/// Wire layout (10 sequential u32s, payload only — the 0xF7B0 envelope +/// is parsed by ; this parser consumes +/// the payload slice): +/// +/// 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 +/// +/// +/// +/// +/// Source: holtburger +/// references/holtburger/crates/holtburger-protocol/src/messages/chat/turbine.rs +/// lines 132-209 (struct + ProtocolUnpack/ProtocolPack). +/// +/// +public static class SetTurbineChatChannels +{ + /// GameEvent sub-opcode (NOT a top-level GameMessage opcode). + public const uint EventType = 0x0295u; + + /// Payload size in bytes (10 u32 fields). + public const int PayloadSize = 40; + + /// + /// Parsed channel ids. Optional fields (, + /// ) carry 0u when the server signals + /// "not applicable" — callers should treat 0 as None. + /// + 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); + + /// + /// Parse the payload of a 0xF7B0 envelope whose event type is + /// . The slice must contain exactly the 10 + /// channel u32s (40 bytes); shorter buffers return null. + /// + public static Parsed? TryParse(ReadOnlySpan 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); + } + + /// + /// Serialize into a 40-byte payload buffer + /// (the 0xF7B0 envelope is the caller's responsibility). Used by + /// fixture round-trip tests. + /// + 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; + } +} diff --git a/src/AcDream.Core.Net/Messages/TurbineChat.cs b/src/AcDream.Core.Net/Messages/TurbineChat.cs new file mode 100644 index 0000000..bc301d8 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/TurbineChat.cs @@ -0,0 +1,491 @@ +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; +} diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 307b1aa..3b752e9 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -141,6 +141,24 @@ public sealed class WorldSession : IDisposable /// public event Action? PlayerKilledReceived; + /// + /// Phase I.6: fires when a TurbineChat (0xF7DE) top-level + /// GameMessage is received. Carries the unified + /// envelope (header + payload + /// variant). Subscribers typically switch on the payload variant + /// and route EventSendToRoom into ChatLog.OnChannelBroadcast. + /// + public event Action? TurbineChatReceived; + + /// + /// Phase I.6: fires when a SetTurbineChatChannels (0x0295) + /// 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 TurbineChatState.OnChannelsReceived. + /// + public event Action? TurbineChannelsReceived; + /// /// Issue #5: fires when a PrivateUpdateVital (0x02E7) 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); + }); } /// @@ -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); } + /// + /// Phase I.6: send a TurbineChat RequestSendToRoomById to a + /// global community room (General / Trade / LFG / Roleplay / + /// Society / Olthoi). Unlike this is a + /// top-level GameMessage (0xF7DE), not a 0xF7B1 GameAction — so it + /// rides 's capture seam (test-friendly) + /// but skips the GameAction sequence counter. + /// + /// + /// must come from the parent's + /// TurbineChatState.NextContextId() — WorldSession does not + /// own that state because it lives at the GameWindow / chat-runtime + /// level. is the local player's guid + /// (the server uses it to attribute messages on the chat-server side). + /// + /// + 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( diff --git a/src/AcDream.Core/Chat/ChatChannelInfo.cs b/src/AcDream.Core/Chat/ChatChannelInfo.cs new file mode 100644 index 0000000..73dad11 --- /dev/null +++ b/src/AcDream.Core/Chat/ChatChannelInfo.cs @@ -0,0 +1,94 @@ +namespace AcDream.Core.Chat; + +/// +/// Source/transport classification for a chat channel — distinguishes +/// retail's two parallel chat channel pipelines. +/// +public enum ChatChannelSource +{ + /// Legacy ChatChannel bitflag id rides 0x0147 ChatChannel. + Legacy, + + /// Turbine room id rides 0xF7DE TurbineChat. + Turbine, +} + +/// +/// Unified info about a chat channel — either a legacy ChatChannel id +/// (Fellowship, Allegiance, Vassals, Patron, Monarch, CoVassals) or a +/// Turbine room id (General, Trade, LFG, Roleplay, Society*, Olthoi). +/// +/// +/// Mirrors holtburger's ChatChannelInfo +/// (references/holtburger/crates/holtburger-core/src/client/types.rs +/// lines 63-102). The two retail channel pipelines run side by side — +/// legacy ChatChannel for the player-organisation channels and +/// TurbineChat for the global community rooms — and a single +/// abstraction over both keeps the chat panel and command bus from +/// having to special-case the transport at every call site. +/// +/// +/// +/// tells callers whether the server +/// echoes the client's own outgoing messages back on this channel +/// (so the client should suppress its optimistic local echo). Per +/// holtburger's predicate at chat.rs::is_self_echo_channel +/// (lines 492-507) this is true ONLY for the legacy fellowship/vassals/ +/// patron/monarch/co-vassals channels — server resends those with +/// empty sender. Turbine and tells do not echo. +/// +/// +public abstract record ChatChannelInfo(string DisplayName, ChatChannelSource Source) +{ + /// Legacy ChatChannel bitflag id (0x00000800 etc.). + public sealed record Legacy(uint ChannelId, string DisplayName) + : ChatChannelInfo(DisplayName, ChatChannelSource.Legacy) + { + public override bool IsSelfEchoChannel() + { + // Per holtburger: the legacy fellowship + allegiance-tree + // channels are the ones the server echoes back to the sender + // with an empty sender field. Bitflag values from + // references/holtburger/.../messages/chat/types.rs::ChatChannel. + return ChannelId switch + { + 0x00000800u => true, // Fellow + 0x00001000u => true, // Vassals + 0x00002000u => true, // Patron + 0x00004000u => true, // Monarch + 0x01000000u => true, // CoVassals + _ => false, + }; + } + } + + /// + /// TurbineChat room. is the runtime channel id + /// the server hands out via SetTurbineChatChannels (0x0295). + /// classifies the room semantically (General, + /// Trade, etc.); chooses the wire dispatch + /// (SendToRoomById for outbound, SendToRoomByName for inbound events). + /// + public sealed record Turbine( + uint RoomId, + uint ChatType, + uint DispatchType, + string DisplayName) + : ChatChannelInfo(DisplayName, ChatChannelSource.Turbine) + { + public override bool IsSelfEchoChannel() + { + // Turbine rooms do NOT echo the sender's own messages back. + // The client must emit its own optimistic local echo to give + // the player feedback that the message was sent. + return false; + } + } + + /// + /// True iff the server echoes our own outgoing messages on this + /// channel — caller should suppress optimistic local echo to avoid + /// double-printing. + /// + public abstract bool IsSelfEchoChannel(); +} diff --git a/src/AcDream.Core/Chat/TurbineChatState.cs b/src/AcDream.Core/Chat/TurbineChatState.cs new file mode 100644 index 0000000..f87818b --- /dev/null +++ b/src/AcDream.Core/Chat/TurbineChatState.cs @@ -0,0 +1,136 @@ +namespace AcDream.Core.Chat; + +/// +/// Runtime state for retail's TurbineChat (0xF7DE) global chat rooms — +/// the General / Trade / LFG / Roleplay / Society / Olthoi pipeline. +/// +/// +/// Lifecycle: +/// +/// Pre-login: false, all room ids 0, context counter 1. +/// Server fires SetTurbineChatChannels (0x0295) shortly after EnterWorld +/// → populates the room ids and flips +/// on. +/// Outbound chat: caller asks for a fresh context id via +/// , looks up the room id by +/// , and feeds the pair to +/// . +/// +/// +/// +/// +/// Mirrors holtburger's TurbineChatState +/// (references/holtburger/crates/holtburger-core/src/client/types.rs +/// lines 657-672). Cookie counter starts at 1 (not 0) per holtburger: +/// next_context_id.wrapping_add(1).max(1) keeps 0 reserved for +/// "no context" / response cookies. +/// +/// +public sealed class TurbineChatState +{ + /// True after the first SetTurbineChatChannels arrives. + public bool Enabled { get; private set; } + + /// Allegiance Turbine room (0 if player has no allegiance). + public uint AllegianceRoom { get; private set; } + public uint GeneralRoom { get; private set; } + public uint TradeRoom { get; private set; } + public uint LfgRoom { get; private set; } + public uint RoleplayRoom { get; private set; } + public uint OlthoiRoom { get; private set; } + /// Top-level Society room (0 if player has no society). + public uint SocietyRoom { get; private set; } + public uint SocietyCelestialHandRoom { get; private set; } + public uint SocietyEldrytchWebRoom { get; private set; } + public uint SocietyRadiantBloodRoom { get; private set; } + + private uint _nextContextId = 1; + + /// + /// Get-and-increment the per-session context cookie. Wraps to 1 (not + /// 0) so 0 stays reserved for "no context" / Response cookies. + /// Mirrors holtburger's wrapping_add(1).max(1). + /// + public uint NextContextId() + { + uint cookie = _nextContextId; + unchecked + { + _nextContextId += 1; + if (_nextContextId == 0) _nextContextId = 1; + } + return cookie; + } + + /// + /// Absorb a parsed SetTurbineChatChannels payload — flips + /// on and fills in the per-channel room ids. + /// Takes raw u32s (rather than the parser's struct directly) so + /// AcDream.Core stays free of an AcDream.Core.Net dependency. + /// + public void OnChannelsReceived( + uint allegianceRoom, + uint generalRoom, + uint tradeRoom, + uint lfgRoom, + uint roleplayRoom, + uint olthoiRoom, + uint societyRoom, + uint societyCelestialHandRoom, + uint societyEldrytchWebRoom, + uint societyRadiantBloodRoom) + { + Enabled = true; + AllegianceRoom = allegianceRoom; + GeneralRoom = generalRoom; + TradeRoom = tradeRoom; + LfgRoom = lfgRoom; + RoleplayRoom = roleplayRoom; + OlthoiRoom = olthoiRoom; + SocietyRoom = societyRoom; + SocietyCelestialHandRoom = societyCelestialHandRoom; + SocietyEldrytchWebRoom = societyEldrytchWebRoom; + SocietyRadiantBloodRoom = societyRadiantBloodRoom; + } + + /// + /// Look up the runtime room id for a Turbine + /// . Returns 0 if the channel is not + /// available (server hasn't populated it / player not in society / etc). + /// + public uint RoomFor(ChatChannelKindLite kind) => kind switch + { + ChatChannelKindLite.Allegiance => AllegianceRoom, + ChatChannelKindLite.General => GeneralRoom, + ChatChannelKindLite.Trade => TradeRoom, + ChatChannelKindLite.Lfg => LfgRoom, + ChatChannelKindLite.Roleplay => RoleplayRoom, + ChatChannelKindLite.Olthoi => OlthoiRoom, + ChatChannelKindLite.Society => SocietyRoom, + ChatChannelKindLite.SocietyCelestialHand => SocietyCelestialHandRoom, + ChatChannelKindLite.SocietyEldrytchWeb => SocietyEldrytchWebRoom, + ChatChannelKindLite.SocietyRadiantBlood => SocietyRadiantBloodRoom, + _ => 0u, + }; +} + +/// +/// Coarse Turbine channel selector usable from +/// without dragging in the +/// AcDream.UI.Abstractions ChatChannelKind (which lives in a +/// different layer). Maps 1:1 onto the chat-type-id values from +/// holtburger turbine.rs (TurbineChatType, lines 31-45). +/// +public enum ChatChannelKindLite +{ + Allegiance, + General, + Trade, + Lfg, + Roleplay, + Society, + SocietyCelestialHand, + SocietyEldrytchWeb, + SocietyRadiantBlood, + Olthoi, +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/SetTurbineChatChannelsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/SetTurbineChatChannelsTests.cs new file mode 100644 index 0000000..44b7601 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/SetTurbineChatChannelsTests.cs @@ -0,0 +1,96 @@ +using System; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +/// +/// Phase I.6: 10-u32 GameEvent +/// payload parser. +/// +/// +/// Golden fixture taken from holtburger +/// references/holtburger/crates/holtburger-protocol/src/messages/chat/turbine.rs +/// lines 654-679 (test set_turbine_chat_channels_fixture) — itself +/// generated by ACE's SyntheticProtocolTests.GenerateTurbineChatFixtures. +/// +/// +public sealed class SetTurbineChatChannelsTests +{ + [Fact] + public void TryParse_HoltburgerGoldenFixture_RoundTrips() + { + // Full game-message body from turbine.rs:655-657: + // B0F70000 – outer 0xF7B0 GameEvent opcode + // 00000000 – guid 0 + // 21000000 – sequence 0x21 + // 95020000 – eventType 0x0295 (SetTurbineChatChannels) + // <40-byte payload> + byte[] full = HexDecode( + "B0F7000000000000210000009502000078563412" + + "020000000300000004000000050000000A000000" + + "09000000070000000800000009000000"); + + // The parser consumes only the 40-byte payload after the + // GameEventEnvelope header (16 bytes). + var env = GameEventEnvelope.TryParse(full); + Assert.NotNull(env); + Assert.Equal(GameEventType.SetTurbineChatChannels, env!.Value.EventType); + + var parsed = SetTurbineChatChannels.TryParse(env.Value.Payload.Span); + Assert.NotNull(parsed); + // Allegiance: 0x12345678 (Unknown id used in fixture). + Assert.Equal(0x12345678u, parsed!.Value.AllegianceRoom); + // General..Olthoi + Assert.Equal(2u, parsed.Value.GeneralRoom); + Assert.Equal(3u, parsed.Value.TradeRoom); + Assert.Equal(4u, parsed.Value.LfgRoom); + Assert.Equal(5u, parsed.Value.RoleplayRoom); + Assert.Equal(10u, parsed.Value.OlthoiRoom); + // Society = SocietyRadiantBlood (id 9) per fixture + Assert.Equal(9u, parsed.Value.SocietyRoom); + // Sub-society rooms 7, 8, 9 + Assert.Equal(7u, parsed.Value.SocietyCelestialHandRoom); + Assert.Equal(8u, parsed.Value.SocietyEldrytchWebRoom); + Assert.Equal(9u, parsed.Value.SocietyRadiantBloodRoom); + } + + [Fact] + public void Serialize_RoundTripsThroughTryParse() + { + var original = new SetTurbineChatChannels.Parsed( + AllegianceRoom: 0x100, + GeneralRoom: 0x200, + TradeRoom: 0x300, + LfgRoom: 0x400, + RoleplayRoom: 0x500, + OlthoiRoom: 0x600, + SocietyRoom: 0x700, + SocietyCelestialHandRoom: 0x800, + SocietyEldrytchWebRoom: 0x900, + SocietyRadiantBloodRoom: 0xA00); + + byte[] payload = SetTurbineChatChannels.Serialize(original); + Assert.Equal(SetTurbineChatChannels.PayloadSize, payload.Length); + + var roundTripped = SetTurbineChatChannels.TryParse(payload); + Assert.NotNull(roundTripped); + Assert.Equal(original, roundTripped!.Value); + } + + [Fact] + public void TryParse_TruncatedPayload_ReturnsNull() + { + // 39 bytes — one short of the 40-byte minimum. + Assert.Null(SetTurbineChatChannels.TryParse(new byte[39])); + } + + private static byte[] HexDecode(string hex) + { + if ((hex.Length & 1) != 0) throw new ArgumentException("hex length must be even"); + byte[] result = new byte[hex.Length / 2]; + for (int i = 0; i < result.Length; i++) + result[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16); + return result; + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/TurbineChatTests.cs b/tests/AcDream.Core.Net.Tests/Messages/TurbineChatTests.cs new file mode 100644 index 0000000..9f19aa0 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/TurbineChatTests.cs @@ -0,0 +1,278 @@ +using System; +using System.Collections.Generic; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +/// +/// Phase I.6: codec round-trip tests for all +/// three payload variants and the UTF-16LE Turbine string codec. +/// +/// +/// Golden fixtures from holtburger +/// references/holtburger/crates/holtburger-protocol/src/messages/chat/turbine.rs +/// lines 555-639 — generated by ACE's +/// SyntheticProtocolTests.GenerateTurbineChatFixtures. +/// +/// +public sealed class TurbineChatTests +{ + // ────────────────────────────────────────────────────────────── + // Round-trip fixtures (parse, then re-serialise, then parse again) + // ────────────────────────────────────────────────────────────── + + [Fact] + public void TryParse_EventSendToRoom_GoldenFixture() + { + byte[] fixture = HexDecode( + "DEF700005E000000010000000100000001000000" + + "B5000B0001000000B5000B00000000003E000000" + + "020000000541006C006900630065000B68006500" + + "6C006C006F00200077006F0072006C0064000C00" + + "0000010000500000000002000000"); + + var parsed = TurbineChat.TryParse(fixture); + Assert.NotNull(parsed); + Assert.Equal(TurbineChat.BlobType.EventBinary, parsed!.Value.BlobType); + Assert.Equal(TurbineChat.DispatchType.SendToRoomByName, parsed.Value.DispatchType); + Assert.Equal(1u, parsed.Value.TargetType); + Assert.Equal(0x000B00B5u, parsed.Value.TargetId); + Assert.Equal(1u, parsed.Value.TransportType); + Assert.Equal(0x000B00B5u, parsed.Value.TransportId); + Assert.Equal(0u, parsed.Value.Cookie); + + var ev = Assert.IsType(parsed.Value.Body); + Assert.Equal(2u, ev.RoomId); // TurbineChatChannel.General + Assert.Equal("Alice", ev.SenderName); + Assert.Equal("hello world", ev.Message); + Assert.Equal(0x0Cu, ev.ExtraDataSize); + Assert.Equal(0x50000001u, ev.SenderId); + Assert.Equal(0, ev.HResult); + Assert.Equal(2u, ev.ChatType); // TurbineChatType.General + } + + [Fact] + public void Build_EventSendToRoom_RoundTripsThroughTryParse() + { + byte[] built = TurbineChat.Build( + blobType: TurbineChat.BlobType.EventBinary, + dispatchType: TurbineChat.DispatchType.SendToRoomByName, + targetType: 1u, + targetId: 0x000B00B5u, + transportType: 1u, + transportId: 0x000B00B5u, + cookie: 0u, + payload: new TurbineChat.Payload.EventSendToRoom( + RoomId: 2u, + SenderName: "Alice", + Message: "hello world", + ExtraDataSize: 0x0Cu, + SenderId: 0x50000001u, + HResult: 0, + ChatType: 2u)); + + var parsed = TurbineChat.TryParse(built); + Assert.NotNull(parsed); + var ev = Assert.IsType(parsed!.Value.Body); + Assert.Equal("Alice", ev.SenderName); + Assert.Equal("hello world", ev.Message); + } + + [Fact] + public void TryParse_Response_GoldenFixture() + { + byte[] fixture = HexDecode( + "DEF7000038000000050000000100000001000000" + + "B5000B0001000000B5000B00000000001800000007000000" + + "020000000200000000000000"); + + var parsed = TurbineChat.TryParse(fixture); + Assert.NotNull(parsed); + Assert.Equal(TurbineChat.BlobType.ResponseBinary, parsed!.Value.BlobType); + Assert.Equal(TurbineChat.DispatchType.SendToRoomByName, parsed.Value.DispatchType); + var resp = Assert.IsType(parsed.Value.Body); + Assert.Equal(7u, resp.ContextId); + Assert.Equal(2u, resp.ResponseId); + Assert.Equal(2u, resp.MethodId); + Assert.Equal(0, resp.HResult); + } + + [Fact] + public void Build_Response_RoundTripsThroughTryParse() + { + byte[] built = TurbineChat.Build( + blobType: TurbineChat.BlobType.ResponseBinary, + dispatchType: TurbineChat.DispatchType.SendToRoomByName, + targetType: 1u, targetId: 0x000B00B5u, + transportType: 1u, transportId: 0x000B00B5u, + cookie: 0u, + payload: new TurbineChat.Payload.Response( + ContextId: 7u, ResponseId: 2u, MethodId: 2u, HResult: 0)); + + var parsed = TurbineChat.TryParse(built); + Assert.NotNull(parsed); + var resp = Assert.IsType(parsed!.Value.Body); + Assert.Equal(7u, resp.ContextId); + } + + [Fact] + public void TryParse_RequestSendToRoomById_GoldenFixture() + { + byte[] fixture = HexDecode( + "DEF700005D000000030000000200000001000000" + + "00000000000000000000000000000000" + + "3D000000" + + "07000000020000000200000002000000" + + "0A7400720061006400650020007300700061006D00" + + "0C000000010000500000000003000000"); + + var parsed = TurbineChat.TryParse(fixture); + Assert.NotNull(parsed); + Assert.Equal(TurbineChat.BlobType.RequestBinary, parsed!.Value.BlobType); + Assert.Equal(TurbineChat.DispatchType.SendToRoomById, parsed.Value.DispatchType); + var req = Assert.IsType(parsed.Value.Body); + Assert.Equal(7u, req.ContextId); + Assert.Equal(2u, req.RoomId); // TurbineChatChannel.General + Assert.Equal("trade spam", req.Message); + Assert.Equal(0x0Cu, req.ExtraDataSize); + Assert.Equal(0x50000001u, req.SenderId); + Assert.Equal(0, req.HResult); + Assert.Equal(3u, req.ChatType); // TurbineChatType.Trade + } + + [Fact] + public void Build_RequestSendToRoomById_RoundTripsThroughTryParse() + { + byte[] built = TurbineChat.Build( + blobType: TurbineChat.BlobType.RequestBinary, + dispatchType: TurbineChat.DispatchType.SendToRoomById, + targetType: 1u, targetId: 0u, + transportType: 0u, transportId: 0u, + cookie: 0u, + payload: new TurbineChat.Payload.RequestSendToRoomById( + ContextId: 7u, + RoomId: 2u, + Message: "trade spam", + ExtraDataSize: 0x0Cu, + SenderId: 0x50000001u, + HResult: 0, + ChatType: 3u)); + + var parsed = TurbineChat.TryParse(built); + Assert.NotNull(parsed); + var req = Assert.IsType(parsed!.Value.Body); + Assert.Equal("trade spam", req.Message); + Assert.Equal(2u, req.RoomId); + } + + [Fact] + public void TryParse_RequestRejectsNonAceRequestIds() + { + // Per turbine.rs:642-651: ACE's RequestSendToRoomById is the only + // (response_id=2, method_id=2) pair. Other ids are rejected. + byte[] fixture = HexDecode( + "DEF700005D000000030000000200000001000000" + + "00000000000000000000000000000000" + + "3D000000" + + "07000000020000000200000002000000" + + "0A7400720061006400650020007300700061006D00" + + "0C000000010000500000000003000000"); + + // Mutate response_id (offset 4 + 36 + 4 = 44) to 3 — must reject. + BitConverter.GetBytes(3u).CopyTo(fixture, 44); + Assert.Null(TurbineChat.TryParse(fixture)); + } + + // ────────────────────────────────────────────────────────────── + // UTF-16LE Turbine string codec + // ────────────────────────────────────────────────────────────── + + [Fact] + public void TurbineString_ShortPrefix_RoundTrips() + { + var buf = new List(); + TurbineChat.WriteTurbineString(buf, "Hello"); + // 1-byte prefix, then 5 UTF-16LE code units = 5 + 1 = 11 bytes. + Assert.Equal(11, buf.Count); + Assert.Equal(5, buf[0]); // length prefix + Assert.Equal(0, 0x80 & buf[0]); // high bit clear + + byte[] data = buf.ToArray(); + int pos = 0; + string roundTrip = TurbineChat.ReadTurbineString(data, ref pos); + Assert.Equal("Hello", roundTrip); + Assert.Equal(data.Length, pos); + } + + [Fact] + public void TurbineString_LongPrefix_RoundTrips() + { + // 200 chars > 0x80 → 2-byte prefix form. + string s = new string('a', 200); + var buf = new List(); + TurbineChat.WriteTurbineString(buf, s); + + // Prefix: 2 bytes; high bit of first byte set. + Assert.NotEqual(0, buf[0] & 0x80); + // Decoded length = ((b0 & 0x7F) << 8) | b1 + int decodedLen = ((buf[0] & 0x7F) << 8) | buf[1]; + Assert.Equal(200, decodedLen); + Assert.Equal(2 + 200 * 2, buf.Count); + + byte[] data = buf.ToArray(); + int pos = 0; + string roundTrip = TurbineChat.ReadTurbineString(data, ref pos); + Assert.Equal(s, roundTrip); + Assert.Equal(data.Length, pos); + } + + [Fact] + public void TurbineString_NonAscii_RoundTripsAsUtf16LE() + { + // 'Café' has 4 UTF-16 code units, last with 0x00E9 — verifies + // we're using UTF-16LE rather than CP1252 like the rest of the + // chat layer. + const string s = "Café"; + var buf = new List(); + TurbineChat.WriteTurbineString(buf, s); + // Bytes: prefix(1) + UTF-16LE C-a-f-é = 1 + 8 = 9 bytes. + Assert.Equal(9, buf.Count); + Assert.Equal(4, buf[0]); + // 'é' = 0xE9 0x00 in UTF-16LE. + Assert.Equal(0xE9, buf[1 + 6]); + Assert.Equal(0x00, buf[1 + 7]); + + byte[] data = buf.ToArray(); + int pos = 0; + string roundTrip = TurbineChat.ReadTurbineString(data, ref pos); + Assert.Equal(s, roundTrip); + } + + [Fact] + public void TurbineString_EmptyString_RoundTrips() + { + var buf = new List(); + TurbineChat.WriteTurbineString(buf, ""); + Assert.Single(buf); + Assert.Equal(0, buf[0]); + + byte[] data = buf.ToArray(); + int pos = 0; + Assert.Equal("", TurbineChat.ReadTurbineString(data, ref pos)); + Assert.Equal(1, pos); + } + + // ────────────────────────────────────────────────────────────── + // Helpers + // ────────────────────────────────────────────────────────────── + + private static byte[] HexDecode(string hex) + { + if ((hex.Length & 1) != 0) throw new ArgumentException("hex length must be even"); + byte[] result = new byte[hex.Length / 2]; + for (int i = 0; i < result.Length; i++) + result[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16); + return result; + } +} diff --git a/tests/AcDream.Core.Tests/Chat/ChatChannelInfoTests.cs b/tests/AcDream.Core.Tests/Chat/ChatChannelInfoTests.cs new file mode 100644 index 0000000..35778d9 --- /dev/null +++ b/tests/AcDream.Core.Tests/Chat/ChatChannelInfoTests.cs @@ -0,0 +1,61 @@ +using AcDream.Core.Chat; +using Xunit; + +namespace AcDream.Core.Tests.Chat; + +/// +/// Phase I.6: + +/// behaviour. Mirrors holtburger's is_self_echo_channel predicate +/// (chat.rs:492-507) — only the legacy fellowship + allegiance-tree +/// channels echo the sender's own messages back with empty sender. +/// +public sealed class ChatChannelInfoTests +{ + [Fact] + public void Legacy_FellowshipBitflag_IsSelfEcho() + { + var c = new ChatChannelInfo.Legacy(0x00000800u, "Fellowship"); + Assert.Equal(ChatChannelSource.Legacy, c.Source); + Assert.Equal("Fellowship", c.DisplayName); + Assert.True(c.IsSelfEchoChannel()); + } + + [Theory] + [InlineData(0x00001000u)] // Vassals + [InlineData(0x00002000u)] // Patron + [InlineData(0x00004000u)] // Monarch + [InlineData(0x01000000u)] // CoVassals + public void Legacy_AllegianceTreeBitflags_AreSelfEcho(uint id) + { + var c = new ChatChannelInfo.Legacy(id, "Allegiance"); + Assert.True(c.IsSelfEchoChannel(), $"channel 0x{id:X8} should self-echo"); + } + + [Fact] + public void Legacy_AllegianceBroadcastBitflag_IsNOTSelfEcho() + { + // 0x02000000 AllegianceBroadcast is the read-only motd channel — + // server does not echo client messages back on it (per holtburger + // chat.rs:492-507 predicate, only Fellow+Vassals+Patron+Monarch+CoVassals). + var c = new ChatChannelInfo.Legacy(0x02000000u, "AllegianceBroadcast"); + Assert.False(c.IsSelfEchoChannel()); + } + + [Fact] + public void Turbine_IsNeverSelfEcho() + { + // Turbine rooms don't echo — caller must emit local optimistic echo. + var t = new ChatChannelInfo.Turbine( + RoomId: 0x12345678u, ChatType: 2u, DispatchType: 2u, DisplayName: "General"); + Assert.Equal(ChatChannelSource.Turbine, t.Source); + Assert.Equal("General", t.DisplayName); + Assert.False(t.IsSelfEchoChannel()); + } + + [Fact] + public void Legacy_UnknownBitflag_IsNotSelfEcho() + { + var c = new ChatChannelInfo.Legacy(0xCAFEF00Du, "WatCha"); + Assert.False(c.IsSelfEchoChannel()); + } +} diff --git a/tests/AcDream.Core.Tests/Chat/TurbineChatStateTests.cs b/tests/AcDream.Core.Tests/Chat/TurbineChatStateTests.cs new file mode 100644 index 0000000..d3c7703 --- /dev/null +++ b/tests/AcDream.Core.Tests/Chat/TurbineChatStateTests.cs @@ -0,0 +1,84 @@ +using AcDream.Core.Chat; +using Xunit; + +namespace AcDream.Core.Tests.Chat; + +/// +/// Phase I.6: behaviour. +/// +public sealed class TurbineChatStateTests +{ + [Fact] + public void InitialState_DisabledAndZeroRooms_NextContextIdStartsAt1() + { + var s = new TurbineChatState(); + Assert.False(s.Enabled); + Assert.Equal(0u, s.GeneralRoom); + Assert.Equal(0u, s.TradeRoom); + Assert.Equal(0u, s.AllegianceRoom); + Assert.Equal(1u, s.NextContextId()); + // After consuming one cookie, the next is 2 + Assert.Equal(2u, s.NextContextId()); + } + + [Fact] + public void OnChannelsReceived_PopulatesAllFieldsAndEnables() + { + var s = new TurbineChatState(); + s.OnChannelsReceived( + allegianceRoom: 0xA0, + generalRoom: 0xA1, + tradeRoom: 0xA2, + lfgRoom: 0xA3, + roleplayRoom: 0xA4, + olthoiRoom: 0xA5, + societyRoom: 0xA6, + societyCelestialHandRoom: 0xA7, + societyEldrytchWebRoom: 0xA8, + societyRadiantBloodRoom: 0xA9); + + Assert.True(s.Enabled); + Assert.Equal(0xA0u, s.AllegianceRoom); + Assert.Equal(0xA1u, s.GeneralRoom); + Assert.Equal(0xA2u, s.TradeRoom); + Assert.Equal(0xA3u, s.LfgRoom); + Assert.Equal(0xA4u, s.RoleplayRoom); + Assert.Equal(0xA5u, s.OlthoiRoom); + Assert.Equal(0xA6u, s.SocietyRoom); + Assert.Equal(0xA7u, s.SocietyCelestialHandRoom); + Assert.Equal(0xA8u, s.SocietyEldrytchWebRoom); + Assert.Equal(0xA9u, s.SocietyRadiantBloodRoom); + } + + [Fact] + public void NextContextId_IsMonotonicAndStartsAt1() + { + var s = new TurbineChatState(); + Assert.Equal(1u, s.NextContextId()); + Assert.Equal(2u, s.NextContextId()); + Assert.Equal(3u, s.NextContextId()); + Assert.Equal(4u, s.NextContextId()); + } + + [Fact] + public void RoomFor_ReturnsConfiguredRoom() + { + var s = new TurbineChatState(); + s.OnChannelsReceived( + allegianceRoom: 1, generalRoom: 2, tradeRoom: 3, + lfgRoom: 4, roleplayRoom: 5, olthoiRoom: 6, + societyRoom: 7, societyCelestialHandRoom: 8, + societyEldrytchWebRoom: 9, societyRadiantBloodRoom: 10); + + Assert.Equal(1u, s.RoomFor(ChatChannelKindLite.Allegiance)); + Assert.Equal(2u, s.RoomFor(ChatChannelKindLite.General)); + Assert.Equal(3u, s.RoomFor(ChatChannelKindLite.Trade)); + Assert.Equal(4u, s.RoomFor(ChatChannelKindLite.Lfg)); + Assert.Equal(5u, s.RoomFor(ChatChannelKindLite.Roleplay)); + Assert.Equal(6u, s.RoomFor(ChatChannelKindLite.Olthoi)); + Assert.Equal(7u, s.RoomFor(ChatChannelKindLite.Society)); + Assert.Equal(8u, s.RoomFor(ChatChannelKindLite.SocietyCelestialHand)); + Assert.Equal(9u, s.RoomFor(ChatChannelKindLite.SocietyEldrytchWeb)); + Assert.Equal(10u, s.RoomFor(ChatChannelKindLite.SocietyRadiantBlood)); + } +}