using System; using System.Collections.Generic; using System.Linq; 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 { public const uint CooldownSpellOffset = 0x8000u; public const uint CooldownBucket = 8u; private readonly Dictionary _learnedSpells = new(); private readonly Dictionary _activeById = new(); private readonly Dictionary> _enchantmentOrderByBucket = new(); private readonly Dictionary _vitalModCache = new(); private readonly List[] _favoriteSpells = Enumerable.Range(0, 8) .Select(_ => new List()).ToArray(); private readonly Dictionary _desiredComponents = new(); private SpellTable _table; private bool _metadataInstalled; private uint _spellbookFilters = 0x3FFFu; /// /// Build a Spellbook with an optional /// metadata source. When provided, /// returns the static spell descriptor (name / school / family / /// icon / mana / duration / etc.). When absent (back-compat for /// existing constructors / tests), /// always returns false. /// public Spellbook(SpellTable? table = null) { _table = table ?? SpellTable.Empty; _metadataInstalled = table is not null; } /// /// Look up static spell metadata. Closes ISSUES.md #11 — feeds the /// buff bar UI labels + icons + the family-stacking aggregation in /// EnchantmentMath (issue #6). /// public bool TryGetMetadata(uint spellId, out SpellMetadata meta) => _table.TryGet(spellId, out meta); /// The spell-metadata table this spellbook was built with. /// Returns if none was provided. public SpellTable Metadata => _table; /// /// Installs the production metadata table after DatCollection opens. /// GameWindow constructs client state before Silk's OnLoad callback, while /// portal.dat becomes available inside OnLoad. The table may be installed /// once; replacing a populated catalog would invalidate active stacking and /// presentation decisions. /// public void InstallMetadata(SpellTable table) { ArgumentNullException.ThrowIfNull(table); if (_metadataInstalled) { if (ReferenceEquals(_table, table)) return; throw new InvalidOperationException("Spell metadata is already installed."); } _table = table; _metadataInstalled = true; if (_learnedSpells.Count != 0) NotifySpellbookChanged(); if (_activeById.Count != 0) NotifyEnchantmentsChanged(); } /// /// Issue #6 — combined buff modifier for a vital stat. Aggregates /// over through /// with family-stacking dedup /// from the spell metadata table. Returns /// when no buffs /// apply (or when no SpellTable was wired). /// public EnchantmentMath.VitalMod GetVitalMod(uint statKey) { if (_vitalModCache.TryGetValue( statKey, out EnchantmentMath.VitalMod cached)) { return cached; } EnchantmentMath.VitalMod calculated = EnchantmentMath.GetMod(ActiveEnchantments, _table, statKey); _vitalModCache.Add(statKey, calculated); return calculated; } /// 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; /// Fires when any manifest-backed magic UI state changes. public event Action? StateChanged; /// Fires when learned spells, favorites, or spellbook filters change. public event Action? SpellbookChanged; /// Fires when the active enchantment set changes. public event Action? EnchantmentsChanged; /// Fires when the desired spell-component levels change. public event Action? DesiredComponentsChanged; /// All currently learned spell ids. public IReadOnlyCollection LearnedSpells => _learnedSpells.Keys; /// Stable learned-spell snapshot, including server spell power. public IReadOnlyList LearnedSpellSnapshot => _learnedSpells .Select(pair => new LearnedSpellRecord(pair.Key, pair.Value)) .OrderBy(spell => spell.SpellId) .ToArray(); /// All currently-active enchantments. public IEnumerable ActiveEnchantments => _activeById.Values; public IReadOnlyList ActiveEnchantmentSnapshot => _activeById.Values.OrderBy(record => record.Identity).ToArray(); /// /// Retail ordinary-effect projection after mult/add filtering and the /// spell-category duel. This is the source for effects-list rows; retail's /// indicator counts scan the raw mult/add records before this projection. /// public IReadOnlyList EnchantmentsInEffectSnapshot { get { var ordered = new List(); AppendBucket(bucket: 1u, ordered); AppendBucket(bucket: 2u, ordered); return EnchantmentRegistryProjection.GetEnchantmentsInEffect(ordered); } } public IReadOnlyList GetFavorites(int tabIndex) { if ((uint)tabIndex >= _favoriteSpells.Length) return Array.Empty(); return _favoriteSpells[tabIndex].ToArray(); } public IReadOnlyDictionary DesiredComponents => _desiredComponents; public uint SpellbookFilters => _spellbookFilters; public int LearnedCount => _learnedSpells.Count; public int ActiveCount => _activeById.Count; public bool HasCooldownEnchantments => _enchantmentOrderByBucket.TryGetValue( CooldownBucket, out List? order) && order.Count != 0; public bool Knows(uint spellId) => _learnedSpells.ContainsKey(spellId); // ── Inbound handlers ───────────────────────────────────────────────────── /// 0x02C1 MagicUpdateSpell: learn a spell. public void OnSpellLearned(uint spellId, float power = 0f) { if (_learnedSpells.TryAdd(spellId, power)) { SpellLearned?.Invoke(spellId); NotifySpellbookChanged(); } else if (_learnedSpells[spellId] != power) { _learnedSpells[spellId] = power; NotifySpellbookChanged(); } } /// 0x01A8 MagicRemoveSpell: forget a spell. public void OnSpellForgotten(uint spellId) { if (_learnedSpells.Remove(spellId)) { SpellForgotten?.Invoke(spellId); NotifySpellbookChanged(); } } /// 0x02C2 MagicUpdateEnchantment: enchantment added / refreshed. public void OnEnchantmentAdded(uint spellId, uint layerId, float duration, uint casterGuid) { var record = new ActiveEnchantmentRecord(spellId, layerId, duration, casterGuid); UpsertLiveEnchantment(record); EnchantmentAdded?.Invoke(record); NotifyEnchantmentsChanged(); } /// /// Issue #7 / #12 — accept a fully-populated record from /// PlayerDescription's enchantment block (which carries /// the StatMod triad + bucket). Used when the wire-format extension /// gives us the full per-enchantment payload, rather than the /// 4-field summary from MagicUpdateEnchantment. /// public void OnEnchantmentAdded(ActiveEnchantmentRecord record) { UpsertLiveEnchantment(record); EnchantmentAdded?.Invoke(record); NotifyEnchantmentsChanged(); } /// 0x02C3 / 0x02C7 MagicRemove/DispelEnchantment. public void OnEnchantmentRemoved(uint layerId, uint spellId) { uint identity = ActiveEnchantmentRecord.MakeIdentity(spellId, layerId); if (RemoveActiveEnchantment(identity, out ActiveEnchantmentRecord record)) { EnchantmentRemoved?.Invoke(record); NotifyEnchantmentsChanged(); } } public void OnEnchantmentsAdded(IEnumerable records) { foreach (ActiveEnchantmentRecord record in records) { UpsertLiveEnchantment(record); EnchantmentAdded?.Invoke(record); } NotifyEnchantmentsChanged(); } public void OnEnchantmentsRemoved(IEnumerable<(uint SpellId, uint Layer)> spells) { bool changed = false; foreach ((uint spellId, uint layer) in spells) { uint identity = ActiveEnchantmentRecord.MakeIdentity(spellId, layer); if (!RemoveActiveEnchantment(identity, out ActiveEnchantmentRecord record)) continue; changed = true; EnchantmentRemoved?.Invoke(record); } if (changed) NotifyEnchantmentsChanged(); } /// /// Retail CEnchantmentRegistry::OnCooldown @ 0x005943C0. /// Scans the cooldown list head-to-tail, stops at the first shared-group /// match, and removes that node when its monotonic lifetime has expired. /// public bool OnCooldown( uint cooldownId, double currentTime, out double remaining) { remaining = 0d; if (cooldownId == 0u || !_enchantmentOrderByBucket.TryGetValue( CooldownBucket, out List? order)) return false; uint cooldownSpellId = unchecked(cooldownId + CooldownSpellOffset); for (int index = 0; index < order.Count; index++) { uint identity = order[index]; if (!_activeById.TryGetValue( identity, out ActiveEnchantmentRecord record) || (record.SpellId & 0xFFFFu) != cooldownSpellId) continue; remaining = record.Duration + record.StartTime - currentTime; if (remaining > 0d) return true; RemoveActiveEnchantment(identity, out _); EnchantmentRemoved?.Invoke(record); NotifyEnchantmentsChanged(); return false; } return false; } /// /// Retail EnchantmentRegistry::PurgeEnchantments (0x00594DE0): purge only /// finite-duration multiplicative/additive enchantments. Vitae, cooldowns, /// and permanent enchantments live in separate registries and survive. /// public void OnPurgeAll() { ActiveEnchantmentRecord[] removed = _activeById.Values .Where(IsPurgeable) .ToArray(); foreach (ActiveEnchantmentRecord record in removed) RemoveActiveEnchantment(record.Identity, out _); foreach (var rec in removed) EnchantmentRemoved?.Invoke(rec); if (removed.Length != 0) NotifyEnchantmentsChanged(); } public void OnPurgeBadEnchantments() { // Retail PurgeBadEnchantments (0x00594E00) tests the StatMod // Beneficial bit directly; spell-table labels do not participate. const uint BeneficialStatMod = 0x02000000u; ActiveEnchantmentRecord[] removed = _activeById.Values .Where(record => IsPurgeable(record) && (record.StatModType.GetValueOrDefault() & BeneficialStatMod) == 0) .ToArray(); foreach (ActiveEnchantmentRecord record in removed) RemoveActiveEnchantment(record.Identity, out _); foreach (ActiveEnchantmentRecord record in removed) EnchantmentRemoved?.Invoke(record); if (removed.Length != 0) NotifyEnchantmentsChanged(); } private static bool IsPurgeable(ActiveEnchantmentRecord record) => (record.Bucket is 1u or 2u) && record.Duration != -1f; /// /// PlayerDescription is a complete character manifest. Replace every /// magic collection atomically so relogging or switching characters cannot /// retain state from the prior snapshot. /// public void ReplaceManifest( IReadOnlyDictionary learnedSpells, IEnumerable enchantments, IReadOnlyList> favorites, IReadOnlyList<(uint Id, uint Amount)> desiredComponents, uint spellbookFilters) { _learnedSpells.Clear(); foreach ((uint spellId, float power) in learnedSpells) _learnedSpells[spellId] = power; _activeById.Clear(); _enchantmentOrderByBucket.Clear(); _vitalModCache.Clear(); foreach (ActiveEnchantmentRecord enchantment in enchantments) UpsertManifestEnchantment(enchantment); for (int i = 0; i < _favoriteSpells.Length; i++) { _favoriteSpells[i].Clear(); if (i < favorites.Count) _favoriteSpells[i].AddRange(favorites[i]); } _desiredComponents.Clear(); foreach ((uint id, uint amount) in desiredComponents) _desiredComponents[id] = amount; _spellbookFilters = spellbookFilters; SpellbookChanged?.Invoke(); EnchantmentsChanged?.Invoke(); DesiredComponentsChanged?.Invoke(); StateChanged?.Invoke(); } public void SetFavorite(int tabIndex, int position, uint spellId) { if ((uint)tabIndex >= _favoriteSpells.Length || position < 0) return; List tab = _favoriteSpells[tabIndex]; if (position > tab.Count) position = tab.Count; tab.Remove(spellId); tab.Insert(Math.Min(position, tab.Count), spellId); NotifySpellbookChanged(); } public void RemoveFavorite(int tabIndex, uint spellId) { if ((uint)tabIndex >= _favoriteSpells.Length) return; if (_favoriteSpells[tabIndex].Remove(spellId)) NotifySpellbookChanged(); } public void SetSpellbookFilters(uint filters) { if (_spellbookFilters == filters) return; _spellbookFilters = filters; NotifySpellbookChanged(); } public void SetDesiredComponent(uint componentId, uint amount) { bool exists = _desiredComponents.TryGetValue( componentId, out uint current); if (exists && current == amount) return; // PlayerModule::SetDesiredCompLevel @ 0x005D4940 retains zero as a // real table value; only ClearDesiredCompList destroys the entry set. _desiredComponents[componentId] = amount; DesiredComponentsChanged?.Invoke(); StateChanged?.Invoke(); } /// /// Destroy the complete desired-component list, matching /// PlayerModule::ClearDesiredCompList @ 0x005D2A00. /// public void ClearDesiredComponents() { if (_desiredComponents.Count == 0) return; _desiredComponents.Clear(); DesiredComponentsChanged?.Invoke(); StateChanged?.Invoke(); } public void Clear() { _learnedSpells.Clear(); _activeById.Clear(); _enchantmentOrderByBucket.Clear(); _vitalModCache.Clear(); foreach (List tab in _favoriteSpells) tab.Clear(); _desiredComponents.Clear(); _spellbookFilters = 0x3FFFu; SpellbookChanged?.Invoke(); EnchantmentsChanged?.Invoke(); DesiredComponentsChanged?.Invoke(); StateChanged?.Invoke(); } private void NotifySpellbookChanged() { SpellbookChanged?.Invoke(); StateChanged?.Invoke(); } private void NotifyEnchantmentsChanged() { _vitalModCache.Clear(); EnchantmentsChanged?.Invoke(); StateChanged?.Invoke(); } private void AppendBucket(uint bucket, List destination) { if (!_enchantmentOrderByBucket.TryGetValue(bucket, out List? order)) return; foreach (uint identity in order) if (_activeById.TryGetValue(identity, out ActiveEnchantmentRecord record) && record.Bucket == bucket) destination.Add(record); } /// /// Retail AddEnchantmentToList @ 0x00593DF0 inserts a new live /// mult/add record at the list head. Refreshing an existing identity keeps /// its node position; moving buckets creates a new head node. /// private void UpsertLiveEnchantment(ActiveEnchantmentRecord record) { uint identity = record.Identity; if (_activeById.TryGetValue(identity, out ActiveEnchantmentRecord previous)) { _activeById[identity] = record; if (previous.Bucket == record.Bucket) { EnsureOrdered(identity, record.Bucket, insertAtHead: true); return; } RemoveFromOrder(previous); } else { _activeById.Add(identity, record); } GetBucketOrder(record.Bucket).Insert(0, identity); } /// Bulk UnPack preserves the received head-to-tail list order. private void UpsertManifestEnchantment(ActiveEnchantmentRecord record) { uint identity = record.Identity; if (_activeById.TryGetValue(identity, out ActiveEnchantmentRecord previous)) { _activeById[identity] = record; if (previous.Bucket == record.Bucket) { EnsureOrdered(identity, record.Bucket, insertAtHead: false); return; } RemoveFromOrder(previous); } else { _activeById.Add(identity, record); } GetBucketOrder(record.Bucket).Add(identity); } private bool RemoveActiveEnchantment( uint identity, out ActiveEnchantmentRecord record) { if (!_activeById.Remove(identity, out record)) return false; RemoveFromOrder(record); return true; } private void EnsureOrdered(uint identity, uint bucket, bool insertAtHead) { List order = GetBucketOrder(bucket); if (order.Contains(identity)) return; if (insertAtHead) order.Insert(0, identity); else order.Add(identity); } private List GetBucketOrder(uint bucket) { if (_enchantmentOrderByBucket.TryGetValue(bucket, out List? order)) return order; order = new List(); _enchantmentOrderByBucket.Add(bucket, order); return order; } private void RemoveFromOrder(ActiveEnchantmentRecord record) { if (!_enchantmentOrderByBucket.TryGetValue(record.Bucket, out List? order)) return; order.Remove(record.Identity); if (order.Count == 0) _enchantmentOrderByBucket.Remove(record.Bucket); } } public readonly record struct LearnedSpellRecord(uint SpellId, float Power); /// /// Summary of one active enchantment layer on the player. The /// optional StatMod fields (issue #12) carry the wire-level /// `_smod` triad (type, key, val). Both the complete /// `PlayerDescription` snapshot and live MagicUpdateEnchantment events /// populate the same record shape. /// /// /// tells EnchantmentMath whether this /// enchantment's StatMod is multiplicative (0x01), additive /// (0x02), vitae (0x04), or cooldown (0x08) per /// the retail EnchantmentMask classification. /// /// public readonly record struct ActiveEnchantmentRecord( uint SpellId, uint LayerId, double Duration, uint CasterGuid, uint? StatModType = null, uint? StatModKey = null, float? StatModValue = null, uint Bucket = 0, double StartTime = 0, uint SpellCategory = 0, uint PowerLevel = 0, float DegradeModifier = 0, float DegradeLimit = 0, double LastTimeDegraded = 0, uint? SpellSetId = null) { public uint Identity => MakeIdentity(SpellId, LayerId); public static uint MakeIdentity(uint spellId, uint layerId) => (spellId & 0xFFFFu) | ((layerId & 0xFFFFu) << 16); }