Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion BMAPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

/// <summary>
/// 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.
/// </summary>
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)
Expand Down
15 changes: 12 additions & 3 deletions BlackMagicAPI.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@
<langVersion>preview</langVersion>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
<Version>3.0.0.0</Version>
<!-- Bind against the game's own BCL (mscorlib/netstandard from References) instead of the
SDK's .NET Framework reference assemblies, which lack netstandard2.1 types like
System.Diagnostics.CodeAnalysis.AllowNullAttribute that the game runtime provides. -->
<DisableImplicitFrameworkReferences>true</DisableImplicitFrameworkReferences>
</PropertyGroup>

<ItemGroup>
<Reference Include="References\*.dll" />
<Reference Include="References\*.dll" Exclude="References\Assembly-CSharp.dll;References\BepInEx.dll" />
</ItemGroup>

<ItemGroup>
Expand All @@ -35,8 +39,13 @@
</PackageReference>
</ItemGroup>

<!-- Deploy target: copies the freshly built plugin into the workspace staging folder. -->
<PropertyGroup>
<BMADeployDir>/home/elemantro/.config/r2modmanPlus-local/MageArena/profiles/Default/BepInEx/plugins/D1GQ-BlackMagicAPI/BlackMagicAPI</BMADeployDir>
</PropertyGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
<Exec Command="copy &quot;$(TargetDir)BlackMagicAPI.dll&quot; &quot;C:\Program Files (x86)\Steam\steamapps\common\Mage Arena\Modded\BepInEx\plugins\BlackMagicAPI&quot;" />
<Exec Command="copy &quot;$(TargetDir)BlackMagicAPI.xml&quot; &quot;C:\Program Files (x86)\Steam\steamapps\common\Mage Arena\Modded\BepInEx\plugins\BlackMagicAPI&quot;" />
<MakeDir Directories="$(BMADeployDir)" />
<Copy SourceFiles="$(TargetDir)BlackMagicAPI.dll" DestinationFolder="$(BMADeployDir)" />
<Copy SourceFiles="$(TargetDir)BlackMagicAPI.xml" DestinationFolder="$(BMADeployDir)" Condition="Exists('$(TargetDir)BlackMagicAPI.xml')" />
</Target>
</Project>
7 changes: 4 additions & 3 deletions Managers/ItemManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
10 changes: 7 additions & 3 deletions Managers/SpellManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
15 changes: 15 additions & 0 deletions Modules/Spells/SpellData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,21 @@ public abstract class SpellData : ICompatibility
/// </remarks>
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.

/// <summary>Legacy 1.x hook for the main texture's PNG name. Unused by current spells.</summary>
public virtual string MainPngName => $"{Name.Replace(" ", "")}_Main";

/// <summary>Legacy 1.x hook for the emission texture's PNG name. Unused by current spells.</summary>
public virtual string EmissionPngName => $"{Name.Replace(" ", "")}_Emission";

/// <summary>Legacy 1.x short identifier hook. Unused by current spells.</summary>
public virtual string ShortId => Name;

/// <summary>
/// The plugin that adds the spell.
/// </summary>
Expand Down
77 changes: 75 additions & 2 deletions Modules/Spells/SpellLogic.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using BlackMagicAPI.Network;
using System.Collections;
using System.Linq;
using System.Reflection;
using UnityEngine;

namespace BlackMagicAPI.Modules.Spells;
Expand Down Expand Up @@ -87,10 +89,81 @@ public void PlayerSetup(GameObject ownerobj, Vector3 fwdVector, int level) { }
/// <param name="viewDirectionVector">The direction vector of the player's view.</param>
/// <param name="castingLevel">The power level of the spell cast.</param>
/// <returns>
/// <c>true</c> if the spell was successfully cast and the page should go on cooldown;
/// <c>true</c> if the spell was successfully cast and the page should go on cooldown;
/// <c>false</c> if the spell failed or the page should not go on cooldown.
/// </returns>
public abstract bool CastSpell(PlayerMovement caster, PageController page, Vector3 spawnPos, Vector3 viewDirectionVector, int castingLevel);
/// <remarks>
/// This is intentionally <c>virtual</c> (not <c>abstract</c>): spells compiled against
/// BlackMagicAPI 1.x override the legacy <see cref="GameObject"/>-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.
/// </remarks>
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;
}

/// <summary>
/// Legacy (BlackMagicAPI 1.x) cast signature. Spells compiled against 1.x override this
/// <see cref="GameObject"/>-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 <see cref="InvokeCastSpell"/> routes the actual cast to whichever signature the spell
/// implements. Current spells override the <see cref="PlayerMovement"/> overload above and ignore this.
/// </summary>
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<Type, MethodInfo?> LegacyCastCache = [];

/// <summary>
/// Invokes the spell's cast logic, bridging spells compiled against older BlackMagicAPI versions.
/// </summary>
/// <remarks>
/// BlackMagicAPI 1.x declared <c>void CastSpell(GameObject, PageController, Vector3, Vector3, int)</c>.
/// 3.x changed it to <c>bool CastSpell(PlayerMovement, ...)</c>. 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
/// <c>GameObject</c>-based method so old spells still function. Returns true (page goes on cooldown)
/// when a legacy void method is used.
/// </remarks>
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);
}

/// <summary>
/// Virtual method for handling item-specific usage logic for spell page.
Expand Down
32 changes: 32 additions & 0 deletions Patches/Compatibility/MagicMissleControllerPatch.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using HarmonyLib;

namespace BlackMagicAPI.Patches.Compatibility;

/// <summary>
/// Defensive guard for the base-game homing missile.
/// <para>
/// <c>MagicMissleController</c> has no Awake/Start/OnEnable; it is initialized exclusively by
/// <c>SetUp()</c>/<c>AISetup()</c>, which assign <c>playerOwner</c> (and the target/forward vectors).
/// Custom-spell prefabs shipped by third-party mods sometimes carry a leftover
/// <c>MagicMissleController</c> component (cloned from the vanilla missile) that is never
/// <c>SetUp()</c>, so <c>playerOwner</c>/<c>rb</c> stay null and <c>Update()</c> throws a
/// NullReferenceException on every frame.
/// </para>
/// <para>
/// This prefix skips the homing logic until the missile has actually been initialized. Legitimately
/// cast missiles always have <c>playerOwner</c> assigned, so they are unaffected; only stray,
/// uninitialized instances are short-circuited.
/// </para>
/// </summary>
[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;
}
}
68 changes: 68 additions & 0 deletions Patches/Compatibility/MainMenuEscapePatch.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using HarmonyLib;
using System;
using UnityEngine;

namespace BlackMagicAPI.Patches.Compatibility;

/// <summary>
/// Makes the in-game pause/escape menu null-safe.
/// <para>
/// Vanilla <c>MainMenuManager.Update</c> is:
/// <c>if (Input.GetKeyDown(KeyCode.Escape) &amp;&amp; !SpellChooser.instance.isSettingPage) ToggleInGameMenu();</c>
/// After the player finishes the spell-loadout selection, the <c>SpellChooser</c> object can be
/// destroyed, leaving <c>SpellChooser.instance</c> null/destroyed. Dereferencing
/// <c>.isSettingPage</c> then throws every frame, so Escape never opens the pause menu.
/// </para>
/// <para>
/// This prefix reimplements the same behaviour but treats a missing <c>SpellChooser</c> as
/// "not selecting", so Escape always toggles the menu. When the chooser is alive it behaves
/// exactly like vanilla.
/// </para>
/// </summary>
[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;
}

/// <summary>
/// Keeps MainMenuManager alive when a third-party plugin's Awake patch throws.
/// <para>
/// MageConfigurationAPI (and potentially others) postfix-patch <c>MainMenuManager.Awake</c> and
/// throw a NullReferenceException because the game's menu UI hierarchy changed
/// (e.g. <c>Canvas (1)/Main/LobbyID/LobbiesMenu/...</c> no longer exists). When Awake throws,
/// Unity disables the whole MonoBehaviour, so <c>Update</c> never runs and Escape stops opening the
/// pause menu. This finalizer swallows the exception so Awake completes and the component stays enabled.
/// </para>
/// </summary>
[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;
}
}
2 changes: 1 addition & 1 deletion Patches/Items/PageControllerPatch.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PlayerMovement>(), __instance, spawnpos, fwdVector, level);
bool cooldown = spell.InvokeCastSpell(ownerobj.GetComponent<PlayerMovement>(), __instance, spawnpos, fwdVector, level);
if (!cooldown)
{
__instance.ReinstatePageEmis();
Expand Down
14 changes: 0 additions & 14 deletions Patches/Items/PaperInteractPatch.cs

This file was deleted.

4 changes: 3 additions & 1 deletion Patches/Soups/SoupManPatch.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ internal class SoupManControllerPatch
{
private static readonly ConditionalWeakTable<SoupManController, List<int>> 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)
Expand Down