diff --git a/Libraries/SPTarkov.Reflection/Patching/AbstractPatch.cs b/Libraries/SPTarkov.Reflection/Patching/AbstractPatch.cs
index 49c0ae37..d9a9818b 100644
--- a/Libraries/SPTarkov.Reflection/Patching/AbstractPatch.cs
+++ b/Libraries/SPTarkov.Reflection/Patching/AbstractPatch.cs
@@ -6,6 +6,9 @@ namespace SPTarkov.Reflection.Patching;
///
/// Harmony patch wrapper class. See mod example 6.1 for usage.
///
+///
+/// A known limitation is that exceptions and logging are only sent to the console and are not color coded. There is no disk logging here.
+///
public abstract class AbstractPatch
{
///
@@ -18,15 +21,21 @@ public abstract class AbstractPatch
///
public bool IsActive { get; private set; }
+ ///
+ /// Is this patch managed by the PatchManager?
+ ///
+ public bool IsManaged { get; private set; }
+
///
/// The harmony Id assigned to this patch, usually the name of the patch class.
///
public string HarmonyId
{
- get { return _harmony.Id; }
+ get { return _harmony?.Id ?? "Harmony Id is null for this patch"; }
}
- private readonly Harmony _harmony;
+ private Harmony? _harmony;
+
private readonly List _prefixList;
private readonly List _postfixList;
private readonly List _transpilerList;
@@ -54,7 +63,7 @@ public abstract class AbstractPatch
&& _ilManipulatorList.Count == 0
)
{
- throw new Exception($"{_harmony.Id}: At least one of the patch methods must be specified");
+ throw new PatchException($"{HarmonyId}: At least one of the patch methods must be specified");
}
}
@@ -100,34 +109,36 @@ public abstract class AbstractPatch
if (TargetMethod == null)
{
- throw new InvalidOperationException($"{_harmony.Id}: TargetMethod is null");
+ throw new PatchException($"{HarmonyId}: TargetMethod is null");
}
try
{
+ // Using null forgiving operator here because we want to throw if _harmony is null, but want the compiler to shut up about it.
+
foreach (var prefix in _prefixList)
{
- _harmony.Patch(TargetMethod, prefix: prefix);
+ _harmony!.Patch(TargetMethod, prefix: prefix);
}
foreach (var postfix in _postfixList)
{
- _harmony.Patch(TargetMethod, postfix: postfix);
+ _harmony!.Patch(TargetMethod, postfix: postfix);
}
foreach (var transpiler in _transpilerList)
{
- _harmony.Patch(TargetMethod, transpiler: transpiler);
+ _harmony!.Patch(TargetMethod, transpiler: transpiler);
}
foreach (var finalizer in _finalizerList)
{
- _harmony.Patch(TargetMethod, finalizer: finalizer);
+ _harmony!.Patch(TargetMethod, finalizer: finalizer);
}
foreach (var ilmanipulator in _ilManipulatorList)
{
- _harmony.Patch(TargetMethod, ilmanipulator: ilmanipulator);
+ _harmony!.Patch(TargetMethod, ilmanipulator: ilmanipulator);
}
ModPatchCache.AddPatch(this);
@@ -135,10 +146,26 @@ public abstract class AbstractPatch
}
catch (Exception ex)
{
- throw new Exception($"{_harmony.Id}:", ex);
+ throw new Exception($"{HarmonyId}:", ex);
}
}
+ ///
+ /// Internal use only, called from the patch manager.
+ ///
+ /// Harmony instance of the patch manager
+ internal void Enable(Harmony harmony)
+ {
+ if (!ReferenceEquals(_harmony, harmony))
+ {
+ // Override the initial harmony instance with the PatchManagers instance
+ _harmony = harmony;
+ }
+
+ IsManaged = true;
+ Enable();
+ }
+
///
/// Remove applied patch from target
///
@@ -154,23 +181,44 @@ public abstract class AbstractPatch
if (target == null)
{
- throw new InvalidOperationException($"{_harmony.Id}: TargetMethod is null");
+ throw new PatchException($"{HarmonyId}: TargetMethod is null");
}
try
{
- _harmony.Unpatch(target, HarmonyPatchType.All, _harmony.Id);
+ // Using null forgiving operator here because we want to throw if _harmony is null, but want the compiler to shut up about it.
+ _harmony!.Unpatch(target, HarmonyPatchType.All, _harmony.Id);
}
catch (Exception ex)
{
- throw new Exception($"{_harmony.Id}:", ex);
+ throw new PatchException($"{HarmonyId}:", ex);
}
if (!ModPatchCache.RemovePatch(this))
{
- throw new Exception($"{_harmony.Id}: Target patch not present in cache, a mod is likely externally altering it.");
+ throw new PatchException($"{HarmonyId}: Target patch not present in cache, a mod is likely externally altering it.");
}
IsActive = false;
}
+
+ ///
+ /// Internal use only, called from the patch manager.
+ ///
+ /// Harmony instance of the patch manager
+ internal void Disable(Harmony harmony)
+ {
+ // Attempting to disable a patch that is not managed by the patch manager
+ if (harmony is null || !ReferenceEquals(_harmony, harmony))
+ {
+ throw new PatchException(
+ $"Patch: {GetType().Name} is attempting to be disabled internally while not managed by the patch manager."
+ );
+ }
+
+ Disable();
+
+ // This patch is no longer considered managed.
+ IsManaged = false;
+ }
}
diff --git a/Libraries/SPTarkov.Reflection/Patching/Attributes.cs b/Libraries/SPTarkov.Reflection/Patching/Attributes.cs
index 42c5c6fd..c38dac99 100644
--- a/Libraries/SPTarkov.Reflection/Patching/Attributes.cs
+++ b/Libraries/SPTarkov.Reflection/Patching/Attributes.cs
@@ -14,3 +14,15 @@ public class PatchFinalizerAttribute : Attribute { }
[AttributeUsage(AttributeTargets.Method)]
public class PatchIlManipulatorAttribute : Attribute { }
+
+///
+/// If added to a patch, it will not be used during auto patching
+///
+[AttributeUsage(AttributeTargets.Class)]
+public class IgnoreAutoPatchAttribute : Attribute;
+
+///
+/// If added to a patch, it will only be enabled during debug builds
+///
+[AttributeUsage(AttributeTargets.Class)]
+public class DebugPatchAttribute : Attribute;
diff --git a/Libraries/SPTarkov.Reflection/Patching/PatchException.cs b/Libraries/SPTarkov.Reflection/Patching/PatchException.cs
new file mode 100644
index 00000000..e05ad6ff
--- /dev/null
+++ b/Libraries/SPTarkov.Reflection/Patching/PatchException.cs
@@ -0,0 +1,10 @@
+namespace SPTarkov.Reflection.Patching;
+
+public class PatchException : Exception
+{
+ public PatchException(string message)
+ : base(message) { }
+
+ public PatchException(string message, Exception innerException)
+ : base(message, innerException) { }
+}
diff --git a/Libraries/SPTarkov.Reflection/Patching/PatchManager.cs b/Libraries/SPTarkov.Reflection/Patching/PatchManager.cs
new file mode 100644
index 00000000..2c19a925
--- /dev/null
+++ b/Libraries/SPTarkov.Reflection/Patching/PatchManager.cs
@@ -0,0 +1,268 @@
+using System.Diagnostics;
+using System.Reflection;
+using HarmonyLib;
+using SPTarkov.DI.Annotations;
+
+namespace SPTarkov.Reflection.Patching;
+
+///
+/// A manager for your patches. You MUST set the PatcherName property BEFORE enabling patches. This is used to identify your harmony instance.
+///
+///
+/// A known limitation is that exceptions and logging are only sent to the console and are not color coded. There is no disk logging here.
+///
+[Injectable]
+public class PatchManager
+{
+ ///
+ /// Patcher name to be assigned to the harmony instance this manager controls. MUST be set prior to patching
+ ///
+ public string? PatcherName { get; set; }
+
+ ///
+ /// Should the manager find and enable patches on its own?
+ ///
+ public bool AutoPatch { get; set; }
+
+ private Harmony? _harmony;
+ private readonly List _patches = [];
+
+ ///
+ /// Adds a single patch
+ ///
+ /// Patch to add
+ /// Thrown if autopatch is enabled. You cannot add patches during auto patching.
+ public void AddPatch(AbstractPatch patch)
+ {
+ if (AutoPatch)
+ {
+ throw new PatchException("You cannot manually add patches when using auto patching");
+ }
+
+ _patches.Add(patch);
+ }
+
+ ///
+ /// Adds a list of patches
+ ///
+ /// List of patches to add
+ /// Thrown if autopatch is enabled. You cannot add patches during auto patching.
+ public void AddPatches(List patchList)
+ {
+ if (AutoPatch)
+ {
+ throw new PatchException("You cannot manually add patches when using auto patching");
+ }
+
+ _patches.AddRange(patchList);
+ }
+
+ ///
+ /// Retrieves a list of types from the given assembly that inherit from ,
+ /// excluding those marked with and, in non-debug builds,
+ /// excluding those marked with .
+ ///
+ /// The assembly to scan for patch types.
+ ///
+ /// A list of types that inherit from and meet the filtering criteria.
+ ///
+ private List GetPatches(Assembly assembly)
+ {
+ List patches = [];
+
+ var baseType = typeof(AbstractPatch);
+ var ignoreAttrType = typeof(IgnoreAutoPatchAttribute);
+
+ foreach (var type in assembly.GetTypes())
+ {
+ if (type.BaseType != baseType)
+ {
+ continue;
+ }
+
+ if (type.IsDefined(ignoreAttrType, inherit: false))
+ {
+ continue;
+ }
+
+ // Assembly was not built in debug and this is a debug patch, skip it.
+ if (!IsAssemblyDebugBuild(assembly) && type.IsDefined(typeof(DebugPatchAttribute), inherit: false))
+ {
+ continue;
+ }
+
+ patches.Add(type);
+ }
+
+ return patches;
+ }
+
+ ///
+ /// Enables all patches, if is enabled it will find them automatically
+ ///
+ ///
+ /// Thrown if PatcherName was not set, or there are no patches found during auto patching, or there are no patches added manually.
+ ///
+ public void EnablePatches()
+ {
+ if (PatcherName is null)
+ {
+ throw new PatchException("You cannot enable patches without setting a PatcherName.");
+ }
+
+ _harmony ??= new Harmony(PatcherName);
+
+ if (AutoPatch)
+ {
+ var patches = GetPatches(Assembly.GetCallingAssembly());
+
+ if (patches.Count == 0)
+ {
+ throw new PatchException("Could not find any patches defined in the assembly during auto patching");
+ }
+
+ var successfulPatches = 0;
+ foreach (var type in patches)
+ {
+ try
+ {
+ ((AbstractPatch)Activator.CreateInstance(type)).Enable(_harmony);
+ successfulPatches++;
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Failed to init [{type.Name}]: {ex.Message}");
+ }
+ }
+
+ Console.WriteLine($"Enabled {successfulPatches} patches");
+ return;
+ }
+
+ if (_patches.Count == 0)
+ {
+ throw new PatchException("No patches have been added to enable. You must add them with AddPatches()");
+ }
+
+ // ReSharper disable once ForCanBeConvertedToForeach
+ for (var i = 0; i < _patches.Count; i++)
+ {
+ try
+ {
+ _patches[i].Enable(_harmony);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Failed to init [{_patches[i].GetType().Name}]: {ex.Message}");
+ }
+ }
+ }
+
+ ///
+ /// Disables all patches, if is enabled it will find them automatically
+ ///
+ ///
+ /// Thrown if there are no enabled patches, or no patches are found during auto patch disabling, or there were no patches added manually to disable.
+ ///
+ public void DisablePatches()
+ {
+ if (_harmony is null)
+ {
+ throw new PatchException("You cannot disable without first enabling patches. _harmony is null");
+ }
+
+ if (AutoPatch)
+ {
+ var patches = GetPatches(Assembly.GetCallingAssembly());
+
+ if (patches.Count == 0)
+ {
+ throw new PatchException("Could not find any patches defined in the assembly during auto patching");
+ }
+
+ var disabledPatches = 0;
+ foreach (var type in patches)
+ {
+ try
+ {
+ ((AbstractPatch)Activator.CreateInstance(type)).Disable(_harmony);
+ disabledPatches++;
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Failed to disable [{type.Name}]: {ex.Message}");
+ }
+ }
+
+ Console.WriteLine($"Disabled {disabledPatches} patches");
+ return;
+ }
+
+ if (_patches.Count == 0)
+ {
+ throw new PatchException("There were no patches to disable");
+ }
+
+ // ReSharper disable once ForCanBeConvertedToForeach
+ for (var i = 0; i < _patches.Count; i++)
+ {
+ try
+ {
+ _patches[i].Disable(_harmony);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Failed to disable [{_patches[i].GetType().Name}]: {ex.Message}");
+ }
+ }
+ }
+
+ ///
+ /// Enables a single patch
+ ///
+ ///
+ ///
+ /// Thrown if PatcherName was not set
+ ///
+ public void EnablePatch(AbstractPatch patch)
+ {
+ if (PatcherName is null || _harmony is null)
+ {
+ throw new PatchException("You cannot enable patches without setting a PatcherName.");
+ }
+
+ patch.Enable(_harmony);
+ }
+
+ ///
+ /// Disables a single patch
+ ///
+ ///
+ public void DisablePatch(AbstractPatch patch)
+ {
+ if (!patch.IsActive)
+ {
+ Console.WriteLine($"Cannot disable patch: {patch.HarmonyId} because it is not active");
+ return;
+ }
+
+ if (_harmony is null)
+ {
+ throw new PatchException("You cannot disable without first enabling patches. _harmony is null");
+ }
+
+ patch.Disable(_harmony);
+ }
+
+ ///
+ /// Check if an assembly is built in debug mode
+ ///
+ /// Assembly to check
+ /// True if debug mode
+ private bool IsAssemblyDebugBuild(Assembly assembly)
+ {
+ var debugAttr = assembly.GetCustomAttribute();
+
+ return debugAttr != null && debugAttr.IsJITOptimizerDisabled;
+ }
+}
diff --git a/Libraries/SPTarkov.Reflection/SPTarkov.Reflection.csproj b/Libraries/SPTarkov.Reflection/SPTarkov.Reflection.csproj
index 74c72f86..3ee0c172 100644
--- a/Libraries/SPTarkov.Reflection/SPTarkov.Reflection.csproj
+++ b/Libraries/SPTarkov.Reflection/SPTarkov.Reflection.csproj
@@ -19,4 +19,7 @@
+
+
+
diff --git a/SPTarkov.Server/Program.cs b/SPTarkov.Server/Program.cs
index d16fb171..60dce127 100644
--- a/SPTarkov.Server/Program.cs
+++ b/SPTarkov.Server/Program.cs
@@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Server.Kestrel.Https;
using SPTarkov.Common.Semver;
using SPTarkov.Common.Semver.Implementations;
using SPTarkov.DI;
+using SPTarkov.Reflection.Patching;
using SPTarkov.Server.Core.Helpers;
using SPTarkov.Server.Core.Loaders;
using SPTarkov.Server.Core.Models.Spt.Config;
@@ -46,6 +47,7 @@ public static class Program
// register SPT components
diHandler.AddInjectableTypesFromTypeAssembly(typeof(Program));
diHandler.AddInjectableTypesFromTypeAssembly(typeof(App));
+ diHandler.AddInjectableTypesFromTypeAssembly(typeof(PatchManager));
List loadedMods = [];
if (ProgramStatics.MODS())