feat(net): Phase F.1 GameEvent (0xF7B0) envelope dispatcher

Implements the inbound GameEvent routing layer — the single biggest
network-protocol gap per r08 (94 sub-opcodes, zero handled before).
WorldSession now detects 0xF7B0, parses the 16-byte header (guid +
gameEventSequence + eventType), and forwards to a pluggable
GameEventDispatcher.

Added:
- GameEventEnvelope record + TryParse with layout from
  ACE GameEventMessage.cs.
- GameEventType enum: all 94 S→C sub-opcodes from
  ACE.Server.Network.GameEvent.GameEventType, named per ACE conventions.
- GameEventDispatcher: handler registry + unhandled-counts bag for
  diagnostics ("which server events are firing that we don't parse?").
  Handlers invoked synchronously on the decode thread; thrown exceptions
  are swallowed + logged to stderr so one bad handler can't take down
  the packet loop.
- GameEvents parsers: ChannelBroadcast, Tell, TransientMessage,
  PopupString, WeenieError (+ WithString), UpdateHealth, PingResponse,
  MagicUpdateSpell. Each returns a typed record or null on malformed
  payload. String16L helper matches the existing CharacterList pattern
  (u16 length + ASCII bytes + 4-byte pad).
- WorldSession.GameEvents property exposing the dispatcher so
  GameWindow / UI / chat can register handlers at startup.

Wired into WorldSession.ProcessDatagram: new `else if (op ==
GameEventEnvelope.Opcode)` branch with TryParse + Dispatch.

Tests (13 new):
- Envelope: valid round-trip, wrong outer opcode, too-short body.
- Dispatcher: handler invoked, unhandled count, exception isolation,
  unregister + rollover to unhandled.
- Event parsers: ChannelBroadcast, Tell, UpdateHealth, WeenieError,
  Transient, MagicUpdateSpell.

Total: 521 tests pass (up from 508).

With this dispatcher in place, Phase F.2 (items + appraise), F.3 (combat
+ damage), F.4 (spell cast state machine), chat UI, allegiance, quest
tracker — all of which depend on GameEvent handling — are unblocked.

Ref: r08 §4 (GameEvent sub-opcode table), §2 (envelope wire shape).
Ref: ACE GameEventMessage.cs / GameEventType.cs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-18 16:52:46 +02:00
parent d3165f99d7
commit d86fd08011
6 changed files with 656 additions and 0 deletions

View file

@ -0,0 +1,90 @@
using System;
using System.Collections.Generic;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Central router for inbound <c>0xF7B0</c> GameEvent envelopes.
///
/// <para>
/// Each handled <see cref="GameEventType"/> gets a registered delegate.
/// Unhandled types are counted for diagnostics so it's easy to see which
/// events the server is actually emitting vs which ones we theoretically
/// support — a useful number when iterating on chat / inventory / combat.
/// </para>
///
/// <para>
/// Handlers are invoked synchronously on the thread that called
/// <see cref="Dispatch"/> — normally the render thread since
/// <see cref="WorldSession"/>'s decode path runs there in the current
/// architecture. Handlers must not block.
/// </para>
/// </summary>
public sealed class GameEventDispatcher
{
public delegate void EventHandler(GameEventEnvelope envelope);
private readonly Dictionary<GameEventType, EventHandler> _handlers = new();
private readonly Dictionary<GameEventType, int> _unhandledCounts = new();
/// <summary>
/// Register a handler for a GameEvent sub-opcode. Replaces any
/// existing handler for that opcode.
/// </summary>
public void Register(GameEventType type, EventHandler handler)
{
ArgumentNullException.ThrowIfNull(handler);
_handlers[type] = handler;
}
/// <summary>
/// Remove the registered handler for a sub-opcode.
/// </summary>
public void Unregister(GameEventType type) => _handlers.Remove(type);
/// <summary>
/// Route an envelope to its handler, or log as unhandled. Exceptions
/// inside handlers are swallowed to keep the decode loop alive — a
/// malformed event from the server should not crash the client.
/// </summary>
public void Dispatch(GameEventEnvelope envelope)
{
if (_handlers.TryGetValue(envelope.EventType, out var handler))
{
try
{
handler(envelope);
}
catch (Exception ex)
{
// The decode thread must survive handler failures. Log via
// Console so it surfaces in live-play logs without needing
// a Serilog sink here.
Console.Error.WriteLine(
$"[GameEvent] handler for 0x{(uint)envelope.EventType:X4} threw: {ex.Message}");
}
}
else
{
_unhandledCounts.TryGetValue(envelope.EventType, out int n);
_unhandledCounts[envelope.EventType] = n + 1;
}
}
/// <summary>Number of events of the given type we've seen with no handler.</summary>
public int GetUnhandledCount(GameEventType type) =>
_unhandledCounts.TryGetValue(type, out var n) ? n : 0;
/// <summary>
/// Snapshot of every event type we've seen without a handler, keyed
/// by type → count. Useful for "which server events are firing that
/// we don't parse?" diagnostic overlays.
/// </summary>
public IReadOnlyDictionary<GameEventType, int> UnhandledCounts => _unhandledCounts;
/// <summary>Reset the unhandled-counts bag (e.g. after a log-off).</summary>
public void ResetUnhandledCounts() => _unhandledCounts.Clear();
/// <summary>How many distinct sub-opcodes have a handler registered.</summary>
public int RegisteredHandlerCount => _handlers.Count;
}

View file

@ -0,0 +1,57 @@
using System;
using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Parsed header of a retail AC <c>0xF7B0</c> GameEvent envelope (S→C).
///
/// <para>
/// Wire layout
/// (references/ACE/Source/ACE.Server/Network/GameEvent/GameEventMessage.cs):
/// <code>
/// u32 0xF7B0 // GameMessage opcode (GameEvent envelope)
/// u32 guid // session's player guid (0 if none yet)
/// u32 gameEventSequence // per-session, incremented by server
/// u32 eventType // GameEventType sub-opcode (94 values)
/// &lt;payload&gt; // variable; type-dispatched
/// </code>
/// </para>
///
/// <para>
/// Payload is returned as a <see cref="ReadOnlyMemory{T}"/> view into the
/// original message body — no copy. Per-event parsers slice from this.
/// </para>
/// </summary>
public readonly record struct GameEventEnvelope(
uint PlayerGuid,
uint Sequence,
GameEventType EventType,
ReadOnlyMemory<byte> Payload)
{
/// <summary>GameMessage opcode of the outer envelope.</summary>
public const uint Opcode = 0xF7B0u;
/// <summary>Header size (4×u32 = 16 bytes before payload).</summary>
public const int HeaderSize = 16;
/// <summary>
/// Parse a raw 0xF7B0 GameMessage body into the header + payload view.
/// Returns null if the body is shorter than the header or the outer
/// opcode doesn't match.
/// </summary>
public static GameEventEnvelope? TryParse(byte[] body)
{
if (body is null || body.Length < HeaderSize) return null;
uint outer = BinaryPrimitives.ReadUInt32LittleEndian(body);
if (outer != Opcode) return null;
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4));
uint sequence = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8));
uint eventType = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12));
var payload = body.AsMemory(HeaderSize);
return new GameEventEnvelope(guid, sequence, (GameEventType)eventType, payload);
}
}

View file

@ -0,0 +1,119 @@
namespace AcDream.Core.Net.Messages;
/// <summary>
/// All 94 <c>GameEventType</c> sub-opcodes delivered inside the
/// <c>0xF7B0</c> envelope (S→C). Source of truth:
/// <c>references/ACE/Source/ACE.Server/Network/GameEvent/GameEventType.cs</c>
/// + r08 protocol atlas §4.
///
/// <para>
/// Only values used by the retail client are enumerated; values the
/// server can technically emit but retail ignores are omitted.
/// </para>
/// </summary>
public enum GameEventType : uint
{
AllegianceUpdateAborted = 0x0003,
PopupString = 0x0004,
PlayerDescription = 0x0013,
AllegianceUpdate = 0x0020,
FriendsListUpdate = 0x0021,
InventoryPutObjInContainer = 0x0022,
WieldObject = 0x0023,
CharacterTitle = 0x0029,
UpdateTitle = 0x002B,
CloseGroundContainer = 0x0052,
ApproachVendor = 0x0062,
StartBarber = 0x0075,
InventoryServerSaveFailed = 0x00A0,
FellowshipQuit = 0x00A3,
FellowshipDismiss = 0x00A4,
BookDataResponse = 0x00B4,
BookModifyPageResponse = 0x00B5,
BookAddPageResponse = 0x00B6,
BookDeletePageResponse = 0x00B7,
BookPageDataResponse = 0x00B8,
GetInscriptionResponse = 0x00C3,
IdentifyObjectResponse = 0x00C9,
ChannelBroadcast = 0x0147,
ChannelList = 0x0148,
ChannelIndex = 0x0149,
ViewContents = 0x0196,
InventoryPutObjectIn3D = 0x019A,
AttackDone = 0x01A7,
MagicRemoveSpell = 0x01A8,
VictimNotification = 0x01AC,
KillerNotification = 0x01AD,
AttackerNotification = 0x01B1,
DefenderNotification = 0x01B2,
EvasionAttackerNotification = 0x01B3,
EvasionDefenderNotification = 0x01B4,
CombatCommenceAttack = 0x01B8,
UpdateHealth = 0x01C0,
QueryAgeResponse = 0x01C3,
UseDone = 0x01C7,
AllegianceUpdateDone = 0x01C8,
FellowshipFellowUpdateDone = 0x01C9,
FellowshipFellowStatsDone = 0x01CA,
ItemAppraiseDone = 0x01CB,
Emote = 0x01E2,
PingResponse = 0x01EA,
SetSquelchDB = 0x01F4,
RegisterTrade = 0x01FD,
OpenTrade = 0x01FE,
CloseTrade = 0x01FF,
AddToTrade = 0x0200,
RemoveFromTrade = 0x0201,
AcceptTrade = 0x0202,
DeclineTrade = 0x0203,
ResetTrade = 0x0205,
TradeFailure = 0x0207,
ClearTradeAcceptance = 0x0208,
HouseProfile = 0x021D,
HouseData = 0x0225,
HouseStatus = 0x0226,
UpdateRentTime = 0x0227,
UpdateRentPayment = 0x0228,
HouseUpdateRestrictions = 0x0248,
UpdateHAR = 0x0257,
HouseTransaction = 0x0259,
QueryItemManaResponse = 0x0264,
AvailableHouses = 0x0271,
CharacterConfirmationRequest = 0x0274,
CharacterConfirmationDone = 0x0276,
AllegianceLoginNotification = 0x027A,
AllegianceInfoResponse = 0x027C,
JoinGameResponse = 0x0281,
StartGame = 0x0282,
MoveResponse = 0x0283,
OpponentTurn = 0x0284,
OpponentStalemate = 0x0285,
WeenieError = 0x028A,
WeenieErrorWithString = 0x028B,
GameOver = 0x028C,
SetTurbineChatChannels = 0x0295,
AdminQueryPluginList = 0x02AE,
AdminQueryPlugin = 0x02B1,
AdminQueryPluginResponse = 0x02B3,
SalvageOperationsResult = 0x02B4,
Tell = 0x02BD,
FellowshipFullUpdate = 0x02BE,
FellowshipDisband = 0x02BF,
FellowshipUpdateFellow = 0x02C0,
MagicUpdateSpell = 0x02C1,
MagicUpdateEnchantment = 0x02C2,
MagicRemoveEnchantment = 0x02C3,
MagicUpdateMultipleEnchantments = 0x02C4,
MagicRemoveMultipleEnchantments = 0x02C5,
MagicPurgeEnchantments = 0x02C6,
MagicDispelEnchantment = 0x02C7,
MagicDispelMultipleEnchantments = 0x02C8,
PortalStormBrewing = 0x02C9,
PortalStormImminent = 0x02CA,
PortalStorm = 0x02CB,
PortalStormSubsided = 0x02CC,
CommunicationTransientString = 0x02EB,
MagicPurgeBadEnchantments = 0x0312,
SendClientContractTrackerTable = 0x0314,
SendClientContractTracker = 0x0315,
}

View file

@ -0,0 +1,163 @@
using System;
using System.Buffers.Binary;
using System.Text;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Parser + record types for the most-used <see cref="GameEventType"/>
/// sub-opcodes inside the <c>0xF7B0</c> envelope. Each parser takes the
/// <see cref="GameEventEnvelope.Payload"/> slice (header stripped) and
/// returns a typed record or null on malformed payload.
///
/// <para>
/// References: r08 protocol atlas §4 (wire specs) + ACE
/// <c>GameEventChat.cs</c>, <c>GameEventTell.cs</c>,
/// <c>GameEventUpdateHealth.cs</c>, <c>GameEventWeenieError.cs</c>,
/// <c>GameEventCommunicationTransientString.cs</c>.
/// </para>
/// </summary>
public static class GameEvents
{
// ── Chat / communication ─────────────────────────────────────────────────
/// <summary>0x0147 ChannelBroadcast payload.</summary>
public readonly record struct ChannelBroadcast(
uint ChannelId,
string SenderName,
string Message);
public static ChannelBroadcast? ParseChannelBroadcast(ReadOnlySpan<byte> payload)
{
int pos = 0;
if (payload.Length < 4) return null;
uint channelId = BinaryPrimitives.ReadUInt32LittleEndian(payload);
pos += 4;
try
{
string sender = ReadString16L(payload, ref pos);
string message = ReadString16L(payload, ref pos);
return new ChannelBroadcast(channelId, sender, message);
}
catch { return null; }
}
/// <summary>0x02BD Tell payload.</summary>
public readonly record struct Tell(
string Message,
string SenderName,
uint SenderGuid,
uint TargetGuid,
uint ChatType);
public static Tell? ParseTell(ReadOnlySpan<byte> payload)
{
int pos = 0;
try
{
string message = ReadString16L(payload, ref pos);
string sender = ReadString16L(payload, ref pos);
if (payload.Length - pos < 12) return null;
uint senderGuid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint targetGuid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint chatType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
return new Tell(message, sender, senderGuid, targetGuid, chatType);
}
catch { return null; }
}
/// <summary>0x02EB CommunicationTransientString payload.</summary>
public readonly record struct TransientMessage(string Message, uint ChatType);
public static TransientMessage? ParseTransient(ReadOnlySpan<byte> payload)
{
int pos = 0;
try
{
string message = ReadString16L(payload, ref pos);
if (payload.Length - pos < 4) return null;
uint chatType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos));
return new TransientMessage(message, chatType);
}
catch { return null; }
}
/// <summary>0x0004 PopupString — modal dialog text.</summary>
public static string? ParsePopupString(ReadOnlySpan<byte> payload)
{
int pos = 0;
try { return ReadString16L(payload, ref pos); } catch { return null; }
}
// ── Errors ──────────────────────────────────────────────────────────────
/// <summary>0x028A WeenieError: generic game-logic failure code.</summary>
public static uint? ParseWeenieError(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>0x028B WeenieErrorWithString.</summary>
public readonly record struct WeenieErrorWithString(uint ErrorCode, string Interpolation);
public static WeenieErrorWithString? ParseWeenieErrorWithString(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
uint code = BinaryPrimitives.ReadUInt32LittleEndian(payload);
int pos = 4;
try
{
string interp = ReadString16L(payload, ref pos);
return new WeenieErrorWithString(code, interp);
}
catch { return null; }
}
// ── Vitals / combat ─────────────────────────────────────────────────────
/// <summary>0x01C0 UpdateHealth: (guid, healthPercent 0..1).</summary>
public readonly record struct UpdateHealth(uint TargetGuid, float HealthPercent);
public static UpdateHealth? ParseUpdateHealth(ReadOnlySpan<byte> payload)
{
if (payload.Length < 8) return null;
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload);
float pct = BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(4));
return new UpdateHealth(guid, pct);
}
// ── Pings / misc ────────────────────────────────────────────────────────
/// <summary>0x01EA PingResponse: echoes the client ping id.</summary>
public static uint? ParsePingResponse(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
// ── Spells / magic ──────────────────────────────────────────────────────
/// <summary>0x02C1 MagicUpdateSpell: spell id added to spellbook.</summary>
public static uint? ParseMagicUpdateSpell(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
// ── Shared string reader (matches LoginRequest.ReadString16L) ───────────
private 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 = Encoding.ASCII.GetString(source.Slice(pos, length));
pos += length;
int recordSize = 2 + length;
int padding = (4 - (recordSize & 3)) & 3;
pos += padding;
return result;
}
}

View file

@ -115,6 +115,14 @@ public sealed class WorldSession : IDisposable
/// <summary>Raised every time the state machine transitions.</summary>
public event Action<State>? StateChanged;
/// <summary>
/// Phase F.1: inbound 0xF7B0 GameEvent dispatcher. Each sub-opcode
/// handler is registered here (by GameWindow / UI layer / chat
/// system) and routed on each incoming GameEvent. Unhandled
/// sub-opcodes are counted for diagnostic overlays.
/// </summary>
public GameEventDispatcher GameEvents { get; } = new();
public State CurrentState { get; private set; } = State.Disconnected;
/// <summary>Movement sequence counters for outbound MoveToState/AutonomousPosition.</summary>
@ -479,6 +487,15 @@ public sealed class WorldSession : IDisposable
posUpdate.Value.Velocity));
}
}
else if (op == GameEventEnvelope.Opcode)
{
// Phase F.1: 0xF7B0 is the GameEvent envelope. Parse the
// header (guid + sequence + eventType) and dispatch to the
// registered handler for that sub-opcode. Unregistered
// types get counted for diagnostic overlays.
var env = GameEventEnvelope.TryParse(body);
if (env is not null) GameEvents.Dispatch(env.Value);
}
else if (op == 0xF751u) // PlayerTeleport — server is moving us through a portal
{
// Phase B.3: holtburger opcodes.rs confirms 0xF751 is the

View file

@ -0,0 +1,210 @@
using System;
using System.Buffers.Binary;
using System.Text;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
public sealed class GameEventDispatcherTests
{
// Helper: build a 0xF7B0 envelope with given sub-opcode + payload bytes.
private static byte[] MakeEnvelope(GameEventType type, ReadOnlySpan<byte> payload,
uint playerGuid = 0x12345678u, uint sequence = 0)
{
byte[] body = new byte[GameEventEnvelope.HeaderSize + payload.Length];
var span = body.AsSpan();
BinaryPrimitives.WriteUInt32LittleEndian(span, GameEventEnvelope.Opcode);
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(4), playerGuid);
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(8), sequence);
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(12), (uint)type);
payload.CopyTo(span.Slice(GameEventEnvelope.HeaderSize));
return body;
}
[Fact]
public void TryParse_ValidEnvelope_ReturnsFields()
{
byte[] body = MakeEnvelope(GameEventType.PlayerDescription,
payload: new byte[8],
playerGuid: 0xAABBCCDD, sequence: 42);
var env = GameEventEnvelope.TryParse(body);
Assert.NotNull(env);
Assert.Equal(0xAABBCCDDu, env!.Value.PlayerGuid);
Assert.Equal(42u, env.Value.Sequence);
Assert.Equal(GameEventType.PlayerDescription, env.Value.EventType);
Assert.Equal(8, env.Value.Payload.Length);
}
[Fact]
public void TryParse_WrongOuterOpcode_ReturnsNull()
{
byte[] body = new byte[16];
BinaryPrimitives.WriteUInt32LittleEndian(body, 0xDEADBEEFu);
Assert.Null(GameEventEnvelope.TryParse(body));
}
[Fact]
public void TryParse_TooShort_ReturnsNull()
{
Assert.Null(GameEventEnvelope.TryParse(new byte[10]));
}
[Fact]
public void Dispatch_CallsHandler_ForRegisteredType()
{
var dispatcher = new GameEventDispatcher();
GameEventEnvelope received = default;
int calls = 0;
dispatcher.Register(GameEventType.Tell, env => { received = env; calls++; });
var body = MakeEnvelope(GameEventType.Tell, new byte[4]);
var env = GameEventEnvelope.TryParse(body);
dispatcher.Dispatch(env!.Value);
Assert.Equal(1, calls);
Assert.Equal(GameEventType.Tell, received.EventType);
}
[Fact]
public void Dispatch_Unhandled_CountedButNoThrow()
{
var dispatcher = new GameEventDispatcher();
var body = MakeEnvelope(GameEventType.HouseProfile, Array.Empty<byte>());
var env = GameEventEnvelope.TryParse(body);
dispatcher.Dispatch(env!.Value);
dispatcher.Dispatch(env.Value);
Assert.Equal(2, dispatcher.GetUnhandledCount(GameEventType.HouseProfile));
}
[Fact]
public void Dispatch_HandlerThrows_DoesNotPropagate()
{
var dispatcher = new GameEventDispatcher();
dispatcher.Register(GameEventType.Tell, _ => throw new InvalidOperationException("bad handler"));
var body = MakeEnvelope(GameEventType.Tell, Array.Empty<byte>());
// Should not throw; error is logged instead.
dispatcher.Dispatch(GameEventEnvelope.TryParse(body)!.Value);
}
[Fact]
public void Unregister_RemovesHandler_AndMovesToUnhandled()
{
var dispatcher = new GameEventDispatcher();
int calls = 0;
dispatcher.Register(GameEventType.Tell, _ => calls++);
dispatcher.Unregister(GameEventType.Tell);
var body = MakeEnvelope(GameEventType.Tell, Array.Empty<byte>());
dispatcher.Dispatch(GameEventEnvelope.TryParse(body)!.Value);
Assert.Equal(0, calls);
Assert.Equal(1, dispatcher.GetUnhandledCount(GameEventType.Tell));
}
// ── Per-event parser tests ───────────────────────────────────────────────
private static byte[] MakeString16L(string s)
{
byte[] data = Encoding.ASCII.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;
}
[Fact]
public void ParseChannelBroadcast_RoundTrip()
{
// u32 channelId + string16L sender + string16L message
byte[] chan = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(chan, 42);
byte[] sender = MakeString16L("Hero");
byte[] msg = MakeString16L("hello allegiance");
byte[] payload = new byte[chan.Length + sender.Length + msg.Length];
Buffer.BlockCopy(chan, 0, payload, 0, chan.Length);
Buffer.BlockCopy(sender, 0, payload, chan.Length, sender.Length);
Buffer.BlockCopy(msg, 0, payload, chan.Length + sender.Length, msg.Length);
var parsed = GameEvents.ParseChannelBroadcast(payload);
Assert.NotNull(parsed);
Assert.Equal(42u, parsed!.Value.ChannelId);
Assert.Equal("Hero", parsed.Value.SenderName);
Assert.Equal("hello allegiance", parsed.Value.Message);
}
[Fact]
public void ParseTell_RoundTrip()
{
byte[] msg = MakeString16L("hi");
byte[] sender = MakeString16L("Alice");
byte[] tail = new byte[12]; // u32 senderGuid + u32 targetGuid + u32 chatType
BinaryPrimitives.WriteUInt32LittleEndian(tail, 0xAAu);
BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(4), 0xBBu);
BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(8), 1u);
byte[] payload = new byte[msg.Length + sender.Length + tail.Length];
Buffer.BlockCopy(msg, 0, payload, 0, msg.Length);
Buffer.BlockCopy(sender, 0, payload, msg.Length, sender.Length);
Buffer.BlockCopy(tail, 0, payload, msg.Length + sender.Length, tail.Length);
var parsed = GameEvents.ParseTell(payload);
Assert.NotNull(parsed);
Assert.Equal("hi", parsed!.Value.Message);
Assert.Equal("Alice", parsed.Value.SenderName);
Assert.Equal(0xAAu, parsed.Value.SenderGuid);
Assert.Equal(0xBBu, parsed.Value.TargetGuid);
Assert.Equal(1u, parsed.Value.ChatType);
}
[Fact]
public void ParseUpdateHealth_RoundTrip()
{
byte[] payload = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0xC0DEu);
BinaryPrimitives.WriteSingleLittleEndian(payload.AsSpan(4), 0.42f);
var parsed = GameEvents.ParseUpdateHealth(payload);
Assert.NotNull(parsed);
Assert.Equal(0xC0DEu, parsed!.Value.TargetGuid);
Assert.Equal(0.42f, parsed.Value.HealthPercent, 4);
}
[Fact]
public void ParseWeenieError_RoundTrip()
{
byte[] payload = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 42u);
Assert.Equal(42u, GameEvents.ParseWeenieError(payload));
}
[Fact]
public void ParseTransient_RoundTrip()
{
byte[] msg = MakeString16L("Your spell fizzled!");
byte[] chatType = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(chatType, 5u);
byte[] payload = new byte[msg.Length + 4];
Buffer.BlockCopy(msg, 0, payload, 0, msg.Length);
Buffer.BlockCopy(chatType, 0, payload, msg.Length, 4);
var parsed = GameEvents.ParseTransient(payload);
Assert.NotNull(parsed);
Assert.Equal("Your spell fizzled!", parsed!.Value.Message);
Assert.Equal(5u, parsed.Value.ChatType);
}
[Fact]
public void ParseMagicUpdateSpell_RoundTrip()
{
byte[] payload = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x3E1u); // Flame Bolt I
Assert.Equal(0x3E1u, GameEvents.ParseMagicUpdateSpell(payload));
}
}