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())