Implement PatchManager (#585)

* First pass at Fika's PatchManager implementation

* add comments

---------

Co-authored-by: Chomp <27521899+chompDev@users.noreply.github.com>
This commit is contained in:
Cj
2025-09-05 05:08:33 -04:00
committed by GitHub
parent fafbfeb291
commit ed05faa96f
6 changed files with 357 additions and 14 deletions
@@ -6,6 +6,9 @@ namespace SPTarkov.Reflection.Patching;
/// <summary>
/// Harmony patch wrapper class. See mod example 6.1 for usage.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public abstract class AbstractPatch
{
/// <summary>
@@ -18,15 +21,21 @@ public abstract class AbstractPatch
/// </summary>
public bool IsActive { get; private set; }
/// <summary>
/// Is this patch managed by the PatchManager?
/// </summary>
public bool IsManaged { get; private set; }
/// <summary>
/// The harmony Id assigned to this patch, usually the name of the patch class.
/// </summary>
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<HarmonyMethod> _prefixList;
private readonly List<HarmonyMethod> _postfixList;
private readonly List<HarmonyMethod> _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);
}
}
/// <summary>
/// Internal use only, called from the patch manager.
/// </summary>
/// <param name="harmony">Harmony instance of the patch manager</param>
internal void Enable(Harmony harmony)
{
if (!ReferenceEquals(_harmony, harmony))
{
// Override the initial harmony instance with the PatchManagers instance
_harmony = harmony;
}
IsManaged = true;
Enable();
}
/// <summary>
/// Remove applied patch from target
/// </summary>
@@ -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;
}
/// <summary>
/// Internal use only, called from the patch manager.
/// </summary>
/// <param name="harmony">Harmony instance of the patch manager</param>
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;
}
}
@@ -14,3 +14,15 @@ public class PatchFinalizerAttribute : Attribute { }
[AttributeUsage(AttributeTargets.Method)]
public class PatchIlManipulatorAttribute : Attribute { }
/// <summary>
/// If added to a patch, it will not be used during auto patching
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
public class IgnoreAutoPatchAttribute : Attribute;
/// <summary>
/// If added to a patch, it will only be enabled during debug builds
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
public class DebugPatchAttribute : Attribute;
@@ -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) { }
}
@@ -0,0 +1,268 @@
using System.Diagnostics;
using System.Reflection;
using HarmonyLib;
using SPTarkov.DI.Annotations;
namespace SPTarkov.Reflection.Patching;
/// <summary>
/// A manager for your patches. You MUST set the PatcherName property BEFORE enabling patches. This is used to identify your harmony instance.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[Injectable]
public class PatchManager
{
/// <summary>
/// Patcher name to be assigned to the harmony instance this manager controls. MUST be set prior to patching
/// </summary>
public string? PatcherName { get; set; }
/// <summary>
/// Should the manager find and enable patches on its own?
/// </summary>
public bool AutoPatch { get; set; }
private Harmony? _harmony;
private readonly List<AbstractPatch> _patches = [];
/// <summary>
/// Adds a single patch
/// </summary>
/// <param name="patch">Patch to add</param>
/// <exception cref="PatchException"> Thrown if autopatch is enabled. You cannot add patches during auto patching. </exception>
public void AddPatch(AbstractPatch patch)
{
if (AutoPatch)
{
throw new PatchException("You cannot manually add patches when using auto patching");
}
_patches.Add(patch);
}
/// <summary>
/// Adds a list of patches
/// </summary>
/// <param name="patchList">List of patches to add</param>
/// <exception cref="PatchException"> Thrown if autopatch is enabled. You cannot add patches during auto patching. </exception>
public void AddPatches(List<AbstractPatch> patchList)
{
if (AutoPatch)
{
throw new PatchException("You cannot manually add patches when using auto patching");
}
_patches.AddRange(patchList);
}
/// <summary>
/// Retrieves a list of types from the given assembly that inherit from <see cref="AbstractPatch"/>, <br/>
/// excluding those marked with <see cref="IgnoreAutoPatchAttribute"/> and, in non-debug builds, <br/>
/// excluding those marked with <see cref="DebugPatchAttribute"/>.
/// </summary>
/// <param name="assembly">The assembly to scan for patch types.</param>
/// <returns>
/// A list of types that inherit from <see cref="AbstractPatch"/> and meet the filtering criteria.
/// </returns>
private List<Type> GetPatches(Assembly assembly)
{
List<Type> 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;
}
/// <summary>
/// Enables all patches, if <see cref="AutoPatch"/> is enabled it will find them automatically
/// </summary>
/// <exception cref="PatchException">
/// Thrown if PatcherName was not set, or there are no patches found during auto patching, or there are no patches added manually.
/// </exception>
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}");
}
}
}
/// <summary>
/// Disables all patches, if <see cref="AutoPatch"/> is enabled it will find them automatically
/// </summary>
/// <exception cref="PatchException">
/// 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.
/// </exception>
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}");
}
}
}
/// <summary>
/// Enables a single patch
/// </summary>
/// <param name="patch"></param>
/// <exception cref="PatchException">
/// Thrown if PatcherName was not set
/// </exception>
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);
}
/// <summary>
/// Disables a single patch
/// </summary>
/// <param name="patch"></param>
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);
}
/// <summary>
/// Check if an assembly is built in debug mode
/// </summary>
/// <param name="assembly">Assembly to check</param>
/// <returns>True if debug mode</returns>
private bool IsAssemblyDebugBuild(Assembly assembly)
{
var debugAttr = assembly.GetCustomAttribute<DebuggableAttribute>();
return debugAttr != null && debugAttr.IsJITOptimizerDisabled;
}
}
@@ -19,4 +19,7 @@
<ItemGroup>
<PackageReference Include="HarmonyX" Version="2.14.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SPTarkov.DI\SPTarkov.DI.csproj" />
</ItemGroup>
</Project>
+2
View File
@@ -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<SptMod> loadedMods = [];
if (ProgramStatics.MODS())