diff --git a/src/AcDream.Core.Net/AcDream.Core.Net.csproj b/src/AcDream.Core.Net/AcDream.Core.Net.csproj
index 68dd27c..4f8a02f 100644
--- a/src/AcDream.Core.Net/AcDream.Core.Net.csproj
+++ b/src/AcDream.Core.Net/AcDream.Core.Net.csproj
@@ -7,4 +7,7 @@
true
AcDream.Core.Net
+
+
+
diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs
new file mode 100644
index 0000000..be38076
--- /dev/null
+++ b/src/AcDream.Core.Net/GameEventWiring.cs
@@ -0,0 +1,155 @@
+using System;
+using AcDream.Core.Chat;
+using AcDream.Core.Combat;
+using AcDream.Core.Items;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Spells;
+
+namespace AcDream.Core.Net;
+
+///
+/// Central registration point that wires every parsed GameEvent from
+/// into the appropriate Core state
+/// class (, ,
+/// , ).
+///
+///
+/// Call once at startup (or on reconnect) passing the session's
+/// dispatcher + the state instances you want to feed. The wiring is
+/// additive — if you want to add a custom handler for a specific
+/// event, register it AFTER this helper so it overrides the default.
+///
+///
+///
+/// This is the piece that makes Phase F.1's dispatcher go from "a
+/// thing that routes opcodes" to "a thing that actually populates
+/// client state so the UI can redraw". Without this glue every
+/// dispatcher handler had to be written by hand at each call site.
+///
+///
+public static class GameEventWiring
+{
+ public static void WireAll(
+ GameEventDispatcher dispatcher,
+ ItemRepository items,
+ CombatState combat,
+ Spellbook spellbook,
+ ChatLog chat)
+ {
+ ArgumentNullException.ThrowIfNull(dispatcher);
+ ArgumentNullException.ThrowIfNull(items);
+ ArgumentNullException.ThrowIfNull(combat);
+ ArgumentNullException.ThrowIfNull(spellbook);
+ ArgumentNullException.ThrowIfNull(chat);
+
+ // ── Chat ──────────────────────────────────────────────────
+ dispatcher.Register(GameEventType.ChannelBroadcast, e =>
+ {
+ var p = GameEvents.ParseChannelBroadcast(e.Payload.Span);
+ if (p is not null) chat.OnChannelBroadcast(p.Value.ChannelId, p.Value.SenderName, p.Value.Message);
+ });
+ dispatcher.Register(GameEventType.Tell, e =>
+ {
+ var p = GameEvents.ParseTell(e.Payload.Span);
+ if (p is not null) chat.OnTellReceived(p.Value.SenderName, p.Value.Message, p.Value.SenderGuid);
+ });
+ dispatcher.Register(GameEventType.CommunicationTransientString, e =>
+ {
+ var p = GameEvents.ParseTransient(e.Payload.Span);
+ if (p is not null) chat.OnSystemMessage(p.Value.Message, p.Value.ChatType);
+ });
+ dispatcher.Register(GameEventType.PopupString, e =>
+ {
+ var s = GameEvents.ParsePopupString(e.Payload.Span);
+ if (s is not null) chat.OnPopup(s);
+ });
+
+ // ── Combat ────────────────────────────────────────────────
+ dispatcher.Register(GameEventType.UpdateHealth, e =>
+ {
+ var p = GameEvents.ParseUpdateHealth(e.Payload.Span);
+ if (p is not null) combat.OnUpdateHealth(p.Value.TargetGuid, p.Value.HealthPercent);
+ });
+ dispatcher.Register(GameEventType.VictimNotification, e =>
+ {
+ var p = GameEvents.ParseVictimNotification(e.Payload.Span);
+ if (p is not null) combat.OnVictimNotification(
+ p.Value.AttackerName, p.Value.AttackerGuid, p.Value.DamageType,
+ p.Value.Damage, p.Value.HitQuadrant, p.Value.Critical, p.Value.AttackType);
+ });
+ dispatcher.Register(GameEventType.DefenderNotification, e =>
+ {
+ var p = GameEvents.ParseDefenderNotification(e.Payload.Span);
+ if (p is not null) combat.OnDefenderNotification(
+ p.Value.AttackerName, p.Value.AttackerGuid, p.Value.DamageType,
+ p.Value.Damage, p.Value.HitQuadrant, p.Value.Critical);
+ });
+ dispatcher.Register(GameEventType.AttackerNotification, e =>
+ {
+ var p = GameEvents.ParseAttackerNotification(e.Payload.Span);
+ if (p is not null) combat.OnAttackerNotification(
+ p.Value.DefenderName, p.Value.DamageType, p.Value.Damage, p.Value.DamagePercent);
+ });
+ dispatcher.Register(GameEventType.EvasionAttackerNotification, e =>
+ {
+ var name = GameEvents.ParseEvasionAttackerNotification(e.Payload.Span);
+ if (name is not null) combat.OnEvasionAttackerNotification(name);
+ });
+ dispatcher.Register(GameEventType.EvasionDefenderNotification, e =>
+ {
+ var name = GameEvents.ParseEvasionDefenderNotification(e.Payload.Span);
+ if (name is not null) combat.OnEvasionDefenderNotification(name);
+ });
+ dispatcher.Register(GameEventType.AttackDone, e =>
+ {
+ var p = GameEvents.ParseAttackDone(e.Payload.Span);
+ if (p is not null) combat.OnAttackDone(p.Value.AttackSequence, p.Value.WeenieError);
+ });
+
+ // ── Spells ────────────────────────────────────────────────
+ dispatcher.Register(GameEventType.MagicUpdateSpell, e =>
+ {
+ var spellId = GameEvents.ParseMagicUpdateSpell(e.Payload.Span);
+ if (spellId is not null) spellbook.OnSpellLearned(spellId.Value);
+ });
+ dispatcher.Register(GameEventType.MagicRemoveSpell, e =>
+ {
+ var spellId = GameEvents.ParseMagicRemoveSpell(e.Payload.Span);
+ if (spellId is not null) spellbook.OnSpellForgotten(spellId.Value);
+ });
+ dispatcher.Register(GameEventType.MagicUpdateEnchantment, e =>
+ {
+ var p = GameEvents.ParseMagicUpdateEnchantment(e.Payload.Span);
+ if (p is not null) spellbook.OnEnchantmentAdded(
+ p.Value.SpellId, p.Value.LayerId, p.Value.Duration, p.Value.CasterGuid);
+ });
+ dispatcher.Register(GameEventType.MagicRemoveEnchantment, e =>
+ {
+ var p = GameEvents.ParseMagicRemoveEnchantment(e.Payload.Span);
+ if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.LayerId, p.Value.SpellId);
+ });
+ dispatcher.Register(GameEventType.MagicDispelEnchantment, e =>
+ {
+ var p = GameEvents.ParseMagicDispelEnchantment(e.Payload.Span);
+ if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.LayerId, p.Value.SpellId);
+ });
+ dispatcher.Register(GameEventType.MagicPurgeEnchantments,
+ _ => spellbook.OnPurgeAll());
+
+ // ── Inventory ─────────────────────────────────────────────
+ dispatcher.Register(GameEventType.WieldObject, e =>
+ {
+ var p = GameEvents.ParseWieldObject(e.Payload.Span);
+ if (p is not null) items.MoveItem(
+ p.Value.ItemGuid,
+ newContainerId: p.Value.WielderGuid,
+ newEquipLocation: (AcDream.Core.Items.EquipMask)p.Value.EquipLoc);
+ });
+ dispatcher.Register(GameEventType.InventoryPutObjInContainer, e =>
+ {
+ var p = GameEvents.ParsePutObjInContainer(e.Payload.Span);
+ if (p is not null) items.MoveItem(p.Value.ItemGuid, p.Value.ContainerGuid,
+ newSlot: (int)p.Value.Placement);
+ });
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs
new file mode 100644
index 0000000..31b978b
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs
@@ -0,0 +1,146 @@
+using System;
+using System.Buffers.Binary;
+using System.Text;
+using AcDream.Core.Chat;
+using AcDream.Core.Combat;
+using AcDream.Core.Items;
+using AcDream.Core.Net;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Spells;
+using Xunit;
+
+namespace AcDream.Core.Net.Tests;
+
+public sealed class GameEventWiringTests
+{
+ 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;
+ }
+
+ private static byte[] WrapEnvelope(GameEventType type, byte[] payload)
+ {
+ byte[] body = new byte[GameEventEnvelope.HeaderSize + payload.Length];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, GameEventEnvelope.Opcode);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), 0u);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), 0u);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), (uint)type);
+ Array.Copy(payload, 0, body, GameEventEnvelope.HeaderSize, payload.Length);
+ return body;
+ }
+
+ private static (GameEventDispatcher, ItemRepository, CombatState, Spellbook, ChatLog) MakeAll()
+ {
+ var dispatcher = new GameEventDispatcher();
+ var items = new ItemRepository();
+ var combat = new CombatState();
+ var spellbook = new Spellbook();
+ var chat = new ChatLog();
+ GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat);
+ return (dispatcher, items, combat, spellbook, chat);
+ }
+
+ [Fact]
+ public void WireAll_ChannelBroadcast_RoutesToChatLog()
+ {
+ var (d, _, _, _, chat) = MakeAll();
+
+ byte[] payload = new byte[4 + MakeString16L("Alice").Length + MakeString16L("hi").Length];
+ BinaryPrimitives.WriteUInt32LittleEndian(payload, 42);
+ int p = 4;
+ var senderBytes = MakeString16L("Alice");
+ Array.Copy(senderBytes, 0, payload, p, senderBytes.Length); p += senderBytes.Length;
+ var msgBytes = MakeString16L("hi");
+ Array.Copy(msgBytes, 0, payload, p, msgBytes.Length);
+
+ var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.ChannelBroadcast, payload));
+ d.Dispatch(env!.Value);
+
+ Assert.Equal(1, chat.Count);
+ var entry = chat.Snapshot()[0];
+ Assert.Equal(ChatKind.Channel, entry.Kind);
+ Assert.Equal("Alice", entry.Sender);
+ }
+
+ [Fact]
+ public void WireAll_UpdateHealth_RoutesToCombatState()
+ {
+ var (d, _, combat, _, _) = MakeAll();
+
+ byte[] payload = new byte[8];
+ BinaryPrimitives.WriteUInt32LittleEndian(payload, 0xCAFE);
+ BinaryPrimitives.WriteSingleLittleEndian(payload.AsSpan(4), 0.42f);
+
+ var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.UpdateHealth, payload));
+ d.Dispatch(env!.Value);
+
+ Assert.Equal(0.42f, combat.GetHealthPercent(0xCAFE), 4);
+ }
+
+ [Fact]
+ public void WireAll_MagicUpdateSpell_RoutesToSpellbook()
+ {
+ var (d, _, _, book, _) = MakeAll();
+
+ byte[] payload = new byte[4];
+ BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x3E1);
+
+ var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.MagicUpdateSpell, payload));
+ d.Dispatch(env!.Value);
+
+ Assert.True(book.Knows(0x3E1));
+ }
+
+ [Fact]
+ public void WireAll_WieldObject_RoutesToItemRepository()
+ {
+ var (d, items, _, _, _) = MakeAll();
+ items.AddOrUpdate(new ItemInstance { ObjectId = 0x1000, WeenieClassId = 1 });
+
+ byte[] payload = new byte[12];
+ BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1000);
+ BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)EquipMask.MeleeWeapon);
+ BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0x2000);
+
+ var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WieldObject, payload));
+ d.Dispatch(env!.Value);
+
+ var item = items.GetItem(0x1000);
+ Assert.NotNull(item);
+ Assert.Equal(EquipMask.MeleeWeapon, item!.CurrentlyEquippedLocation);
+ Assert.Equal(0x2000u, item.ContainerId);
+ }
+
+ [Fact]
+ public void WireAll_PopupString_RoutesToChatLog()
+ {
+ var (d, _, _, _, chat) = MakeAll();
+
+ byte[] payload = MakeString16L("A modal message");
+ var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PopupString, payload));
+ d.Dispatch(env!.Value);
+
+ Assert.Equal(1, chat.Count);
+ Assert.Equal(ChatKind.Popup, chat.Snapshot()[0].Kind);
+ }
+
+ [Fact]
+ public void WireAll_MagicPurgeEnchantments_CallsOnPurgeAll()
+ {
+ var (d, _, _, book, _) = MakeAll();
+ book.OnEnchantmentAdded(1, 1, 100f, 0);
+ book.OnEnchantmentAdded(2, 2, 100f, 0);
+ Assert.Equal(2, book.ActiveCount);
+
+ var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.MagicPurgeEnchantments, Array.Empty()));
+ d.Dispatch(env!.Value);
+
+ Assert.Equal(0, book.ActiveCount);
+ }
+}