diff --git a/src/AcDream.Core.Net/Messages/CastSpellRequest.cs b/src/AcDream.Core.Net/Messages/CastSpellRequest.cs
new file mode 100644
index 0000000..1e5f25d
--- /dev/null
+++ b/src/AcDream.Core.Net/Messages/CastSpellRequest.cs
@@ -0,0 +1,64 @@
+using System.Buffers.Binary;
+
+namespace AcDream.Core.Net.Messages;
+
+///
+/// Outbound Magic_CastSpell GameActions — targeted and untargeted
+/// variants. From r01 §2 + r08 §3 opcodes 0x0048 / 0x004A.
+///
+///
+/// Wire layouts (inside the 0xF7B1 GameAction envelope):
+///
+///
+///
+/// Untargeted (0x0048):
+///
+/// u32 0xF7B1
+/// u32 gameActionSequence
+/// u32 0x0048
+/// u32 spellId
+///
+///
+///
+///
+/// Targeted (0x004A):
+///
+/// u32 0xF7B1
+/// u32 gameActionSequence
+/// u32 0x004A
+/// u32 targetGuid
+/// u32 spellId
+///
+///
+///
+public static class CastSpellRequest
+{
+ public const uint GameActionEnvelope = 0xF7B1u;
+ public const uint UntargetedSubOpcode = 0x0048u;
+ public const uint TargetedSubOpcode = 0x004Au;
+
+ /// Build an untargeted cast (buff self, recall, self-heal, etc).
+ public static byte[] BuildUntargeted(uint gameActionSequence, uint spellId)
+ {
+ byte[] body = new byte[16];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), UntargetedSubOpcode);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), spellId);
+ return body;
+ }
+
+ ///
+ /// Build a targeted cast (projectile attacks, target buffs, debuffs).
+ ///
+ public static byte[] BuildTargeted(uint gameActionSequence, uint targetGuid, uint spellId)
+ {
+ byte[] body = new byte[20];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), TargetedSubOpcode);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), targetGuid);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), spellId);
+ return body;
+ }
+}
diff --git a/src/AcDream.Core.Net/Messages/GameEvents.cs b/src/AcDream.Core.Net/Messages/GameEvents.cs
index 8c35872..5f7dcc3 100644
--- a/src/AcDream.Core.Net/Messages/GameEvents.cs
+++ b/src/AcDream.Core.Net/Messages/GameEvents.cs
@@ -264,6 +264,63 @@ public static class GameEvents
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
}
+ // ── Spell enchantments ──────────────────────────────────────────────────
+
+ ///
+ /// 0x02C3 MagicRemoveEnchantment — (layerId, spellId).
+ ///
+ public readonly record struct MagicRemoveEnchantment(uint LayerId, uint SpellId);
+
+ public static MagicRemoveEnchantment? ParseMagicRemoveEnchantment(ReadOnlySpan payload)
+ {
+ if (payload.Length < 8) return null;
+ return new MagicRemoveEnchantment(
+ BinaryPrimitives.ReadUInt32LittleEndian(payload),
+ BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
+ }
+
+ /// 0x01A8 MagicRemoveSpell — spell id removed from spellbook.
+ public static uint? ParseMagicRemoveSpell(ReadOnlySpan payload)
+ {
+ if (payload.Length < 4) return null;
+ return BinaryPrimitives.ReadUInt32LittleEndian(payload);
+ }
+
+ ///
+ /// 0x02C2 MagicUpdateEnchantment — the Enchantment blob. Full layout
+ /// (ACE Enchantment.Pack) is ~80+ bytes of spell metadata +
+ /// stat mods. We expose the first few fields that drive the enchant
+ /// bar UI; the rest is available via the raw payload view.
+ ///
+ public readonly record struct EnchantmentSummary(
+ uint SpellId,
+ uint LayerId,
+ float Duration,
+ uint CasterGuid);
+
+ public static EnchantmentSummary? ParseMagicUpdateEnchantment(ReadOnlySpan payload)
+ {
+ // Layout (ACE Enchantment.Pack):
+ // u32 spellId
+ // u32 layerId
+ // f32 duration
+ // u32 casterGuid
+ // ... (stat mods, category, power, etc.)
+ if (payload.Length < 16) return null;
+ return new EnchantmentSummary(
+ BinaryPrimitives.ReadUInt32LittleEndian(payload),
+ BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)),
+ BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(8)),
+ BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(12)));
+ }
+
+ ///
+ /// 0x02C7 MagicDispelEnchantment — (layerId, spellId).
+ /// Structure matches MagicRemoveEnchantment.
+ ///
+ public static MagicRemoveEnchantment? ParseMagicDispelEnchantment(ReadOnlySpan payload)
+ => ParseMagicRemoveEnchantment(payload);
+
// ── Appraise / identify ─────────────────────────────────────────────────
/// 0x00C9 IdentifyObjectResponse header.
diff --git a/src/AcDream.Core/Spells/Spellbook.cs b/src/AcDream.Core/Spells/Spellbook.cs
new file mode 100644
index 0000000..2025fa7
--- /dev/null
+++ b/src/AcDream.Core/Spells/Spellbook.cs
@@ -0,0 +1,104 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+
+namespace AcDream.Core.Spells;
+
+///
+/// Client-side spellbook mirror. Tracks which spells the player has
+/// learned (UpdateSpell 0x02C1) + a parallel active-enchantment
+/// table keyed by layer id (UpdateEnchantment 0x02C2 etc.).
+///
+///
+/// The UI binds to the collection-changed events so the spellbook
+/// panel + active-buff bar redraw automatically when the server
+/// pushes changes.
+///
+///
+public sealed class Spellbook
+{
+ private readonly HashSet _learnedSpells = new();
+ private readonly ConcurrentDictionary _activeByLayer = new();
+
+ /// Fires when a spell is added to the player's spellbook.
+ public event Action? SpellLearned;
+
+ /// Fires when a spell is removed (rare — usually on respec / admin).
+ public event Action? SpellForgotten;
+
+ /// Fires when an enchantment is added / refreshed.
+ public event Action? EnchantmentAdded;
+
+ /// Fires when an enchantment is removed (expired / dispelled).
+ public event Action? EnchantmentRemoved;
+
+ /// All currently learned spell ids.
+ public IReadOnlyCollection LearnedSpells => _learnedSpells;
+
+ /// All currently-active enchantments.
+ public IEnumerable ActiveEnchantments => _activeByLayer.Values;
+
+ public int LearnedCount => _learnedSpells.Count;
+ public int ActiveCount => _activeByLayer.Count;
+
+ public bool Knows(uint spellId) => _learnedSpells.Contains(spellId);
+
+ // ── Inbound handlers ─────────────────────────────────────────────────────
+
+ /// 0x02C1 MagicUpdateSpell: learn a spell.
+ public void OnSpellLearned(uint spellId)
+ {
+ if (_learnedSpells.Add(spellId))
+ SpellLearned?.Invoke(spellId);
+ }
+
+ /// 0x01A8 MagicRemoveSpell: forget a spell.
+ public void OnSpellForgotten(uint spellId)
+ {
+ if (_learnedSpells.Remove(spellId))
+ SpellForgotten?.Invoke(spellId);
+ }
+
+ /// 0x02C2 MagicUpdateEnchantment: enchantment added / refreshed.
+ public void OnEnchantmentAdded(uint spellId, uint layerId, float duration, uint casterGuid)
+ {
+ var record = new ActiveEnchantmentRecord(spellId, layerId, duration, casterGuid);
+ _activeByLayer[layerId] = record;
+ EnchantmentAdded?.Invoke(record);
+ }
+
+ /// 0x02C3 / 0x02C7 MagicRemove/DispelEnchantment.
+ public void OnEnchantmentRemoved(uint layerId, uint spellId)
+ {
+ if (_activeByLayer.TryRemove(layerId, out var record))
+ EnchantmentRemoved?.Invoke(record);
+ else
+ EnchantmentRemoved?.Invoke(new ActiveEnchantmentRecord(spellId, layerId, 0f, 0));
+ }
+
+ /// 0x02C6 MagicPurgeEnchantments: clear all active buffs.
+ public void OnPurgeAll()
+ {
+ foreach (var rec in _activeByLayer.Values)
+ EnchantmentRemoved?.Invoke(rec);
+ _activeByLayer.Clear();
+ }
+
+ public void Clear()
+ {
+ _learnedSpells.Clear();
+ _activeByLayer.Clear();
+ }
+}
+
+///
+/// Summary of one active enchantment layer on the player. Richer detail
+/// (stat mods, category, power) requires the full
+/// struct — this record is the wire-slim version surfaced by the
+/// event.
+///
+public readonly record struct ActiveEnchantmentRecord(
+ uint SpellId,
+ uint LayerId,
+ float Duration,
+ uint CasterGuid);
diff --git a/tests/AcDream.Core.Net.Tests/Messages/CastSpellTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CastSpellTests.cs
new file mode 100644
index 0000000..f1253ff
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Messages/CastSpellTests.cs
@@ -0,0 +1,78 @@
+using System;
+using System.Buffers.Binary;
+using AcDream.Core.Net.Messages;
+using Xunit;
+
+namespace AcDream.Core.Net.Tests.Messages;
+
+public sealed class CastSpellTests
+{
+ [Fact]
+ public void BuildUntargeted_EmitsCorrectWireBytes()
+ {
+ byte[] body = CastSpellRequest.BuildUntargeted(gameActionSequence: 5, spellId: 0x3E1);
+
+ Assert.Equal(16, body.Length);
+ Assert.Equal(CastSpellRequest.GameActionEnvelope,
+ BinaryPrimitives.ReadUInt32LittleEndian(body));
+ Assert.Equal(5u,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)));
+ Assert.Equal(CastSpellRequest.UntargetedSubOpcode,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
+ Assert.Equal(0x3E1u,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
+ }
+
+ [Fact]
+ public void BuildTargeted_EmitsCorrectWireBytes()
+ {
+ byte[] body = CastSpellRequest.BuildTargeted(
+ gameActionSequence: 7, targetGuid: 0xAAAAu, spellId: 0x3E1);
+
+ Assert.Equal(20, body.Length);
+ Assert.Equal(CastSpellRequest.TargetedSubOpcode,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
+ Assert.Equal(0xAAAAu,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
+ Assert.Equal(0x3E1u,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
+ }
+
+ [Fact]
+ public void ParseMagicUpdateSpell_RoundTrip()
+ {
+ byte[] payload = new byte[4];
+ BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x3E1u);
+ Assert.Equal(0x3E1u, GameEvents.ParseMagicUpdateSpell(payload));
+ }
+
+ [Fact]
+ public void ParseMagicUpdateEnchantment_RoundTrip()
+ {
+ byte[] payload = new byte[16];
+ BinaryPrimitives.WriteUInt32LittleEndian(payload, 42u); // spellId
+ BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 7u); // layerId
+ BinaryPrimitives.WriteSingleLittleEndian(payload.AsSpan(8), 300.0f); // duration
+ BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 0xBEEFu); // caster
+
+ var parsed = GameEvents.ParseMagicUpdateEnchantment(payload);
+ Assert.NotNull(parsed);
+ Assert.Equal(42u, parsed!.Value.SpellId);
+ Assert.Equal(7u, parsed.Value.LayerId);
+ Assert.Equal(300f, parsed.Value.Duration, 4);
+ Assert.Equal(0xBEEFu, parsed.Value.CasterGuid);
+ }
+
+ [Fact]
+ public void ParseMagicRemoveEnchantment_RoundTrip()
+ {
+ byte[] payload = new byte[8];
+ BinaryPrimitives.WriteUInt32LittleEndian(payload, 7u); // layerId
+ BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 42u); // spellId
+
+ var parsed = GameEvents.ParseMagicRemoveEnchantment(payload);
+ Assert.NotNull(parsed);
+ Assert.Equal(7u, parsed!.Value.LayerId);
+ Assert.Equal(42u, parsed.Value.SpellId);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Spells/SpellbookTests.cs b/tests/AcDream.Core.Tests/Spells/SpellbookTests.cs
new file mode 100644
index 0000000..a85e504
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Spells/SpellbookTests.cs
@@ -0,0 +1,93 @@
+using AcDream.Core.Spells;
+using Xunit;
+
+namespace AcDream.Core.Tests.Spells;
+
+public sealed class SpellbookTests
+{
+ [Fact]
+ public void OnSpellLearned_FiresEvent_Idempotent()
+ {
+ var book = new Spellbook();
+ int events = 0;
+ book.SpellLearned += _ => events++;
+
+ book.OnSpellLearned(0x3E1); // Flame Bolt I
+ book.OnSpellLearned(0x3E1); // duplicate — no event
+
+ Assert.Equal(1, events);
+ Assert.Equal(1, book.LearnedCount);
+ Assert.True(book.Knows(0x3E1));
+ }
+
+ [Fact]
+ public void OnSpellForgotten_FiresEvent()
+ {
+ var book = new Spellbook();
+ book.OnSpellLearned(0x3E1);
+
+ uint seen = 0;
+ book.SpellForgotten += id => seen = id;
+
+ book.OnSpellForgotten(0x3E1);
+ Assert.Equal(0x3E1u, seen);
+ Assert.False(book.Knows(0x3E1));
+ }
+
+ [Fact]
+ public void OnEnchantmentAdded_FiresEvent_StoresByLayer()
+ {
+ var book = new Spellbook();
+ ActiveEnchantmentRecord added = default;
+ book.EnchantmentAdded += r => added = r;
+
+ book.OnEnchantmentAdded(spellId: 42, layerId: 7, duration: 300f, casterGuid: 0xBEEF);
+
+ Assert.Equal(42u, added.SpellId);
+ Assert.Equal(7u, added.LayerId);
+ Assert.Equal(300f, added.Duration, 4);
+ Assert.Equal(1, book.ActiveCount);
+ }
+
+ [Fact]
+ public void OnEnchantmentAdded_SameLayer_Replaces()
+ {
+ var book = new Spellbook();
+ book.OnEnchantmentAdded(42, 7, 300f, 0);
+ book.OnEnchantmentAdded(42, 7, 600f, 0); // refresh
+
+ Assert.Equal(1, book.ActiveCount);
+ foreach (var e in book.ActiveEnchantments)
+ Assert.Equal(600f, e.Duration, 4);
+ }
+
+ [Fact]
+ public void OnEnchantmentRemoved_FiresEvent()
+ {
+ var book = new Spellbook();
+ book.OnEnchantmentAdded(42, 7, 300f, 0);
+
+ uint removedSpell = 0;
+ book.EnchantmentRemoved += r => removedSpell = r.SpellId;
+
+ book.OnEnchantmentRemoved(7, 42);
+ Assert.Equal(42u, removedSpell);
+ Assert.Equal(0, book.ActiveCount);
+ }
+
+ [Fact]
+ public void OnPurgeAll_RemovesAllEnchantments_FiresPerRecord()
+ {
+ var book = new Spellbook();
+ book.OnEnchantmentAdded(1, 1, 100f, 0);
+ book.OnEnchantmentAdded(2, 2, 100f, 0);
+ book.OnEnchantmentAdded(3, 3, 100f, 0);
+
+ int removals = 0;
+ book.EnchantmentRemoved += _ => removals++;
+
+ book.OnPurgeAll();
+ Assert.Equal(3, removals);
+ Assert.Equal(0, book.ActiveCount);
+ }
+}