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:
parent
b131514d51
commit
ff5ed9ec0b
25 changed files with 899 additions and 10 deletions
|
|
@ -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 =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
55
src/AcDream.Core.Net/Messages/EmoteText.cs
Normal file
55
src/AcDream.Core.Net/Messages/EmoteText.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
51
src/AcDream.Core.Net/Messages/Encodings.cs
Normal file
51
src/AcDream.Core.Net/Messages/Encodings.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
51
src/AcDream.Core.Net/Messages/PlayerKilled.cs
Normal file
51
src/AcDream.Core.Net/Messages/PlayerKilled.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
51
src/AcDream.Core.Net/Messages/ServerMessage.cs
Normal file
51
src/AcDream.Core.Net/Messages/ServerMessage.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
51
src/AcDream.Core.Net/Messages/SoulEmote.cs
Normal file
51
src/AcDream.Core.Net/Messages/SoulEmote.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
46
src/AcDream.Core.Net/Messages/StringReader.cs
Normal file
46
src/AcDream.Core.Net/Messages/StringReader.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue