diff --git a/BMAPlugin.cs b/BMAPlugin.cs index 0b55825..86947e7 100644 --- a/BMAPlugin.cs +++ b/BMAPlugin.cs @@ -38,12 +38,38 @@ private void Awake() _log = Logger; Instance = this; Harmony = new(ModMetaData.GUID); - Harmony.PatchAll(); + PatchAllResilient(Harmony); Log.LogInfo($"BlackMagicAPI v{ModMetaData.VERSION} loaded, (Compatibility -> v{CompatibilityManager.COMPATIBILITY_VERSION})"); SynchronizeManager.UpdateSyncHash(); StartCoroutine(CoWaitForChainloaderToLog()); } + /// + /// Applies every Harmony patch class in this assembly individually so that a single + /// patch whose target game method was removed/renamed by an update (e.g. a deleted + /// PaperInteract.Start) only disables that one feature instead of aborting the whole + /// plugin load. The original Harmony.PatchAll() is all-or-nothing and throws on the + /// first undefined target method. + /// + private static void PatchAllResilient(Harmony harmony) + { + var assembly = typeof(BMAPlugin).Assembly; + foreach (var type in AccessTools.GetTypesFromAssembly(assembly)) + { + try + { + harmony.CreateClassProcessor(type).Patch(); + } + catch (System.Exception ex) + { + Log.LogWarning( + $"Skipping patch class '{type.FullName}' - its target game method is missing " + + $"(likely changed by a game update). This feature is disabled but the mod will " + + $"continue loading. Details: {ex.Message}"); + } + } + } + private IEnumerator CoWaitForChainloaderToLog() { while (!Chainloader._loaded && !SplashScreen.isFinished) diff --git a/BlackMagicAPI.csproj b/BlackMagicAPI.csproj index 9ca2474..084ee14 100644 --- a/BlackMagicAPI.csproj +++ b/BlackMagicAPI.csproj @@ -8,10 +8,14 @@ preview True 3.0.0.0 + + true - + @@ -35,8 +39,13 @@ + + + /home/elemantro/.config/r2modmanPlus-local/MageArena/profiles/Default/BepInEx/plugins/D1GQ-BlackMagicAPI/BlackMagicAPI + - - + + + \ No newline at end of file diff --git a/Managers/ItemManager.cs b/Managers/ItemManager.cs index 065e86d..2351b88 100644 --- a/Managers/ItemManager.cs +++ b/Managers/ItemManager.cs @@ -184,9 +184,10 @@ internal static void RegisterItem(BaseUnityPlugin baseUnity, Type ItemDataType, ModSyncManager.FailedItems.Add((baseUnity, ItemDataType)); return; case CompatibilityResult.OldVersion: - BMAPlugin.Log.LogError($"Failed to register item from {baseUnity.Info.Metadata.Name}: {ItemDataType.Name} Is incompatible with BlackMagicAPI v{ModMetaData.VERSION}!"); - ModSyncManager.FailedItems.Add((baseUnity, ItemDataType)); - return; + // See SpellManager: type loaded and constructed fine, so it is binary-compatible. + // Warn-and-register instead of hard-failing on a strict version-equality mismatch. + BMAPlugin.Log.LogWarning($"Registering item from {baseUnity.Info.Metadata.Name}: {ItemDataType.Name} was built against a different BlackMagicAPI version (v{ModMetaData.VERSION} installed). Loading anyway; report issues to the item's author if it misbehaves."); + break; case CompatibilityResult.Error: BMAPlugin.Log.LogError($"Failed to register item from {baseUnity.Info.Metadata.Name}: An error occurred when trying to get Compatibility Version from {ItemDataType.Name}!"); ModSyncManager.FailedItems.Add((baseUnity, ItemDataType)); diff --git a/Managers/SpellManager.cs b/Managers/SpellManager.cs index 8007c3a..03c1a7c 100644 --- a/Managers/SpellManager.cs +++ b/Managers/SpellManager.cs @@ -95,9 +95,13 @@ internal static void RegisterSpell(BaseUnityPlugin baseUnity, Type SpellDataType ModSyncManager.FailedSpells.Add((baseUnity, SpellDataType)); return; case CompatibilityResult.OldVersion: - BMAPlugin.Log.LogError($"Failed to register spell from {baseUnity.Info.Metadata.Name}: {SpellDataType.Name} Is incompatible with BlackMagicAPI v{ModMetaData.VERSION}!"); - ModSyncManager.FailedSpells.Add((baseUnity, SpellDataType)); - return; + // Built against a different BlackMagicAPI version, but the type loaded and + // constructed successfully (otherwise this would be Error/NoProperty), so it is + // binary-compatible. Warn instead of hard-failing so spells from mods that haven't + // rebuilt against the current API still register. If a genuine API break exists it + // will surface as a runtime error from that spell, not a silent drop of all of them. + BMAPlugin.Log.LogWarning($"Registering spell from {baseUnity.Info.Metadata.Name}: {SpellDataType.Name} was built against a different BlackMagicAPI version (v{ModMetaData.VERSION} installed). Loading anyway; report issues to the spell's author if it misbehaves."); + break; case CompatibilityResult.Error: BMAPlugin.Log.LogError($"Failed to register spell from {baseUnity.Info.Metadata.Name}: An error occurred when trying to get Compatibility Version from {SpellDataType.Name}!"); ModSyncManager.FailedSpells.Add((baseUnity, SpellDataType)); diff --git a/Modules/Spells/SpellData.cs b/Modules/Spells/SpellData.cs index 4973254..4d5ccdc 100644 --- a/Modules/Spells/SpellData.cs +++ b/Modules/Spells/SpellData.cs @@ -105,6 +105,21 @@ public abstract class SpellData : ICompatibility /// public int Id { get; internal set; } + // ---- Legacy (BlackMagicAPI 1.x) compatibility members ---- + // Spells compiled against BMA 1.x override these properties. They were removed in 3.x, which made + // those spell types fail to load (ReflectionTypeLoadException) and silently disappear. Re-declaring + // them as virtuals lets the old overrides bind so the types load again. Current (3.x) spells use + // GetMainTexture/GetEmissionTexture instead and never touch these, so the defaults are harmless. + + /// Legacy 1.x hook for the main texture's PNG name. Unused by current spells. + public virtual string MainPngName => $"{Name.Replace(" ", "")}_Main"; + + /// Legacy 1.x hook for the emission texture's PNG name. Unused by current spells. + public virtual string EmissionPngName => $"{Name.Replace(" ", "")}_Emission"; + + /// Legacy 1.x short identifier hook. Unused by current spells. + public virtual string ShortId => Name; + /// /// The plugin that adds the spell. /// diff --git a/Modules/Spells/SpellLogic.cs b/Modules/Spells/SpellLogic.cs index 0b0bbbf..7a5566f 100644 --- a/Modules/Spells/SpellLogic.cs +++ b/Modules/Spells/SpellLogic.cs @@ -1,5 +1,7 @@ using BlackMagicAPI.Network; using System.Collections; +using System.Linq; +using System.Reflection; using UnityEngine; namespace BlackMagicAPI.Modules.Spells; @@ -87,10 +89,81 @@ public void PlayerSetup(GameObject ownerobj, Vector3 fwdVector, int level) { } /// The direction vector of the player's view. /// The power level of the spell cast. /// - /// true if the spell was successfully cast and the page should go on cooldown; + /// true if the spell was successfully cast and the page should go on cooldown; /// false if the spell failed or the page should not go on cooldown. /// - public abstract bool CastSpell(PlayerMovement caster, PageController page, Vector3 spawnPos, Vector3 viewDirectionVector, int castingLevel); + /// + /// This is intentionally virtual (not abstract): spells compiled against + /// BlackMagicAPI 1.x override the legacy -based overload below instead. + /// If this were abstract, those types would be implicitly abstract and Unity would fail to + /// instantiate them ("could not be instantiated" / "invalid vtable method slot"). The default + /// implementation forwards to the legacy overload so old spells still run; current spells + /// override this method directly. + /// + public virtual bool CastSpell(PlayerMovement caster, PageController page, Vector3 spawnPos, Vector3 viewDirectionVector, int castingLevel) + { + CastSpell(caster != null ? caster.gameObject : null, page, spawnPos, viewDirectionVector, castingLevel); + return true; + } + + /// + /// Legacy (BlackMagicAPI 1.x) cast signature. Spells compiled against 1.x override this + /// -based method; it was removed in 3.x, which made those spell types fail + /// to load entirely. Re-declaring it as a virtual no-op lets the old overrides bind so the types + /// load, and routes the actual cast to whichever signature the spell + /// implements. Current spells override the overload above and ignore this. + /// + public virtual void CastSpell(GameObject caster, PageController page, Vector3 spawnPos, Vector3 viewDirectionVector, int castingLevel) { } + + // Caches, per concrete SpellLogic type, the legacy CastSpell(GameObject, ...) MethodInfo to call, + // or null if the type implements the current CastSpell(PlayerMovement, ...) override directly. + private static readonly Dictionary LegacyCastCache = []; + + /// + /// Invokes the spell's cast logic, bridging spells compiled against older BlackMagicAPI versions. + /// + /// + /// BlackMagicAPI 1.x declared void CastSpell(GameObject, PageController, Vector3, Vector3, int). + /// 3.x changed it to bool CastSpell(PlayerMovement, ...). Spells built against 1.x therefore + /// never satisfy the current abstract method, so a direct virtual call would either do nothing or throw. + /// This dispatcher calls the modern override when present, and otherwise reflects the legacy + /// GameObject-based method so old spells still function. Returns true (page goes on cooldown) + /// when a legacy void method is used. + /// + internal bool InvokeCastSpell(PlayerMovement caster, PageController page, Vector3 spawnPos, Vector3 viewDirectionVector, int castingLevel) + { + var type = GetType(); + if (!LegacyCastCache.TryGetValue(type, out var legacy)) + { + var modern = type.GetMethod(nameof(CastSpell), + [typeof(PlayerMovement), typeof(PageController), typeof(Vector3), typeof(Vector3), typeof(int)]); + + // If the modern signature isn't overridden by this type, look for the legacy GameObject-based one. + if (modern == null || modern.DeclaringType == typeof(SpellLogic)) + { + legacy = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .FirstOrDefault(m => + m.Name == nameof(CastSpell) && + m.GetParameters() is { Length: 5 } p && + p[0].ParameterType == typeof(GameObject)); + } + else + { + legacy = null; + } + + LegacyCastCache[type] = legacy; + } + + if (legacy != null) + { + var result = legacy.Invoke(this, + [caster != null ? caster.gameObject : null, page, spawnPos, viewDirectionVector, castingLevel]); + return result is bool b ? b : true; + } + + return CastSpell(caster, page, spawnPos, viewDirectionVector, castingLevel); + } /// /// Virtual method for handling item-specific usage logic for spell page. diff --git a/Patches/Compatibility/MagicMissleControllerPatch.cs b/Patches/Compatibility/MagicMissleControllerPatch.cs new file mode 100644 index 0000000..f2b9a3a --- /dev/null +++ b/Patches/Compatibility/MagicMissleControllerPatch.cs @@ -0,0 +1,32 @@ +using HarmonyLib; + +namespace BlackMagicAPI.Patches.Compatibility; + +/// +/// Defensive guard for the base-game homing missile. +/// +/// MagicMissleController has no Awake/Start/OnEnable; it is initialized exclusively by +/// SetUp()/AISetup(), which assign playerOwner (and the target/forward vectors). +/// Custom-spell prefabs shipped by third-party mods sometimes carry a leftover +/// MagicMissleController component (cloned from the vanilla missile) that is never +/// SetUp(), so playerOwner/rb stay null and Update() throws a +/// NullReferenceException on every frame. +/// +/// +/// This prefix skips the homing logic until the missile has actually been initialized. Legitimately +/// cast missiles always have playerOwner assigned, so they are unaffected; only stray, +/// uninitialized instances are short-circuited. +/// +/// +[HarmonyPatch(typeof(MagicMissleController))] +internal class MagicMissleControllerPatch +{ + [HarmonyPatch(nameof(MagicMissleController.Update))] + [HarmonyPrefix] + [HarmonyPriority(Priority.First)] + private static bool Update_Prefix(MagicMissleController __instance) + { + // Run the original Update only once the missile has been initialized. + return __instance.playerOwner != null && __instance.rb != null; + } +} diff --git a/Patches/Compatibility/MainMenuEscapePatch.cs b/Patches/Compatibility/MainMenuEscapePatch.cs new file mode 100644 index 0000000..2125004 --- /dev/null +++ b/Patches/Compatibility/MainMenuEscapePatch.cs @@ -0,0 +1,68 @@ +using HarmonyLib; +using System; +using UnityEngine; + +namespace BlackMagicAPI.Patches.Compatibility; + +/// +/// Makes the in-game pause/escape menu null-safe. +/// +/// Vanilla MainMenuManager.Update is: +/// if (Input.GetKeyDown(KeyCode.Escape) && !SpellChooser.instance.isSettingPage) ToggleInGameMenu(); +/// After the player finishes the spell-loadout selection, the SpellChooser object can be +/// destroyed, leaving SpellChooser.instance null/destroyed. Dereferencing +/// .isSettingPage then throws every frame, so Escape never opens the pause menu. +/// +/// +/// This prefix reimplements the same behaviour but treats a missing SpellChooser as +/// "not selecting", so Escape always toggles the menu. When the chooser is alive it behaves +/// exactly like vanilla. +/// +/// +[HarmonyPatch(typeof(MainMenuManager))] +internal class MainMenuEscapePatch +{ + [HarmonyPatch(nameof(MainMenuManager.Update))] + [HarmonyPrefix] + private static bool Update_Prefix(MainMenuManager __instance) + { + if (Input.GetKeyDown(KeyCode.Escape)) + { + var chooser = SpellChooser.instance; + // Unity's overloaded == treats destroyed objects as null. + bool settingPage = chooser != null && chooser.isSettingPage; + BMAPlugin.Log.LogInfo($"[EscapePatch] Escape pressed. settingPage={settingPage}, calling ToggleInGameMenu"); + if (!settingPage) + { + try { __instance.ToggleInGameMenu(); } + catch (System.Exception ex) { BMAPlugin.Log.LogError($"[EscapePatch] ToggleInGameMenu threw: {ex}"); } + } + } + + // Update only handled the Escape key; we've fully replaced it. + return false; + } + + /// + /// Keeps MainMenuManager alive when a third-party plugin's Awake patch throws. + /// + /// MageConfigurationAPI (and potentially others) postfix-patch MainMenuManager.Awake and + /// throw a NullReferenceException because the game's menu UI hierarchy changed + /// (e.g. Canvas (1)/Main/LobbyID/LobbiesMenu/... no longer exists). When Awake throws, + /// Unity disables the whole MonoBehaviour, so Update never runs and Escape stops opening the + /// pause menu. This finalizer swallows the exception so Awake completes and the component stays enabled. + /// + /// + [HarmonyPatch(nameof(MainMenuManager.Awake))] + [HarmonyFinalizer] + private static Exception Awake_Finalizer(Exception __exception) + { + if (__exception != null) + { + BMAPlugin.Log.LogWarning( + $"Suppressed an exception thrown during MainMenuManager.Awake (likely a third-party " + + $"menu patch broken by a game update) so the pause menu keeps working: {__exception.Message}"); + } + return null; + } +} diff --git a/Patches/Items/PageControllerPatch.cs b/Patches/Items/PageControllerPatch.cs index 522d7f1..a017dbd 100644 --- a/Patches/Items/PageControllerPatch.cs +++ b/Patches/Items/PageControllerPatch.cs @@ -128,7 +128,7 @@ private static void CastSpellObs_Logic(PageController __instance, GameObject own spell.SyncData(dataWriter.GetObjectBuffer()); dataWriter.Dispose(); - bool cooldown = spell.CastSpell(ownerobj.GetComponent(), __instance, spawnpos, fwdVector, level); + bool cooldown = spell.InvokeCastSpell(ownerobj.GetComponent(), __instance, spawnpos, fwdVector, level); if (!cooldown) { __instance.ReinstatePageEmis(); diff --git a/Patches/Items/PaperInteractPatch.cs b/Patches/Items/PaperInteractPatch.cs deleted file mode 100644 index a7df081..0000000 --- a/Patches/Items/PaperInteractPatch.cs +++ /dev/null @@ -1,14 +0,0 @@ -using HarmonyLib; - -namespace BlackMagicAPI.Patches.Generation; - -[HarmonyPatch(typeof(PaperInteract))] -internal class PaperInteractPatch -{ - [HarmonyPatch(nameof(PaperInteract.Start))] - [HarmonyPostfix] - [HarmonyPriority(Priority.First)] - private static void Start_Postfix(PaperInteract __instance) - { - } -} diff --git a/Patches/Soups/SoupManPatch.cs b/Patches/Soups/SoupManPatch.cs index b528a9d..b0e3180 100644 --- a/Patches/Soups/SoupManPatch.cs +++ b/Patches/Soups/SoupManPatch.cs @@ -13,7 +13,9 @@ internal class SoupManControllerPatch { private static readonly ConditionalWeakTable> usedIds = []; - [HarmonyPatch(nameof(SoupManController.Start))] + // Game update renamed SoupManController.Start -> Awake; retarget so custom soup + // registration still runs at controller initialization. + [HarmonyPatch(nameof(SoupManController.Awake))] [HarmonyPostfix] [HarmonyPriority(Priority.First)] private static void Start_Postfix(SoupManController __instance)