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

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