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);
+ }
}