From d86fd0801153d0b22cefb177992c4fbd143e9fa9 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 18 Apr 2026 16:52:46 +0200 Subject: [PATCH] feat(net): Phase F.1 GameEvent (0xF7B0) envelope dispatcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Messages/GameEventDispatcher.cs | 90 ++++++++ .../Messages/GameEventEnvelope.cs | 57 +++++ .../Messages/GameEventType.cs | 119 ++++++++++ src/AcDream.Core.Net/Messages/GameEvents.cs | 163 ++++++++++++++ src/AcDream.Core.Net/WorldSession.cs | 17 ++ .../Messages/GameEventDispatcherTests.cs | 210 ++++++++++++++++++ 6 files changed, 656 insertions(+) create mode 100644 src/AcDream.Core.Net/Messages/GameEventDispatcher.cs create mode 100644 src/AcDream.Core.Net/Messages/GameEventEnvelope.cs create mode 100644 src/AcDream.Core.Net/Messages/GameEventType.cs create mode 100644 src/AcDream.Core.Net/Messages/GameEvents.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/GameEventDispatcherTests.cs 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)); + } +}