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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-25 19:06:01 +02:00
parent b131514d51
commit ff5ed9ec0b
25 changed files with 899 additions and 10 deletions

View file

@ -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 =>
{

View file

@ -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;

View file

@ -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;

View file

@ -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));

View file

@ -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;

View file

@ -0,0 +1,55 @@
using System;
using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Inbound <c>0x01E0 EmoteText</c> top-level GameMessage.
/// Server-driven third-person emote announcement (e.g.
/// "The Olthoi growls at you.").
///
/// <para>
/// This is a standalone GameMessage — NOT wrapped in the 0xF7B0
/// GameEvent envelope. Dispatched directly from the opcode switch in
/// <see cref="WorldSession.ProcessDatagram"/>.
/// </para>
///
/// <para>
/// Wire layout (port from holtburger
/// <c>references/holtburger/.../messages/chat/types.rs::EmoteTextData</c>,
/// see also opcodes.rs:155):
/// <code>
/// u32 opcode // 0x01E0
/// u32 senderGuid
/// string16L senderName
/// string16L text
/// </code>
/// </para>
/// </summary>
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; }
}
}

View file

@ -0,0 +1,51 @@
using System.Runtime.CompilerServices;
using System.Text;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Shared text encodings for the AC wire protocol.
///
/// <para>
/// 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.
/// </para>
///
/// <para>
/// .NET 6+ ships only ASCII / Latin-1 / UTF8/16/32 in the base library;
/// Windows-1252 lives in <c>System.Text.Encoding.CodePages</c>. The
/// module initializer below registers that provider on first load of
/// any type in <c>AcDream.Core.Net.Messages</c>, so call sites can use
/// <see cref="Encoding.GetEncoding(int)"/> with id <c>1252</c> directly.
/// </para>
/// </summary>
public static class Encodings
{
/// <summary>
/// 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
/// <c>CodePagesEncodingProvider</c> so
/// <see cref="Encoding.GetEncoding(int)"/> with id 1252 returns a
/// real encoding instance instead of throwing
/// <see cref="System.NotSupportedException"/>.
/// </summary>
#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
/// <summary>
/// CP1252 (Windows-1252). Cached so callers don't look it up each frame.
/// Initialized after <see cref="Register"/> via field-init ordering.
/// </summary>
public static readonly Encoding Windows1252 = Encoding.GetEncoding(1252);
}

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -0,0 +1,51 @@
using System;
using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Inbound <c>0x019E PlayerKilled</c> 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."
///
/// <para>
/// Wire layout (port from holtburger
/// <c>references/holtburger/.../messages/combat/types.rs::PlayerKilledData</c>,
/// see also opcodes.rs:150):
/// <code>
/// u32 opcode // 0x019E
/// string16L deathMessage
/// u32 victimGuid
/// u32 killerGuid
/// </code>
/// </para>
/// </summary>
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; }
}
}

View file

@ -0,0 +1,51 @@
using System;
using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Inbound <c>0xF7E0 ServerMessage</c> top-level GameMessage.
/// General-purpose server-broadcast text — admin announcements,
/// combat logs, and routine error messages routed by the server
/// instead of via WeenieError.
///
/// <para>
/// This is a standalone GameMessage — NOT wrapped in 0xF7B0
/// GameEvent envelope. Dispatched directly from
/// <see cref="WorldSession.ProcessDatagram"/>.
/// </para>
///
/// <para>
/// Wire layout (port from holtburger
/// <c>references/holtburger/.../messages/chat/types.rs::ServerMessageData</c>,
/// see also opcodes.rs:167):
/// <code>
/// u32 opcode // 0xF7E0
/// string16L message
/// u32 chatType // ChatMessageType (Broadcast / System / etc)
/// </code>
/// </para>
/// </summary>
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; }
}
}

View file

@ -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));

View file

@ -0,0 +1,51 @@
using System;
using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Inbound <c>0x01E2 SoulEmote</c> top-level GameMessage.
/// Server-driven complex emote with optional animation pairing.
/// Wire layout is identical to <see cref="EmoteText"/>; the difference
/// is only how the client renders it (chat-only vs paired with a
/// <c>PlayScript</c> on the same target).
///
/// <para>
/// Wire layout (port from holtburger
/// <c>references/holtburger/.../messages/chat/types.rs::SoulEmoteData</c>,
/// see also opcodes.rs:158):
/// <code>
/// u32 opcode // 0x01E2
/// u32 senderGuid
/// string16L senderName
/// string16L text
/// </code>
/// </para>
/// </summary>
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; }
}
}

View file

@ -0,0 +1,46 @@
using System;
using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Shared reader for the AC <c>String16L</c> wire format used by every
/// inbound and outbound chat / name / message body.
///
/// <para>
/// Wire shape:
/// <code>
/// u16 length // byte count, NOT char count
/// byte[] text // CP1252-encoded bytes, no terminator
/// pad to 4-byte boundary
/// </code>
/// </para>
///
/// <para>
/// Codec is Windows-1252 (CP1252), matching retail and holtburger's
/// <c>encoding_rs::WINDOWS_1252</c>. Registration of the
/// <c>CodePagesEncodingProvider</c> is handled by
/// <see cref="Encodings"/>.
/// </para>
/// </summary>
internal static class StringReader
{
/// <summary>
/// Read a String16L from <paramref name="source"/> at <paramref name="pos"/>,
/// advancing <paramref name="pos"/> past the length, body, and 4-byte
/// alignment padding. Throws <see cref="FormatException"/> on truncation.
/// </summary>
public static string ReadString16L(ReadOnlySpan<byte> 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;
}
}

View file

@ -109,6 +109,38 @@ public sealed class WorldSession : IDisposable
/// </summary>
public event Action<HearSpeech.Parsed>? SpeechHeard;
/// <summary>
/// Phase I.5: fires when an <c>EmoteText (0x01E0)</c> 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
/// <c>ChatLog.OnEmote</c>.
/// </summary>
public event Action<EmoteText.Parsed>? EmoteHeard;
/// <summary>
/// Phase I.5: fires when a <c>SoulEmote (0x01E2)</c> top-level
/// GameMessage is received — complex emote with optional animation
/// pairing. Wire layout matches EmoteText.
/// </summary>
public event Action<SoulEmote.Parsed>? SoulEmoteHeard;
/// <summary>
/// Phase I.5: fires when a <c>ServerMessage (0xF7E0)</c> top-level
/// GameMessage is received — general server-broadcast text used
/// for announcements, combat logs, and routine error messages.
/// Subscribers typically feed <c>ChatLog.OnSystemMessage</c>.
/// </summary>
public event Action<ServerMessage.Parsed>? ServerMessageReceived;
/// <summary>
/// Phase I.5: fires when a <c>PlayerKilled (0x019E)</c> top-level
/// GameMessage is received — server announcement that a player
/// was killed in combat. Subscribers typically feed
/// <c>ChatLog.OnPlayerKilled</c>.
/// </summary>
public event Action<PlayerKilled.Parsed>? PlayerKilledReceived;
/// <summary>
/// Issue #5: fires when a <c>PrivateUpdateVital (0x02E7)</c> 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

View file

@ -42,16 +42,86 @@ public sealed class ChatLog
// ── Inbound adapters ─────────────────────────────────────────────────────
/// <summary>Local or ranged HearSpeech (0x02BB / 0x02BC).</summary>
/// <remarks>
/// Phase I.5: an empty <paramref name="sender"/> 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
/// <c>references/holtburger/.../client/messages.rs</c> lines 476-487.
/// </remarks>
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));
}
/// <summary>EmoteText (0x01E0) — server-driven third-person emote.</summary>
public void OnEmote(string senderName, string text, uint senderGuid)
{
Append(new ChatEntry(
Kind: ChatKind.Emote,
Sender: senderName,
Text: text,
SenderGuid: senderGuid,
ChannelId: 0));
}
/// <summary>SoulEmote (0x01E2) — complex emote (chat + paired animation).</summary>
public void OnSoulEmote(string senderName, string text, uint senderGuid)
{
Append(new ChatEntry(
Kind: ChatKind.SoulEmote,
Sender: senderName,
Text: text,
SenderGuid: senderGuid,
ChannelId: 0));
}
/// <summary>PlayerKilled (0x019E) — death announcement.</summary>
/// <remarks>
/// Death messages are routed as <see cref="ChatKind.System"/> so they
/// share styling with other server announcements. The
/// <c>SenderGuid</c> field carries the victim guid; the
/// <c>ChannelId</c> field carries the killer guid (a small misuse
/// of the field but avoids a schema change).
/// </remarks>
public void OnPlayerKilled(string deathMessage, uint victimGuid, uint killerGuid)
{
Append(new ChatEntry(
Kind: ChatKind.System,
Sender: "",
Text: deathMessage,
SenderGuid: victimGuid,
ChannelId: killerGuid));
}
/// <summary>WeenieError (0x028A) / WeenieErrorWithString (0x028B).</summary>
/// <remarks>
/// 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 <see cref="ChatKind.System"/>; the
/// <c>ChannelId</c> field carries the WeenieError code so plugins can
/// filter or react. <paramref name="param"/> is the interpolated
/// substring (null for plain WeenieError, set for WeenieErrorWithString).
/// </remarks>
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));
}
/// <summary>GameEvent ChannelBroadcast (0x0147).</summary>
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(

View file

@ -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,
};
}

View file

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

View file

@ -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];

View file

@ -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;
/// <summary>
/// Phase I.5: 0x01E0 EmoteText parser. Wire layout matches holtburger's
/// EmoteTextData: u32 senderGuid + string16L senderName + string16L text.
/// </summary>
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;
}
}

View file

@ -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;
/// <summary>
/// Phase I.5: 0x019E PlayerKilled parser. Wire layout per holtburger
/// PlayerKilledData: string16L deathMessage + u32 victimGuid + u32
/// killerGuid.
/// </summary>
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;
}
}

View file

@ -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;
/// <summary>
/// Phase I.5: 0xF7E0 ServerMessage parser. Wire layout per holtburger
/// ServerMessageData: string16L message + u32 chatType.
/// </summary>
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;
}
}

View file

@ -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;
/// <summary>
/// Phase I.5: 0x01E2 SoulEmote parser. Wire layout is identical to
/// EmoteText (u32 senderGuid + string16L senderName + string16L text)
/// per holtburger SoulEmoteData.
/// </summary>
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;
}
}

View file

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