diff --git a/src/AcDream.Core.Net/Messages/GameEventDispatcher.cs b/src/AcDream.Core.Net/Messages/GameEventDispatcher.cs
new file mode 100644
index 0000000..c4eb38b
--- /dev/null
+++ b/src/AcDream.Core.Net/Messages/GameEventDispatcher.cs
@@ -0,0 +1,90 @@
+using System;
+using System.Collections.Generic;
+
+namespace AcDream.Core.Net.Messages;
+
+///
+/// Central router for inbound 0xF7B0 GameEvent envelopes.
+///
+///
+/// Each handled 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.
+///
+///
+///
+/// Handlers are invoked synchronously on the thread that called
+/// — normally the render thread since
+/// 's decode path runs there in the current
+/// architecture. Handlers must not block.
+///
+///
+public sealed class GameEventDispatcher
+{
+ public delegate void EventHandler(GameEventEnvelope envelope);
+
+ private readonly Dictionary _handlers = new();
+ private readonly Dictionary _unhandledCounts = new();
+
+ ///
+ /// Register a handler for a GameEvent sub-opcode. Replaces any
+ /// existing handler for that opcode.
+ ///
+ public void Register(GameEventType type, EventHandler handler)
+ {
+ ArgumentNullException.ThrowIfNull(handler);
+ _handlers[type] = handler;
+ }
+
+ ///
+ /// Remove the registered handler for a sub-opcode.
+ ///
+ public void Unregister(GameEventType type) => _handlers.Remove(type);
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+ }
+
+ /// Number of events of the given type we've seen with no handler.
+ public int GetUnhandledCount(GameEventType type) =>
+ _unhandledCounts.TryGetValue(type, out var n) ? n : 0;
+
+ ///
+ /// 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.
+ ///
+ public IReadOnlyDictionary UnhandledCounts => _unhandledCounts;
+
+ /// Reset the unhandled-counts bag (e.g. after a log-off).
+ public void ResetUnhandledCounts() => _unhandledCounts.Clear();
+
+ /// How many distinct sub-opcodes have a handler registered.
+ public int RegisteredHandlerCount => _handlers.Count;
+}
diff --git a/src/AcDream.Core.Net/Messages/GameEventEnvelope.cs b/src/AcDream.Core.Net/Messages/GameEventEnvelope.cs
new file mode 100644
index 0000000..9ef9f2a
--- /dev/null
+++ b/src/AcDream.Core.Net/Messages/GameEventEnvelope.cs
@@ -0,0 +1,57 @@
+using System;
+using System.Buffers.Binary;
+
+namespace AcDream.Core.Net.Messages;
+
+///
+/// Parsed header of a retail AC 0xF7B0 GameEvent envelope (S→C).
+///
+///
+/// Wire layout
+/// (references/ACE/Source/ACE.Server/Network/GameEvent/GameEventMessage.cs):
+///
+/// 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)
+/// <payload> // variable; type-dispatched
+///
+///
+///
+///
+/// Payload is returned as a view into the
+/// original message body — no copy. Per-event parsers slice from this.
+///
+///
+public readonly record struct GameEventEnvelope(
+ uint PlayerGuid,
+ uint Sequence,
+ GameEventType EventType,
+ ReadOnlyMemory Payload)
+{
+ /// GameMessage opcode of the outer envelope.
+ public const uint Opcode = 0xF7B0u;
+
+ /// Header size (4×u32 = 16 bytes before payload).
+ public const int HeaderSize = 16;
+
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+}
diff --git a/src/AcDream.Core.Net/Messages/GameEventType.cs b/src/AcDream.Core.Net/Messages/GameEventType.cs
new file mode 100644
index 0000000..21b9838
--- /dev/null
+++ b/src/AcDream.Core.Net/Messages/GameEventType.cs
@@ -0,0 +1,119 @@
+namespace AcDream.Core.Net.Messages;
+
+///
+/// All 94 GameEventType sub-opcodes delivered inside the
+/// 0xF7B0 envelope (S→C). Source of truth:
+/// references/ACE/Source/ACE.Server/Network/GameEvent/GameEventType.cs
+/// + r08 protocol atlas §4.
+///
+///
+/// Only values used by the retail client are enumerated; values the
+/// server can technically emit but retail ignores are omitted.
+///
+///
+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,
+}
diff --git a/src/AcDream.Core.Net/Messages/GameEvents.cs b/src/AcDream.Core.Net/Messages/GameEvents.cs
new file mode 100644
index 0000000..0102f7c
--- /dev/null
+++ b/src/AcDream.Core.Net/Messages/GameEvents.cs
@@ -0,0 +1,163 @@
+using System;
+using System.Buffers.Binary;
+using System.Text;
+
+namespace AcDream.Core.Net.Messages;
+
+///
+/// Parser + record types for the most-used
+/// sub-opcodes inside the 0xF7B0 envelope. Each parser takes the
+/// slice (header stripped) and
+/// returns a typed record or null on malformed payload.
+///
+///
+/// References: r08 protocol atlas §4 (wire specs) + ACE
+/// GameEventChat.cs, GameEventTell.cs,
+/// GameEventUpdateHealth.cs, GameEventWeenieError.cs,
+/// GameEventCommunicationTransientString.cs.
+///
+///
+public static class GameEvents
+{
+ // ── Chat / communication ─────────────────────────────────────────────────
+
+ /// 0x0147 ChannelBroadcast payload.
+ public readonly record struct ChannelBroadcast(
+ uint ChannelId,
+ string SenderName,
+ string Message);
+
+ public static ChannelBroadcast? ParseChannelBroadcast(ReadOnlySpan 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; }
+ }
+
+ /// 0x02BD Tell payload.
+ public readonly record struct Tell(
+ string Message,
+ string SenderName,
+ uint SenderGuid,
+ uint TargetGuid,
+ uint ChatType);
+
+ public static Tell? ParseTell(ReadOnlySpan 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; }
+ }
+
+ /// 0x02EB CommunicationTransientString payload.
+ public readonly record struct TransientMessage(string Message, uint ChatType);
+
+ public static TransientMessage? ParseTransient(ReadOnlySpan 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; }
+ }
+
+ /// 0x0004 PopupString — modal dialog text.
+ public static string? ParsePopupString(ReadOnlySpan payload)
+ {
+ int pos = 0;
+ try { return ReadString16L(payload, ref pos); } catch { return null; }
+ }
+
+ // ── Errors ──────────────────────────────────────────────────────────────
+
+ /// 0x028A WeenieError: generic game-logic failure code.
+ public static uint? ParseWeenieError(ReadOnlySpan payload)
+ {
+ if (payload.Length < 4) return null;
+ return BinaryPrimitives.ReadUInt32LittleEndian(payload);
+ }
+
+ /// 0x028B WeenieErrorWithString.
+ public readonly record struct WeenieErrorWithString(uint ErrorCode, string Interpolation);
+
+ public static WeenieErrorWithString? ParseWeenieErrorWithString(ReadOnlySpan 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 ─────────────────────────────────────────────────────
+
+ /// 0x01C0 UpdateHealth: (guid, healthPercent 0..1).
+ public readonly record struct UpdateHealth(uint TargetGuid, float HealthPercent);
+
+ public static UpdateHealth? ParseUpdateHealth(ReadOnlySpan 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 ────────────────────────────────────────────────────────
+
+ /// 0x01EA PingResponse: echoes the client ping id.
+ public static uint? ParsePingResponse(ReadOnlySpan payload)
+ {
+ if (payload.Length < 4) return null;
+ return BinaryPrimitives.ReadUInt32LittleEndian(payload);
+ }
+
+ // ── Spells / magic ──────────────────────────────────────────────────────
+
+ /// 0x02C1 MagicUpdateSpell: spell id added to spellbook.
+ public static uint? ParseMagicUpdateSpell(ReadOnlySpan payload)
+ {
+ if (payload.Length < 4) return null;
+ return BinaryPrimitives.ReadUInt32LittleEndian(payload);
+ }
+
+ // ── Shared string reader (matches LoginRequest.ReadString16L) ───────────
+
+ private static string ReadString16L(ReadOnlySpan 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;
+ }
+}
diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs
index 17d4356..1a102e5 100644
--- a/src/AcDream.Core.Net/WorldSession.cs
+++ b/src/AcDream.Core.Net/WorldSession.cs
@@ -115,6 +115,14 @@ public sealed class WorldSession : IDisposable
/// Raised every time the state machine transitions.
public event Action? StateChanged;
+ ///
+ /// 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.
+ ///
+ public GameEventDispatcher GameEvents { get; } = new();
+
public State CurrentState { get; private set; } = State.Disconnected;
/// Movement sequence counters for outbound MoveToState/AutonomousPosition.
@@ -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
diff --git a/tests/AcDream.Core.Net.Tests/Messages/GameEventDispatcherTests.cs b/tests/AcDream.Core.Net.Tests/Messages/GameEventDispatcherTests.cs
new file mode 100644
index 0000000..89e050c
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Messages/GameEventDispatcherTests.cs
@@ -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 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());
+ 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());
+
+ // 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());
+ 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));
+ }
+}