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);
|
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 ────────────────────────────────────────────────
|
// ── Combat ────────────────────────────────────────────────
|
||||||
dispatcher.Register(GameEventType.UpdateHealth, e =>
|
dispatcher.Register(GameEventType.UpdateHealth, e =>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -429,7 +429,8 @@ public static class AppraiseInfoParser
|
||||||
ushort len = BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos));
|
ushort len = BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos));
|
||||||
pos += 2;
|
pos += 2;
|
||||||
if (src.Length - pos < len) throw new FormatException("truncated string body");
|
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;
|
pos += len;
|
||||||
int record = 2 + len;
|
int record = 2 + len;
|
||||||
int pad = (4 - (record & 3)) & 3;
|
int pad = (4 - (record & 3)) & 3;
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,8 @@ public static class CharacterList
|
||||||
ushort length = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(pos));
|
ushort length = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(pos));
|
||||||
pos += 2;
|
pos += 2;
|
||||||
if (source.Length - pos < length) throw new FormatException("truncated String16L body");
|
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;
|
pos += length;
|
||||||
int recordSize = 2 + length;
|
int recordSize = 2 + length;
|
||||||
int padding = (4 - (recordSize & 3)) & 3;
|
int padding = (4 - (recordSize & 3)) & 3;
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,8 @@ public static class ChatRequests
|
||||||
|
|
||||||
private static byte[] PackString16L(string s)
|
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)
|
if (data.Length > ushort.MaxValue)
|
||||||
throw new ArgumentException("String too long for 16-bit length prefix.", nameof(s));
|
throw new ArgumentException("String too long for 16-bit length prefix.", nameof(s));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -433,7 +433,8 @@ public static class CreateObject
|
||||||
pos += 2;
|
pos += 2;
|
||||||
if (length > 1024) throw new FormatException($"String16L length {length} exceeds sanity limit");
|
if (length > 1024) throw new FormatException($"String16L length {length} exceeds sanity limit");
|
||||||
if (source.Length - pos < length) throw new FormatException("truncated String16L body");
|
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;
|
pos += length;
|
||||||
int recordSize = 2 + length;
|
int recordSize = 2 + length;
|
||||||
int padding = (4 - (recordSize & 3)) & 3;
|
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));
|
ushort length = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(pos));
|
||||||
pos += 2;
|
pos += 2;
|
||||||
if (source.Length - pos < length) throw new FormatException("truncated String16L body");
|
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;
|
pos += length;
|
||||||
int recordSize = 2 + length;
|
int recordSize = 2 + length;
|
||||||
int padding = (4 - (recordSize & 3)) & 3;
|
int padding = (4 - (recordSize & 3)) & 3;
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,10 @@ public static class HearSpeech
|
||||||
ushort length = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(pos));
|
ushort length = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(pos));
|
||||||
pos += 2;
|
pos += 2;
|
||||||
if (source.Length - pos < length) throw new FormatException("truncated String16L body");
|
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;
|
pos += length;
|
||||||
int recordSize = 2 + length;
|
int recordSize = 2 + length;
|
||||||
int padding = (4 - (recordSize & 3)) & 3;
|
int padding = (4 - (recordSize & 3)) & 3;
|
||||||
|
|
|
||||||
|
|
@ -590,7 +590,8 @@ public static class PlayerDescriptionParser
|
||||||
ushort len = BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos));
|
ushort len = BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos));
|
||||||
pos += 2;
|
pos += 2;
|
||||||
if (src.Length - pos < len) throw new FormatException("truncated string body");
|
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;
|
pos += len;
|
||||||
// String16L records pad to 4-byte alignment per AppraiseInfoParser convention.
|
// String16L records pad to 4-byte alignment per AppraiseInfoParser convention.
|
||||||
int record = 2 + len;
|
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)
|
private static byte[] PackString16L(string s)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(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)
|
if (data.Length > ushort.MaxValue)
|
||||||
throw new ArgumentException("String too long for 16-bit length prefix.", nameof(s));
|
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>
|
/// </summary>
|
||||||
public event Action<HearSpeech.Parsed>? SpeechHeard;
|
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>
|
/// <summary>
|
||||||
/// Issue #5: fires when a <c>PrivateUpdateVital (0x02E7)</c> arrives
|
/// Issue #5: fires when a <c>PrivateUpdateVital (0x02E7)</c> arrives
|
||||||
/// — full per-vital snapshot (ranks / start / xp / current).
|
/// — full per-vital snapshot (ranks / start / xp / current).
|
||||||
|
|
@ -615,6 +647,47 @@ public sealed class WorldSession : IDisposable
|
||||||
if (parsed is not null)
|
if (parsed is not null)
|
||||||
SpeechHeard?.Invoke(parsed.Value);
|
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)
|
else if (op == PrivateUpdateVital.FullOpcode)
|
||||||
{
|
{
|
||||||
// Issue #5: full per-vital snapshot from the server. Wire
|
// Issue #5: full per-vital snapshot from the server. Wire
|
||||||
|
|
|
||||||
|
|
@ -42,16 +42,86 @@ public sealed class ChatLog
|
||||||
// ── Inbound adapters ─────────────────────────────────────────────────────
|
// ── Inbound adapters ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>Local or ranged HearSpeech (0x02BB / 0x02BC).</summary>
|
/// <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)
|
public void OnLocalSpeech(string sender, string text, uint senderGuid, bool isRanged)
|
||||||
{
|
{
|
||||||
|
string effectiveSender = string.IsNullOrEmpty(sender) ? "You" : sender;
|
||||||
Append(new ChatEntry(
|
Append(new ChatEntry(
|
||||||
Kind: isRanged ? ChatKind.RangedSpeech : ChatKind.LocalSpeech,
|
Kind: isRanged ? ChatKind.RangedSpeech : ChatKind.LocalSpeech,
|
||||||
Sender: sender,
|
Sender: effectiveSender,
|
||||||
Text: text,
|
Text: text,
|
||||||
SenderGuid: senderGuid,
|
SenderGuid: senderGuid,
|
||||||
ChannelId: 0));
|
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>
|
/// <summary>GameEvent ChannelBroadcast (0x0147).</summary>
|
||||||
public void OnChannelBroadcast(uint channelId, string sender, string text)
|
public void OnChannelBroadcast(uint channelId, string sender, string text)
|
||||||
{
|
{
|
||||||
|
|
@ -129,6 +199,8 @@ public enum ChatKind
|
||||||
Tell,
|
Tell,
|
||||||
System,
|
System,
|
||||||
Popup,
|
Popup,
|
||||||
|
Emote,
|
||||||
|
SoulEmote,
|
||||||
}
|
}
|
||||||
|
|
||||||
public readonly record struct ChatEntry(
|
public readonly record struct ChatEntry(
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,13 @@ public sealed class ChatVM
|
||||||
ChatKind.Tell => $"[Tell] {entry.Sender}: {entry.Text}",
|
ChatKind.Tell => $"[Tell] {entry.Sender}: {entry.Text}",
|
||||||
ChatKind.System => $"[System] {entry.Text}",
|
ChatKind.System => $"[System] {entry.Text}",
|
||||||
ChatKind.Popup => $"[Popup] {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,
|
_ => entry.Text,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -279,4 +279,49 @@ public sealed class GameEventWiringTests
|
||||||
Assert.Equal(0, book.ActiveCount);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -120,9 +120,32 @@ public sealed class ChatTests
|
||||||
Assert.Null(HearSpeech.TryParse(body));
|
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)
|
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 recordSize = 2 + data.Length;
|
||||||
int padding = (4 - (recordSize & 3)) & 3;
|
int padding = (4 - (recordSize & 3)) & 3;
|
||||||
byte[] result = new byte[recordSize + padding];
|
byte[] result = new byte[recordSize + padding];
|
||||||
|
|
|
||||||
80
tests/AcDream.Core.Net.Tests/Messages/EmoteTextTests.cs
Normal file
80
tests/AcDream.Core.Net.Tests/Messages/EmoteTextTests.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
65
tests/AcDream.Core.Net.Tests/Messages/PlayerKilledTests.cs
Normal file
65
tests/AcDream.Core.Net.Tests/Messages/PlayerKilledTests.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
58
tests/AcDream.Core.Net.Tests/Messages/ServerMessageTests.cs
Normal file
58
tests/AcDream.Core.Net.Tests/Messages/ServerMessageTests.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
53
tests/AcDream.Core.Net.Tests/Messages/SoulEmoteTests.cs
Normal file
53
tests/AcDream.Core.Net.Tests/Messages/SoulEmoteTests.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -90,4 +90,86 @@ public sealed class ChatLogTests
|
||||||
log.Clear();
|
log.Clear();
|
||||||
Assert.Equal(0, log.Count);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue