using ACE.Database; using ACE.DatLoader.FileTypes; using CustomSpells; using Ganss.Excel; namespace CustomSpells; [HarmonyPatch] public class PatchClass(BasicMod mod, string settingsName = "Settings.json") : BasicPatch(mod, settingsName) { public override async Task OnWorldOpen() { Settings = SettingsContainer.Settings; SetupSpells(); } // ---- Aelrynth: undo (2026-09-30) --------------------------------------------------------- // Upstream changed spells and set bonuses in memory and never put them back: a disable or a hot // reload left them changed until a restart, and a reload applied edits on top of edits. The first // time a spell or set is touched, what was there is kept (null = nothing was there); Stop puts // every one back. private static readonly Dictionary _originalSpells = new(); private static readonly Dictionary _originalSets = new(); private static void Remember(uint key) { if (_originalSpells.ContainsKey(key)) return; DatManager.PortalDat.SpellTable.Spells.TryGetValue(key, out var dat); DatabaseManager.World.spellCache.TryGetValue(key, out var db); _originalSpells[key] = (dat, db); } public static int RestoreAll() { var restored = 0; foreach (var (key, (dat, db)) in _originalSpells) { if (dat is null) DatManager.PortalDat.SpellTable.Spells.Remove(key); else DatManager.PortalDat.SpellTable.Spells[key] = dat; // Not cached before = read from the (unchanged) world database again next time. if (db is null) DatabaseManager.World.spellCache.TryRemove(key, out _); else DatabaseManager.World.spellCache[key] = db; restored++; } foreach (var (key, set) in _originalSets) { if (set is null) DatManager.PortalDat.SpellTable.SpellSet.Remove(key); else DatManager.PortalDat.SpellTable.SpellSet[key] = set; restored++; } _originalSpells.Clear(); _originalSets.Clear(); return restored; } public override void Stop() { try { var restored = RestoreAll(); if (restored > 0) ModManager.Log($"CustomSpells: put back {restored} spell(s)/set(s) as they were before the mod"); } catch (Exception ex) { ModManager.Log($"CustomSpells: restoring spells failed: {ex.Message}", ModManager.LogLevel.Error); } base.Stop(); } private static void SetupSpells() { if (Settings.AutoloadSpreadsheet) LoadSpellSpreadsheet(); CustomizeSpells(Settings.CustomSpells); OverrideSpellSets(); } private static void CustomizeSpells(List customSpells) { foreach (var customSpell in customSpells) { //Get keys from SpellId, defaulting to overriding the template uint template = (uint)customSpell.Template; uint key = (uint)(customSpell.Id ?? customSpell.Template); try { //Get and verify spell copies var dbSpell = DatabaseManager.World.GetCachedSpell(template).Clone(); var spellBase = new Spell(template)._spellBase.Clone(); if (dbSpell is null || spellBase is null) continue; Remember(key); // Aelrynth: undo customSpell.Apply(spellBase, dbSpell); //Set SpellBase - DatManager.PortalDat.SpellTable.Spells //if (!DatManager.PortalDat.SpellTable.Spells.ContainsKey(key)) // DatManager.PortalDat.SpellTable.Spells.Add(key, spellBase); //else DatManager.PortalDat.SpellTable.Spells[key] = spellBase; //Set DB Spell - DatabaseManager.World.spellCache / GetCachedSpell DatabaseManager.World.spellCache[key] = dbSpell; //ModManager.Log($"Customized Spell: {customSpell.Template} -> {customSpell.Id}"); } catch (Exception) { ModManager.Log($"Failed to customized {customSpell.Template} -> {customSpell.Id}"); } } } private static void LoadSpellSpreadsheet(Player player = null) { var sw = Stopwatch.StartNew(); var spells = SpellCustomization.ParseCustomizations(); CustomizeSpells(spells); sw.Stop(); var msg = $"Loaded {spells.Count} custom spells in {sw.ElapsedMilliseconds}ms from:\n{Settings.Spreadsheet}"; ModManager.Log(msg); player?.SendMessage(msg); } private static void OverrideSpellSets() { try { foreach (var setOverride in Settings.Sets) { var key = (uint)setOverride.Key; // Aelrynth: a fresh set replaces the dat's, which is kept for Stop - upstream cleared // and refilled the dat's own object, so there was nothing to put back. if (!_originalSets.ContainsKey(key)) _originalSets[key] = DatManager.PortalDat.SpellTable.SpellSet.TryGetValue(key, out var original) ? original : null; var datSet = new ACE.DatLoader.Entity.SpellSet(); DatManager.PortalDat.SpellTable.SpellSet[key] = datSet; //Order overriding sets by tier var tiers = setOverride.Value.OrderBy(x => x.NumEquipped); var start = tiers.FirstOrDefault().NumEquipped; var end = tiers.LastOrDefault().NumEquipped; //Clear sets datSet.SpellSetTiers.Clear(); datSet.SpellSetTiersNoGaps.Clear(); datSet.HighestTier = end; foreach (var tier in tiers) { //Make and add SpellSetTier SpellSetTiers sst = new() { Spells = tier.Spells.Select(x => (uint)x).ToList() }; datSet.SpellSetTiers.TryAdd(tier.NumEquipped, sst); } //Create the no-gaps list using ACE logic. Could be improved SpellSetTiers lastSpellSetTier = null; for (uint i = 0; i <= datSet.HighestTier; i++) { if (datSet.SpellSetTiers.TryGetValue(i, out var spellSetTiers)) lastSpellSetTier = spellSetTiers; if (lastSpellSetTier != null) datSet.SpellSetTiersNoGaps.TryAdd(i, lastSpellSetTier); } } } catch (Exception ex) { ModManager.Log(ex.Message, ModManager.LogLevel.Error); return; } ModManager.Log($"Replaced {Settings.Sets.Count} EquipmentSets with a combined {Settings.Sets.Sum(x => x.Value.Count())} tiers and {Settings.Sets.Sum(x => x.Value.Sum(s => s.Spells.Count))} set spells"); } [CommandHandler("loadspells", AccessLevel.Developer, CommandHandlerFlag.None)] public static void HandleLoadSpellSpreadsheet(Session session, params string[] parameters) { LoadSpellSpreadsheet(session?.Player); } [CommandHandler("spelldump", AccessLevel.Developer, CommandHandlerFlag.ConsoleInvoke)] public static void HandleSpellDump(Session session, params string[] parameters) { List sc = new(); foreach (var spellId in Enum.GetValues()) { var spell = new Spell(spellId); if (spell is null || spell._spellBase is null || spell._spell is null) continue; var custom = new SpellCustomization(spell); if (custom is null) continue; sc.Add(custom); } var path = Path.Combine(CustomSpells.Mod.Instance.ModPath, "Dump.xlsx"); ExcelMapper excel = new() { CreateMissingHeaders = true, IgnoreNestedTypes = true }; excel.SetupCustomSpellMappings(); excel.Save(path, sc, "Spells"); var msg = $"Exported {sc.Count} spells to be customized to:\n{path}"; ModManager.Log(msg); } [CommandHandler("listset", AccessLevel.Player, CommandHandlerFlag.None)] public static void HandleSS(Session session, params string[] parameters) { var p = session.Player; var s = GetLastAppraisedObject(session); if (s is null || s.EquipmentSetId is null) return; if (!DatManager.PortalDat.SpellTable.SpellSet.TryGetValue((uint)s.EquipmentSetId, out var set)) return; //ACE.Server.Entity.SpellSet loads from DatManager.PortalDat.SpellTable.SpellSet var sb = new StringBuilder($"\nEquipmentSet {s.EquipmentSetId}:"); foreach (var tier in set.SpellSetTiersNoGaps) { sb.Append($"\nWearing >= {tier.Key}"); foreach (var spellId in tier.Value.Spells) { Spell spell = new Spell(spellId); sb.Append($"\n ({spellId}) {spell.Name}"); } } p.SendMessage($"{sb}"); } //[CommandHandler("setup-spells", AccessLevel.Developer, CommandHandlerFlag.None)] //public static void HandleSetupSpells(Session session, params string[] parameters) //{ // SetupSpells(); //} //Lazy helper public static WorldObject GetLastAppraisedObject(Session session) { var targetID = session.Player.RequestedAppraisalTarget; if (targetID == null) { return null; } var target = session.Player.FindObject(targetID.Value, Player.SearchLocations.Everywhere, out _, out _, out _); if (target == null) { return null; } return target; } //[HarmonyPrefix] //[HarmonyPatch(typeof(Spell), nameof(Spell.GetMagicSkill))] //public static bool PreGetMagicSkill(ref Spell __instance, ref Skill __result) //{ // Debugger.Break(); // __result = __instance.School switch // { // MagicSchool.CreatureEnchantment => Skill.CreatureEnchantment, // MagicSchool.WarMagic => Skill.WarMagic, // MagicSchool.ItemEnchantment => Skill.ItemEnchantment, // MagicSchool.LifeMagic => Skill.LifeMagic, // MagicSchool.VoidMagic => Skill.VoidMagic, // MagicSchool x when x <= (MagicSchool)Skill.Summoning => (Skill)x, // _ => default, // }; // return false; //} [HarmonyPrefix] [HarmonyPatch(typeof(Creature), nameof(Creature.GetCreatureSkill), new Type[] { typeof(MagicSchool) })] public static bool PreGetCreatureSkill(MagicSchool skill, ref Creature __instance, ref CreatureSkill __result) { // Aelrynth: None and the five retail schools are ACE's own (upstream also sent None to // GetCreatureSkill(Skill.None)). Only a school past VoidMagic - a new one made with this mod - // is mapped to the skill with the same number. if (skill <= MagicSchool.VoidMagic) return true; __result = skill <= (MagicSchool)Skill.Summoning ? __instance.GetCreatureSkill((Skill)skill) : null; return false; } [HarmonyPrefix] [HarmonyPatch(typeof(Player), nameof(Player.HasFoci), new Type[] { typeof(MagicSchool) })] public static bool PreHasFoci(MagicSchool school, ref Player __instance, ref bool __result) { // Aelrynth: a school with a foci is ACE's own check, which answers true for the Infused // augmentations first. Upstream worked that out and then overwrote it with the pack check, so // every player with an Infused augmentation would have needed components again. Only a school // ACE has no foci for (a new one; ACE would throw on it) is always "has foci". if (Player.FociWCIDs.ContainsKey(school)) return true; __result = true; return false; } }