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)