From ff5ed9ec0b8889a0d7c35b77b3cd71c61c23fa3f Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Apr 2026 19:06:01 +0200 Subject: [PATCH] feat(net): #18 holtburger inbound chat parity - EmoteText, SoulEmote, ServerMessage, PlayerKilled, WeenieError + Windows-1252 codec Five sub-changes: 1. Windows-1252 codec switch (global). Every Encoding.ASCII call site in src/AcDream.Core.Net/Messages/ -> Encoding.GetEncoding(1252). Touched HearSpeech, ChatRequests, GameEvents, AppraiseInfoParser, CharacterList, CreateObject, PlayerDescriptionParser, SocialActions. New Encodings.cs module-init registers CodePagesEncodingProvider (System.Text.Encoding.CodePages ships with .NET 10 SDK but isn't auto-registered). Matches retail + holtburger; accented names no longer round-trip-broken. 2. New parsers (opcodes confirmed against holtburger opcodes.rs): - EmoteText (0x01E0) { u32 senderGuid, string16 senderName, string16 text } - SoulEmote (0x01E2) same wire layout as EmoteText - ServerMessage (0xF7E0) { string16 message, u32 chatType } - PlayerKilled (0x019E) { string16 deathMessage, u32 victimGuid, u32 killerGuid } Shared StringReader.cs has the CP1252 String16L primitive. 3. WorldSession dispatch. ProcessDatagram adds branches for the four new top-level opcodes + fires session-level events (EmoteHeard, SoulEmoteHeard, ServerMessageReceived, PlayerKilledReceived). 0x0295 SetTurbineChatChannels stubbed with TODO for parallel I.6. 4. GameEventWiring routes WeenieError + WeenieErrorWithString (parsers existed but were unrouted) -> chat.OnWeenieError. 5. ChatLog adapters: Emote / SoulEmote ChatKind values, OnEmote, OnSoulEmote, OnPlayerKilled, OnWeenieError. OnLocalSpeech now substitutes empty sender -> "You" per holtburger client/messages.rs. ChatVM.FormatEntry handles new kinds (asterisk + sender + text). 22 new tests covering parser round-trips + reject-bad-opcode + ChatLog adapter coverage + Win-1252 round-trip with non-ASCII chars. Solution total: 881 green (210->225 in Core.Net.Tests, 606->613 in Core.Tests). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.Core.Net/GameEventWiring.cs | 16 ++++ .../Messages/AppraiseInfoParser.cs | 3 +- .../Messages/CharacterList.cs | 3 +- src/AcDream.Core.Net/Messages/ChatRequests.cs | 3 +- src/AcDream.Core.Net/Messages/CreateObject.cs | 3 +- src/AcDream.Core.Net/Messages/EmoteText.cs | 55 +++++++++++++ src/AcDream.Core.Net/Messages/Encodings.cs | 51 ++++++++++++ src/AcDream.Core.Net/Messages/GameEvents.cs | 3 +- src/AcDream.Core.Net/Messages/HearSpeech.cs | 5 +- .../Messages/PlayerDescriptionParser.cs | 3 +- src/AcDream.Core.Net/Messages/PlayerKilled.cs | 51 ++++++++++++ .../Messages/ServerMessage.cs | 51 ++++++++++++ .../Messages/SocialActions.cs | 3 +- src/AcDream.Core.Net/Messages/SoulEmote.cs | 51 ++++++++++++ src/AcDream.Core.Net/Messages/StringReader.cs | 46 +++++++++++ src/AcDream.Core.Net/WorldSession.cs | 73 +++++++++++++++++ src/AcDream.Core/Chat/ChatLog.cs | 74 ++++++++++++++++- .../Panels/Chat/ChatVM.cs | 7 ++ .../GameEventWiringTests.cs | 45 ++++++++++ .../Messages/ChatTests.cs | 25 +++++- .../Messages/EmoteTextTests.cs | 80 ++++++++++++++++++ .../Messages/PlayerKilledTests.cs | 65 +++++++++++++++ .../Messages/ServerMessageTests.cs | 58 +++++++++++++ .../Messages/SoulEmoteTests.cs | 53 ++++++++++++ tests/AcDream.Core.Tests/Chat/ChatLogTests.cs | 82 +++++++++++++++++++ 25 files changed, 899 insertions(+), 10 deletions(-) create mode 100644 src/AcDream.Core.Net/Messages/EmoteText.cs create mode 100644 src/AcDream.Core.Net/Messages/Encodings.cs create mode 100644 src/AcDream.Core.Net/Messages/PlayerKilled.cs create mode 100644 src/AcDream.Core.Net/Messages/ServerMessage.cs create mode 100644 src/AcDream.Core.Net/Messages/SoulEmote.cs create mode 100644 src/AcDream.Core.Net/Messages/StringReader.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/EmoteTextTests.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/PlayerKilledTests.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/ServerMessageTests.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/SoulEmoteTests.cs diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index ee40d92..5848737 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -66,6 +66,22 @@ public static class GameEventWiring if (s is not null) chat.OnPopup(s); }); + // ── Errors ─────────────────────────────────────────────── + // Phase I.5: WeenieError + WeenieErrorWithString parsers existed + // (GameEvents.ParseWeenieError(WithString)) but were never registered. + // The server fires these for game-logic failures: "not enough mana", + // "can't pick that up", "your spell fizzled". Routed to chat. + dispatcher.Register(GameEventType.WeenieError, e => + { + var code = GameEvents.ParseWeenieError(e.Payload.Span); + if (code is not null) chat.OnWeenieError(code.Value, param: null); + }); + dispatcher.Register(GameEventType.WeenieErrorWithString, e => + { + var p = GameEvents.ParseWeenieErrorWithString(e.Payload.Span); + if (p is not null) chat.OnWeenieError(p.Value.ErrorCode, p.Value.Interpolation); + }); + // ── Combat ──────────────────────────────────────────────── dispatcher.Register(GameEventType.UpdateHealth, e => { diff --git a/src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs b/src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs index 0661710..38e132d 100644 --- a/src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs +++ b/src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs @@ -429,7 +429,8 @@ public static class AppraiseInfoParser ushort len = BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos)); pos += 2; if (src.Length - pos < len) throw new FormatException("truncated string body"); - string v = Encoding.ASCII.GetString(src.Slice(pos, len)); + // Windows-1252 matches retail (and holtburger's encoding_rs::WINDOWS_1252). + string v = Encoding.GetEncoding(1252).GetString(src.Slice(pos, len)); pos += len; int record = 2 + len; int pad = (4 - (record & 3)) & 3; diff --git a/src/AcDream.Core.Net/Messages/CharacterList.cs b/src/AcDream.Core.Net/Messages/CharacterList.cs index 6fc3af1..0b7eac9 100644 --- a/src/AcDream.Core.Net/Messages/CharacterList.cs +++ b/src/AcDream.Core.Net/Messages/CharacterList.cs @@ -92,7 +92,8 @@ public static class CharacterList ushort length = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(pos)); pos += 2; if (source.Length - pos < length) throw new FormatException("truncated String16L body"); - string result = Encoding.ASCII.GetString(source.Slice(pos, length)); + // Windows-1252 matches retail (and holtburger's encoding_rs::WINDOWS_1252). + string result = Encoding.GetEncoding(1252).GetString(source.Slice(pos, length)); pos += length; int recordSize = 2 + length; int padding = (4 - (recordSize & 3)) & 3; diff --git a/src/AcDream.Core.Net/Messages/ChatRequests.cs b/src/AcDream.Core.Net/Messages/ChatRequests.cs index daebbfd..485961c 100644 --- a/src/AcDream.Core.Net/Messages/ChatRequests.cs +++ b/src/AcDream.Core.Net/Messages/ChatRequests.cs @@ -84,7 +84,8 @@ public static class ChatRequests private static byte[] PackString16L(string s) { - byte[] data = Encoding.ASCII.GetBytes(s); + // Windows-1252 matches retail (and holtburger's encoding_rs::WINDOWS_1252). + byte[] data = Encoding.GetEncoding(1252).GetBytes(s); if (data.Length > ushort.MaxValue) throw new ArgumentException("String too long for 16-bit length prefix.", nameof(s)); diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs index e7d9c87..1541e07 100644 --- a/src/AcDream.Core.Net/Messages/CreateObject.cs +++ b/src/AcDream.Core.Net/Messages/CreateObject.cs @@ -433,7 +433,8 @@ public static class CreateObject pos += 2; if (length > 1024) throw new FormatException($"String16L length {length} exceeds sanity limit"); if (source.Length - pos < length) throw new FormatException("truncated String16L body"); - string result = System.Text.Encoding.ASCII.GetString(source.Slice(pos, length)); + // Windows-1252 matches retail (and holtburger's encoding_rs::WINDOWS_1252). + string result = System.Text.Encoding.GetEncoding(1252).GetString(source.Slice(pos, length)); pos += length; int recordSize = 2 + length; int padding = (4 - (recordSize & 3)) & 3; diff --git a/src/AcDream.Core.Net/Messages/EmoteText.cs b/src/AcDream.Core.Net/Messages/EmoteText.cs new file mode 100644 index 0000000..2931a3c --- /dev/null +++ b/src/AcDream.Core.Net/Messages/EmoteText.cs @@ -0,0 +1,55 @@ +using System; +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Inbound 0x01E0 EmoteText top-level GameMessage. +/// Server-driven third-person emote announcement (e.g. +/// "The Olthoi growls at you."). +/// +/// +/// This is a standalone GameMessage — NOT wrapped in the 0xF7B0 +/// GameEvent envelope. Dispatched directly from the opcode switch in +/// . +/// +/// +/// +/// Wire layout (port from holtburger +/// references/holtburger/.../messages/chat/types.rs::EmoteTextData, +/// see also opcodes.rs:155): +/// +/// u32 opcode // 0x01E0 +/// u32 senderGuid +/// string16L senderName +/// string16L text +/// +/// +/// +public static class EmoteText +{ + public const uint Opcode = 0x01E0u; + + public readonly record struct Parsed( + uint SenderGuid, + string SenderName, + string Text); + + public static Parsed? TryParse(byte[] body) + { + if (body is null || body.Length < 8) return null; + uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body); + if (opcode != Opcode) return null; + + try + { + int pos = 4; + uint senderGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos)); + pos += 4; + string senderName = StringReader.ReadString16L(body, ref pos); + string text = StringReader.ReadString16L(body, ref pos); + return new Parsed(senderGuid, senderName, text); + } + catch { return null; } + } +} diff --git a/src/AcDream.Core.Net/Messages/Encodings.cs b/src/AcDream.Core.Net/Messages/Encodings.cs new file mode 100644 index 0000000..3a357c3 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/Encodings.cs @@ -0,0 +1,51 @@ +using System.Runtime.CompilerServices; +using System.Text; + +namespace AcDream.Core.Net.Messages; + +/// +/// Shared text encodings for the AC wire protocol. +/// +/// +/// Retail and holtburger both use Windows-1252 (CP1252) for every +/// String16L on the wire — chat, character names, item names, +/// system messages, popup text. Before Phase I.5 we used ASCII, +/// which was wrong: any non-ASCII byte (e.g. é = 0xE9, name accents +/// or chat punctuation from Latin-1 locales) decoded to '?'. Switching +/// to CP1252 matches what the server actually sends. +/// +/// +/// +/// .NET 6+ ships only ASCII / Latin-1 / UTF8/16/32 in the base library; +/// Windows-1252 lives in System.Text.Encoding.CodePages. The +/// module initializer below registers that provider on first load of +/// any type in AcDream.Core.Net.Messages, so call sites can use +/// with id 1252 directly. +/// +/// +public static class Encodings +{ + /// + /// Module initializer — guaranteed by ECMA-335 to run before any + /// other code in this module (in particular, before any static + /// field initializer in user code that follows). Registers + /// CodePagesEncodingProvider so + /// with id 1252 returns a + /// real encoding instance instead of throwing + /// . + /// +#pragma warning disable CA2255 // Library-internal: registers CP1252 once + // before any wire-string parser is invoked. + [ModuleInitializer] + internal static void Register() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + } +#pragma warning restore CA2255 + + /// + /// CP1252 (Windows-1252). Cached so callers don't look it up each frame. + /// Initialized after via field-init ordering. + /// + public static readonly Encoding Windows1252 = Encoding.GetEncoding(1252); +} diff --git a/src/AcDream.Core.Net/Messages/GameEvents.cs b/src/AcDream.Core.Net/Messages/GameEvents.cs index b0bc1c0..6889140 100644 --- a/src/AcDream.Core.Net/Messages/GameEvents.cs +++ b/src/AcDream.Core.Net/Messages/GameEvents.cs @@ -471,7 +471,8 @@ public static class GameEvents ushort length = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(pos)); pos += 2; if (source.Length - pos < length) throw new FormatException("truncated String16L body"); - string result = Encoding.ASCII.GetString(source.Slice(pos, length)); + // Windows-1252 matches retail (and holtburger's encoding_rs::WINDOWS_1252). + string result = Encoding.GetEncoding(1252).GetString(source.Slice(pos, length)); pos += length; int recordSize = 2 + length; int padding = (4 - (recordSize & 3)) & 3; diff --git a/src/AcDream.Core.Net/Messages/HearSpeech.cs b/src/AcDream.Core.Net/Messages/HearSpeech.cs index d918e13..138af62 100644 --- a/src/AcDream.Core.Net/Messages/HearSpeech.cs +++ b/src/AcDream.Core.Net/Messages/HearSpeech.cs @@ -75,7 +75,10 @@ public static class HearSpeech ushort length = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(pos)); pos += 2; if (source.Length - pos < length) throw new FormatException("truncated String16L body"); - string result = Encoding.ASCII.GetString(source.Slice(pos, length)); + // Windows-1252 matches retail (and holtburger's encoding_rs::WINDOWS_1252). + // ASCII would munge any non-ASCII byte into a '?' which corrupts player + // names and chat with accented characters (e.g. "Café"). + string result = Encoding.GetEncoding(1252).GetString(source.Slice(pos, length)); pos += length; int recordSize = 2 + length; int padding = (4 - (recordSize & 3)) & 3; diff --git a/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs b/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs index 4911f66..406af15 100644 --- a/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs +++ b/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs @@ -590,7 +590,8 @@ public static class PlayerDescriptionParser ushort len = BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos)); pos += 2; if (src.Length - pos < len) throw new FormatException("truncated string body"); - string v = Encoding.ASCII.GetString(src.Slice(pos, len)); + // Windows-1252 matches retail (and holtburger's encoding_rs::WINDOWS_1252). + string v = Encoding.GetEncoding(1252).GetString(src.Slice(pos, len)); pos += len; // String16L records pad to 4-byte alignment per AppraiseInfoParser convention. int record = 2 + len; diff --git a/src/AcDream.Core.Net/Messages/PlayerKilled.cs b/src/AcDream.Core.Net/Messages/PlayerKilled.cs new file mode 100644 index 0000000..e35a6c4 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/PlayerKilled.cs @@ -0,0 +1,51 @@ +using System; +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Inbound 0x019E PlayerKilled top-level GameMessage. +/// Server announcement that a player was killed in combat — used for +/// the death-message scroll and chat-log lines like +/// "Caith was killed by an Olthoi Soldier." +/// +/// +/// Wire layout (port from holtburger +/// references/holtburger/.../messages/combat/types.rs::PlayerKilledData, +/// see also opcodes.rs:150): +/// +/// u32 opcode // 0x019E +/// string16L deathMessage +/// u32 victimGuid +/// u32 killerGuid +/// +/// +/// +public static class PlayerKilled +{ + public const uint Opcode = 0x019Eu; + + public readonly record struct Parsed( + string DeathMessage, + uint VictimGuid, + uint KillerGuid); + + public static Parsed? TryParse(byte[] body) + { + if (body is null || body.Length < 4) return null; + uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body); + if (opcode != Opcode) return null; + + try + { + int pos = 4; + string deathMessage = StringReader.ReadString16L(body, ref pos); + if (body.Length - pos < 8) return null; + uint victimGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos)); + pos += 4; + uint killerGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos)); + return new Parsed(deathMessage, victimGuid, killerGuid); + } + catch { return null; } + } +} diff --git a/src/AcDream.Core.Net/Messages/ServerMessage.cs b/src/AcDream.Core.Net/Messages/ServerMessage.cs new file mode 100644 index 0000000..3ccf0b8 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/ServerMessage.cs @@ -0,0 +1,51 @@ +using System; +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Inbound 0xF7E0 ServerMessage top-level GameMessage. +/// General-purpose server-broadcast text — admin announcements, +/// combat logs, and routine error messages routed by the server +/// instead of via WeenieError. +/// +/// +/// This is a standalone GameMessage — NOT wrapped in 0xF7B0 +/// GameEvent envelope. Dispatched directly from +/// . +/// +/// +/// +/// Wire layout (port from holtburger +/// references/holtburger/.../messages/chat/types.rs::ServerMessageData, +/// see also opcodes.rs:167): +/// +/// u32 opcode // 0xF7E0 +/// string16L message +/// u32 chatType // ChatMessageType (Broadcast / System / etc) +/// +/// +/// +public static class ServerMessage +{ + public const uint Opcode = 0xF7E0u; + + public readonly record struct Parsed(string Message, uint ChatType); + + public static Parsed? TryParse(byte[] body) + { + if (body is null || body.Length < 8) return null; + uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body); + if (opcode != Opcode) return null; + + try + { + int pos = 4; + string message = StringReader.ReadString16L(body, ref pos); + if (body.Length - pos < 4) return null; + uint chatType = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos)); + return new Parsed(message, chatType); + } + catch { return null; } + } +} diff --git a/src/AcDream.Core.Net/Messages/SocialActions.cs b/src/AcDream.Core.Net/Messages/SocialActions.cs index 86f7524..4fb1505 100644 --- a/src/AcDream.Core.Net/Messages/SocialActions.cs +++ b/src/AcDream.Core.Net/Messages/SocialActions.cs @@ -160,7 +160,8 @@ public static class SocialActions private static byte[] PackString16L(string s) { ArgumentNullException.ThrowIfNull(s); - byte[] data = Encoding.ASCII.GetBytes(s); + // Windows-1252 matches retail (and holtburger's encoding_rs::WINDOWS_1252). + byte[] data = Encoding.GetEncoding(1252).GetBytes(s); if (data.Length > ushort.MaxValue) throw new ArgumentException("String too long for 16-bit length prefix.", nameof(s)); diff --git a/src/AcDream.Core.Net/Messages/SoulEmote.cs b/src/AcDream.Core.Net/Messages/SoulEmote.cs new file mode 100644 index 0000000..cdc46f8 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/SoulEmote.cs @@ -0,0 +1,51 @@ +using System; +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Inbound 0x01E2 SoulEmote top-level GameMessage. +/// Server-driven complex emote with optional animation pairing. +/// Wire layout is identical to ; the difference +/// is only how the client renders it (chat-only vs paired with a +/// PlayScript on the same target). +/// +/// +/// Wire layout (port from holtburger +/// references/holtburger/.../messages/chat/types.rs::SoulEmoteData, +/// see also opcodes.rs:158): +/// +/// u32 opcode // 0x01E2 +/// u32 senderGuid +/// string16L senderName +/// string16L text +/// +/// +/// +public static class SoulEmote +{ + public const uint Opcode = 0x01E2u; + + public readonly record struct Parsed( + uint SenderGuid, + string SenderName, + string Text); + + public static Parsed? TryParse(byte[] body) + { + if (body is null || body.Length < 8) return null; + uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body); + if (opcode != Opcode) return null; + + try + { + int pos = 4; + uint senderGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos)); + pos += 4; + string senderName = StringReader.ReadString16L(body, ref pos); + string text = StringReader.ReadString16L(body, ref pos); + return new Parsed(senderGuid, senderName, text); + } + catch { return null; } + } +} diff --git a/src/AcDream.Core.Net/Messages/StringReader.cs b/src/AcDream.Core.Net/Messages/StringReader.cs new file mode 100644 index 0000000..c9a1a49 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/StringReader.cs @@ -0,0 +1,46 @@ +using System; +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Shared reader for the AC String16L wire format used by every +/// inbound and outbound chat / name / message body. +/// +/// +/// Wire shape: +/// +/// u16 length // byte count, NOT char count +/// byte[] text // CP1252-encoded bytes, no terminator +/// pad to 4-byte boundary +/// +/// +/// +/// +/// Codec is Windows-1252 (CP1252), matching retail and holtburger's +/// encoding_rs::WINDOWS_1252. Registration of the +/// CodePagesEncodingProvider is handled by +/// . +/// +/// +internal static class StringReader +{ + /// + /// Read a String16L from at , + /// advancing past the length, body, and 4-byte + /// alignment padding. Throws on truncation. + /// + public static string ReadString16L(ReadOnlySpan source, ref int pos) + { + if (source.Length - pos < 2) throw new FormatException("truncated String16L length"); + ushort length = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(pos)); + pos += 2; + if (source.Length - pos < length) throw new FormatException("truncated String16L body"); + string result = Encodings.Windows1252.GetString(source.Slice(pos, length)); + pos += length; + int recordSize = 2 + length; + int padding = (4 - (recordSize & 3)) & 3; + pos += padding; + return result; + } +} diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 048b91d..5b2ff00 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -109,6 +109,38 @@ public sealed class WorldSession : IDisposable /// public event Action? SpeechHeard; + /// + /// Phase I.5: fires when an EmoteText (0x01E0) top-level + /// GameMessage is received — server-driven third-person emote + /// announcement (e.g. "The Olthoi growls at you."). Standalone + /// GameMessage, NOT wrapped in 0xF7B0. Subscribers typically feed + /// ChatLog.OnEmote. + /// + public event Action? EmoteHeard; + + /// + /// Phase I.5: fires when a SoulEmote (0x01E2) top-level + /// GameMessage is received — complex emote with optional animation + /// pairing. Wire layout matches EmoteText. + /// + public event Action? SoulEmoteHeard; + + /// + /// Phase I.5: fires when a ServerMessage (0xF7E0) top-level + /// GameMessage is received — general server-broadcast text used + /// for announcements, combat logs, and routine error messages. + /// Subscribers typically feed ChatLog.OnSystemMessage. + /// + public event Action? ServerMessageReceived; + + /// + /// Phase I.5: fires when a PlayerKilled (0x019E) top-level + /// GameMessage is received — server announcement that a player + /// was killed in combat. Subscribers typically feed + /// ChatLog.OnPlayerKilled. + /// + public event Action? PlayerKilledReceived; + /// /// Issue #5: fires when a PrivateUpdateVital (0x02E7) arrives /// — full per-vital snapshot (ranks / start / xp / current). @@ -615,6 +647,47 @@ public sealed class WorldSession : IDisposable if (parsed is not null) SpeechHeard?.Invoke(parsed.Value); } + else if (op == EmoteText.Opcode) + { + // Phase I.5: server-driven third-person emote + // ("The Olthoi growls at you."). Standalone GameMessage, + // not wrapped in 0xF7B0. Holtburger opcodes.rs:155. + var parsed = EmoteText.TryParse(body); + if (parsed is not null) + EmoteHeard?.Invoke(parsed.Value); + } + else if (op == SoulEmote.Opcode) + { + // Phase I.5: complex emote (chat + paired animation). + // Wire layout identical to EmoteText. Holtburger + // opcodes.rs:158. + var parsed = SoulEmote.TryParse(body); + if (parsed is not null) + SoulEmoteHeard?.Invoke(parsed.Value); + } + else if (op == ServerMessage.Opcode) + { + // Phase I.5: server announcement / system message. + // Holtburger opcodes.rs:167. + var parsed = ServerMessage.TryParse(body); + if (parsed is not null) + ServerMessageReceived?.Invoke(parsed.Value); + } + else if (op == PlayerKilled.Opcode) + { + // Phase I.5: death announcement. Holtburger opcodes.rs:150. + var parsed = PlayerKilled.TryParse(body); + if (parsed is not null) + PlayerKilledReceived?.Invoke(parsed.Value); + } + else if (op == 0x0295u) + { + // 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. + } else if (op == PrivateUpdateVital.FullOpcode) { // Issue #5: full per-vital snapshot from the server. Wire diff --git a/src/AcDream.Core/Chat/ChatLog.cs b/src/AcDream.Core/Chat/ChatLog.cs index 115d379..86e33ea 100644 --- a/src/AcDream.Core/Chat/ChatLog.cs +++ b/src/AcDream.Core/Chat/ChatLog.cs @@ -42,16 +42,86 @@ public sealed class ChatLog // ── Inbound adapters ───────────────────────────────────────────────────── /// Local or ranged HearSpeech (0x02BB / 0x02BC). + /// + /// Phase I.5: an empty is substituted with + /// "You" — the server uses this convention to indicate that the + /// player is the speaker (e.g. their own ranged shouts echo back). + /// Port from holtburger + /// references/holtburger/.../client/messages.rs lines 476-487. + /// public void OnLocalSpeech(string sender, string text, uint senderGuid, bool isRanged) { + string effectiveSender = string.IsNullOrEmpty(sender) ? "You" : sender; Append(new ChatEntry( Kind: isRanged ? ChatKind.RangedSpeech : ChatKind.LocalSpeech, - Sender: sender, + Sender: effectiveSender, Text: text, SenderGuid: senderGuid, ChannelId: 0)); } + /// EmoteText (0x01E0) — server-driven third-person emote. + public void OnEmote(string senderName, string text, uint senderGuid) + { + Append(new ChatEntry( + Kind: ChatKind.Emote, + Sender: senderName, + Text: text, + SenderGuid: senderGuid, + ChannelId: 0)); + } + + /// SoulEmote (0x01E2) — complex emote (chat + paired animation). + public void OnSoulEmote(string senderName, string text, uint senderGuid) + { + Append(new ChatEntry( + Kind: ChatKind.SoulEmote, + Sender: senderName, + Text: text, + SenderGuid: senderGuid, + ChannelId: 0)); + } + + /// PlayerKilled (0x019E) — death announcement. + /// + /// Death messages are routed as so they + /// share styling with other server announcements. The + /// SenderGuid field carries the victim guid; the + /// ChannelId field carries the killer guid (a small misuse + /// of the field but avoids a schema change). + /// + public void OnPlayerKilled(string deathMessage, uint victimGuid, uint killerGuid) + { + Append(new ChatEntry( + Kind: ChatKind.System, + Sender: "", + Text: deathMessage, + SenderGuid: victimGuid, + ChannelId: killerGuid)); + } + + /// WeenieError (0x028A) / WeenieErrorWithString (0x028B). + /// + /// Phase I.5: previously-orphaned parser. The server fires this when a + /// game-logic action fails (e.g. "you don't have enough mana", "you + /// can't pick that up"). Routed as ; the + /// ChannelId field carries the WeenieError code so plugins can + /// filter or react. is the interpolated + /// substring (null for plain WeenieError, set for WeenieErrorWithString). + /// + public void OnWeenieError(uint errorId, string? param) + { + string text = string.IsNullOrEmpty(param) + ? $"WeenieError 0x{errorId:X4}" + : $"WeenieError 0x{errorId:X4}: {param}"; + Append(new ChatEntry( + Kind: ChatKind.System, + Sender: "", + Text: text, + SenderGuid: 0, + ChannelId: errorId)); + } + /// GameEvent ChannelBroadcast (0x0147). public void OnChannelBroadcast(uint channelId, string sender, string text) { @@ -129,6 +199,8 @@ public enum ChatKind Tell, System, Popup, + Emote, + SoulEmote, } public readonly record struct ChatEntry( diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs index fa05d27..636d2a6 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs @@ -77,6 +77,13 @@ public sealed class ChatVM ChatKind.Tell => $"[Tell] {entry.Sender}: {entry.Text}", ChatKind.System => $"[System] {entry.Text}", ChatKind.Popup => $"[Popup] {entry.Text}", + // Phase I.5: emote rendering matches retail's leading-asterisk + // convention ("* Caith waves at you"). SoulEmote uses the same + // prefix; the difference between Emote and SoulEmote is which + // animation pairs with the chat line (handled by the renderer, + // not the formatter). + ChatKind.Emote => $"* {entry.Sender} {entry.Text}", + ChatKind.SoulEmote => $"* {entry.Sender} {entry.Text}", _ => entry.Text, }; } diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs index cf73296..d32936c 100644 --- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs @@ -279,4 +279,49 @@ public sealed class GameEventWiringTests Assert.Equal(0, book.ActiveCount); } + [Fact] + public void WireAll_WeenieError_RoutesToChatLog() + { + // Phase I.5: 0x028A previously had a parser + // (GameEvents.ParseWeenieError) but no dispatcher registration. The + // server fires this for plain game-logic failures (e.g. "you can't + // pick that up"). Now wired → ChatLog.OnWeenieError. + var (d, _, _, _, chat) = MakeAll(); + + byte[] payload = new byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x9C); // arbitrary error code + + var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WeenieError, payload)); + d.Dispatch(env!.Value); + + Assert.Equal(1, chat.Count); + var e = chat.Snapshot()[0]; + Assert.Equal(ChatKind.System, e.Kind); + Assert.Equal(0x9Cu, e.ChannelId); + Assert.Contains("0x009C", e.Text); + } + + [Fact] + public void WireAll_WeenieErrorWithString_RoutesToChatLogWithInterpolation() + { + // Phase I.5: 0x028B carries an interpolated substring (e.g. the + // target's name in "you can't pick up the {Mana Stone}"). Now + // wired → ChatLog.OnWeenieError with the param. + var (d, _, _, _, chat) = MakeAll(); + + byte[] interpBytes = MakeString16L("Mana Stone"); + byte[] payload = new byte[4 + interpBytes.Length]; + BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x42u); + Array.Copy(interpBytes, 0, payload, 4, interpBytes.Length); + + var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WeenieErrorWithString, payload)); + d.Dispatch(env!.Value); + + Assert.Equal(1, chat.Count); + var e = chat.Snapshot()[0]; + Assert.Equal(ChatKind.System, e.Kind); + Assert.Equal(0x42u, e.ChannelId); + Assert.Contains("Mana Stone", e.Text); + } + } diff --git a/tests/AcDream.Core.Net.Tests/Messages/ChatTests.cs b/tests/AcDream.Core.Net.Tests/Messages/ChatTests.cs index 8d39425..ad47adc 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/ChatTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/ChatTests.cs @@ -120,9 +120,32 @@ public sealed class ChatTests Assert.Null(HearSpeech.TryParse(body)); } + [Fact] + public void HearSpeech_TryParse_PreservesWindows1252_RoundTrip() + { + // Phase I.5: ASCII would munge non-ASCII bytes into '?'. CP1252 + // round-trips them. The high-byte 0xE9 = 'é' in Latin-1/CP1252. + byte[] msg = PackString16L("Café"); + byte[] sender = PackString16L("Élise"); + byte[] inbound = new byte[4 + msg.Length + sender.Length + 8]; + int pos = 0; + BinaryPrimitives.WriteUInt32LittleEndian(inbound, HearSpeech.LocalOpcode); + pos += 4; + Array.Copy(msg, 0, inbound, pos, msg.Length); pos += msg.Length; + Array.Copy(sender, 0, inbound, pos, sender.Length); pos += sender.Length; + BinaryPrimitives.WriteUInt32LittleEndian(inbound.AsSpan(pos), 0u); pos += 4; + BinaryPrimitives.WriteUInt32LittleEndian(inbound.AsSpan(pos), 0u); + + var parsed = HearSpeech.TryParse(inbound); + Assert.NotNull(parsed); + Assert.Equal("Café", parsed!.Value.Text); + Assert.Equal("Élise", parsed.Value.SenderName); + } + private static byte[] PackString16L(string s) { - byte[] data = Encoding.ASCII.GetBytes(s); + // Test helper now uses CP1252 to match the production codec. + byte[] data = Encoding.GetEncoding(1252).GetBytes(s); int recordSize = 2 + data.Length; int padding = (4 - (recordSize & 3)) & 3; byte[] result = new byte[recordSize + padding]; diff --git a/tests/AcDream.Core.Net.Tests/Messages/EmoteTextTests.cs b/tests/AcDream.Core.Net.Tests/Messages/EmoteTextTests.cs new file mode 100644 index 0000000..7ea75c1 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/EmoteTextTests.cs @@ -0,0 +1,80 @@ +using System; +using System.Buffers.Binary; +using System.Text; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +/// +/// Phase I.5: 0x01E0 EmoteText parser. Wire layout matches holtburger's +/// EmoteTextData: u32 senderGuid + string16L senderName + string16L text. +/// +public sealed class EmoteTextTests +{ + [Fact] + public void TryParse_RoundTrips_GuidAndStrings() + { + byte[] sender = PackString16L("Caith"); + byte[] text = PackString16L("waves at you"); + byte[] body = new byte[4 + 4 + sender.Length + text.Length]; + int pos = 0; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(pos), EmoteText.Opcode); + pos += 4; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(pos), 0xDEADBEEFu); + pos += 4; + Array.Copy(sender, 0, body, pos, sender.Length); pos += sender.Length; + Array.Copy(text, 0, body, pos, text.Length); + + var parsed = EmoteText.TryParse(body); + Assert.NotNull(parsed); + Assert.Equal(0xDEADBEEFu, parsed!.Value.SenderGuid); + Assert.Equal("Caith", parsed.Value.SenderName); + Assert.Equal("waves at you", parsed.Value.Text); + } + + [Fact] + public void TryParse_WrongOpcode_ReturnsNull() + { + byte[] body = new byte[8]; + BinaryPrimitives.WriteUInt32LittleEndian(body, 0x12345678u); + Assert.Null(EmoteText.TryParse(body)); + } + + [Fact] + public void TryParse_TruncatedHeader_ReturnsNull() + { + byte[] body = new byte[6]; + BinaryPrimitives.WriteUInt32LittleEndian(body, EmoteText.Opcode); + Assert.Null(EmoteText.TryParse(body)); + } + + [Fact] + public void TryParse_PreservesWindows1252_RoundTrip() + { + byte[] sender = PackString16L("Élise"); + byte[] text = PackString16L("waves at you, Café"); + byte[] body = new byte[4 + 4 + sender.Length + text.Length]; + int pos = 0; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(pos), EmoteText.Opcode); pos += 4; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(pos), 1u); pos += 4; + Array.Copy(sender, 0, body, pos, sender.Length); pos += sender.Length; + Array.Copy(text, 0, body, pos, text.Length); + + var parsed = EmoteText.TryParse(body); + Assert.NotNull(parsed); + Assert.Equal("Élise", parsed!.Value.SenderName); + Assert.Equal("waves at you, Café", parsed.Value.Text); + } + + private static byte[] PackString16L(string s) + { + byte[] data = Encoding.GetEncoding(1252).GetBytes(s); + int recordSize = 2 + data.Length; + int padding = (4 - (recordSize & 3)) & 3; + byte[] result = new byte[recordSize + padding]; + BinaryPrimitives.WriteUInt16LittleEndian(result, (ushort)data.Length); + Array.Copy(data, 0, result, 2, data.Length); + return result; + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/PlayerKilledTests.cs b/tests/AcDream.Core.Net.Tests/Messages/PlayerKilledTests.cs new file mode 100644 index 0000000..9174cdc --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/PlayerKilledTests.cs @@ -0,0 +1,65 @@ +using System; +using System.Buffers.Binary; +using System.Text; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +/// +/// Phase I.5: 0x019E PlayerKilled parser. Wire layout per holtburger +/// PlayerKilledData: string16L deathMessage + u32 victimGuid + u32 +/// killerGuid. +/// +public sealed class PlayerKilledTests +{ + [Fact] + public void TryParse_RoundTrips_AllThreeFields() + { + // Synthetic payload mirroring holtburger's test_player_killed_fixture + // (combat/types.rs lines 100-115). Death message = "Test", victim = + // 0x12345678, killer = 0x90ABCDEF. + byte[] msg = PackString16L("Test"); + byte[] body = new byte[4 + msg.Length + 8]; + int pos = 0; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(pos), PlayerKilled.Opcode); pos += 4; + Array.Copy(msg, 0, body, pos, msg.Length); pos += msg.Length; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(pos), 0x12345678u); pos += 4; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(pos), 0x90ABCDEFu); + + var parsed = PlayerKilled.TryParse(body); + Assert.NotNull(parsed); + Assert.Equal("Test", parsed!.Value.DeathMessage); + Assert.Equal(0x12345678u, parsed.Value.VictimGuid); + Assert.Equal(0x90ABCDEFu, parsed.Value.KillerGuid); + } + + [Fact] + public void TryParse_WrongOpcode_ReturnsNull() + { + byte[] body = new byte[16]; + BinaryPrimitives.WriteUInt32LittleEndian(body, 0xDEADBEEFu); + Assert.Null(PlayerKilled.TryParse(body)); + } + + [Fact] + public void TryParse_TruncatedAfterMessage_ReturnsNull() + { + byte[] msg = PackString16L("died"); + byte[] body = new byte[4 + msg.Length + 4]; // only one guid present + BinaryPrimitives.WriteUInt32LittleEndian(body, PlayerKilled.Opcode); + Array.Copy(msg, 0, body, 4, msg.Length); + Assert.Null(PlayerKilled.TryParse(body)); + } + + private static byte[] PackString16L(string s) + { + byte[] data = Encoding.GetEncoding(1252).GetBytes(s); + int recordSize = 2 + data.Length; + int padding = (4 - (recordSize & 3)) & 3; + byte[] result = new byte[recordSize + padding]; + BinaryPrimitives.WriteUInt16LittleEndian(result, (ushort)data.Length); + Array.Copy(data, 0, result, 2, data.Length); + return result; + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/ServerMessageTests.cs b/tests/AcDream.Core.Net.Tests/Messages/ServerMessageTests.cs new file mode 100644 index 0000000..c3c05bf --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/ServerMessageTests.cs @@ -0,0 +1,58 @@ +using System; +using System.Buffers.Binary; +using System.Text; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +/// +/// Phase I.5: 0xF7E0 ServerMessage parser. Wire layout per holtburger +/// ServerMessageData: string16L message + u32 chatType. +/// +public sealed class ServerMessageTests +{ + [Fact] + public void TryParse_RoundTrips_MessageAndChatType() + { + byte[] msg = PackString16L("The server will reset in 5 minutes."); + byte[] body = new byte[4 + msg.Length + 4]; + BinaryPrimitives.WriteUInt32LittleEndian(body, ServerMessage.Opcode); + Array.Copy(msg, 0, body, 4, msg.Length); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4 + msg.Length), 5u); // System + + var parsed = ServerMessage.TryParse(body); + Assert.NotNull(parsed); + Assert.Equal("The server will reset in 5 minutes.", parsed!.Value.Message); + Assert.Equal(5u, parsed.Value.ChatType); + } + + [Fact] + public void TryParse_WrongOpcode_ReturnsNull() + { + byte[] body = new byte[8]; + BinaryPrimitives.WriteUInt32LittleEndian(body, 0xDEADBEEFu); + Assert.Null(ServerMessage.TryParse(body)); + } + + [Fact] + public void TryParse_TruncatedChatType_ReturnsNull() + { + byte[] msg = PackString16L("hi"); + byte[] body = new byte[4 + msg.Length + 2]; // 2 bytes short + BinaryPrimitives.WriteUInt32LittleEndian(body, ServerMessage.Opcode); + Array.Copy(msg, 0, body, 4, msg.Length); + Assert.Null(ServerMessage.TryParse(body)); + } + + private static byte[] PackString16L(string s) + { + byte[] data = Encoding.GetEncoding(1252).GetBytes(s); + int recordSize = 2 + data.Length; + int padding = (4 - (recordSize & 3)) & 3; + byte[] result = new byte[recordSize + padding]; + BinaryPrimitives.WriteUInt16LittleEndian(result, (ushort)data.Length); + Array.Copy(data, 0, result, 2, data.Length); + return result; + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/SoulEmoteTests.cs b/tests/AcDream.Core.Net.Tests/Messages/SoulEmoteTests.cs new file mode 100644 index 0000000..1cd4d11 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/SoulEmoteTests.cs @@ -0,0 +1,53 @@ +using System; +using System.Buffers.Binary; +using System.Text; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +/// +/// Phase I.5: 0x01E2 SoulEmote parser. Wire layout is identical to +/// EmoteText (u32 senderGuid + string16L senderName + string16L text) +/// per holtburger SoulEmoteData. +/// +public sealed class SoulEmoteTests +{ + [Fact] + public void TryParse_RoundTrips_GuidAndStrings() + { + byte[] sender = PackString16L("Caith"); + byte[] text = PackString16L("dances"); + byte[] body = new byte[4 + 4 + sender.Length + text.Length]; + int pos = 0; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(pos), SoulEmote.Opcode); pos += 4; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(pos), 0xCAFEF00Du); pos += 4; + Array.Copy(sender, 0, body, pos, sender.Length); pos += sender.Length; + Array.Copy(text, 0, body, pos, text.Length); + + var parsed = SoulEmote.TryParse(body); + Assert.NotNull(parsed); + Assert.Equal(0xCAFEF00Du, parsed!.Value.SenderGuid); + Assert.Equal("Caith", parsed.Value.SenderName); + Assert.Equal("dances", parsed.Value.Text); + } + + [Fact] + public void TryParse_WrongOpcode_ReturnsNull() + { + byte[] body = new byte[8]; + BinaryPrimitives.WriteUInt32LittleEndian(body, 0xBADBEEFu); + Assert.Null(SoulEmote.TryParse(body)); + } + + private static byte[] PackString16L(string s) + { + byte[] data = Encoding.GetEncoding(1252).GetBytes(s); + int recordSize = 2 + data.Length; + int padding = (4 - (recordSize & 3)) & 3; + byte[] result = new byte[recordSize + padding]; + BinaryPrimitives.WriteUInt16LittleEndian(result, (ushort)data.Length); + Array.Copy(data, 0, result, 2, data.Length); + return result; + } +} diff --git a/tests/AcDream.Core.Tests/Chat/ChatLogTests.cs b/tests/AcDream.Core.Tests/Chat/ChatLogTests.cs index 0418f1a..85116dd 100644 --- a/tests/AcDream.Core.Tests/Chat/ChatLogTests.cs +++ b/tests/AcDream.Core.Tests/Chat/ChatLogTests.cs @@ -90,4 +90,86 @@ public sealed class ChatLogTests log.Clear(); Assert.Equal(0, log.Count); } + + // ── Phase I.5: emote / soul-emote / killed / weenie-error adapters ── + + [Fact] + public void OnEmote_AppendsEmoteEntry() + { + var log = new ChatLog(); + log.OnEmote("Caith", "waves at you", 0xCAFE); + var e = log.Snapshot()[0]; + Assert.Equal(ChatKind.Emote, e.Kind); + Assert.Equal("Caith", e.Sender); + Assert.Equal("waves at you", e.Text); + Assert.Equal(0xCAFEu, e.SenderGuid); + } + + [Fact] + public void OnSoulEmote_AppendsSoulEmoteEntry() + { + var log = new ChatLog(); + log.OnSoulEmote("Bob", "dances", 0xBEEF); + var e = log.Snapshot()[0]; + Assert.Equal(ChatKind.SoulEmote, e.Kind); + Assert.Equal("Bob", e.Sender); + Assert.Equal("dances", e.Text); + Assert.Equal(0xBEEFu, e.SenderGuid); + } + + [Fact] + public void OnPlayerKilled_AppendsSystemEntry_StoresGuids() + { + var log = new ChatLog(); + log.OnPlayerKilled("Caith was killed by a Drudge.", + victimGuid: 0x12345678u, killerGuid: 0x90ABCDEFu); + var e = log.Snapshot()[0]; + Assert.Equal(ChatKind.System, e.Kind); + Assert.Equal("Caith was killed by a Drudge.", e.Text); + Assert.Equal(0x12345678u, e.SenderGuid); + Assert.Equal(0x90ABCDEFu, e.ChannelId); // killer guid stashed here + } + + [Fact] + public void OnWeenieError_PlainCode_AppendsSystemEntry() + { + var log = new ChatLog(); + log.OnWeenieError(errorId: 0x1234, param: null); + var e = log.Snapshot()[0]; + Assert.Equal(ChatKind.System, e.Kind); + Assert.Contains("0x1234", e.Text); + Assert.Equal(0x1234u, e.ChannelId); + } + + [Fact] + public void OnWeenieError_WithString_AppendsInterpolation() + { + var log = new ChatLog(); + log.OnWeenieError(errorId: 0x5678, param: "Mana Stone"); + var e = log.Snapshot()[0]; + Assert.Equal(ChatKind.System, e.Kind); + Assert.Contains("Mana Stone", e.Text); + } + + [Fact] + public void OnLocalSpeech_EmptySender_SubstitutesYou() + { + // Holtburger client/messages.rs lines 476-487 — empty sender + // means the player is the speaker (echo back of their own + // ranged shout). Substitute "You" so the chat line reads + // "You: hello" instead of ": hello". + var log = new ChatLog(); + log.OnLocalSpeech(sender: "", text: "hello", senderGuid: 0, isRanged: false); + var e = log.Snapshot()[0]; + Assert.Equal("You", e.Sender); + Assert.Equal("hello", e.Text); + } + + [Fact] + public void OnLocalSpeech_NonEmptySender_KeepsAsIs() + { + var log = new ChatLog(); + log.OnLocalSpeech(sender: "Alice", text: "hi", senderGuid: 0xAA, isRanged: false); + Assert.Equal("Alice", log.Snapshot()[0].Sender); + } }