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:
@@ -6,6 +6,9 @@ namespace SPTarkov.Reflection.Patching;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Harmony patch wrapper class. See mod example 6.1 for usage.
|
/// Harmony patch wrapper class. See mod example 6.1 for usage.
|
||||||
/// </summary>
|
/// </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
|
public abstract class AbstractPatch
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -18,15 +21,21 @@ public abstract class AbstractPatch
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsActive { get; private set; }
|
public bool IsActive { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Is this patch managed by the PatchManager?
|
||||||
|
/// </summary>
|
||||||
|
public bool IsManaged { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The harmony Id assigned to this patch, usually the name of the patch class.
|
/// The harmony Id assigned to this patch, usually the name of the patch class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string HarmonyId
|
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> _prefixList;
|
||||||
private readonly List<HarmonyMethod> _postfixList;
|
private readonly List<HarmonyMethod> _postfixList;
|
||||||
private readonly List<HarmonyMethod> _transpilerList;
|
private readonly List<HarmonyMethod> _transpilerList;
|
||||||
@@ -54,7 +63,7 @@ public abstract class AbstractPatch
|
|||||||
&& _ilManipulatorList.Count == 0
|
&& _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)
|
if (TargetMethod == null)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException($"{_harmony.Id}: TargetMethod is null");
|
throw new PatchException($"{HarmonyId}: TargetMethod is null");
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
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)
|
foreach (var prefix in _prefixList)
|
||||||
{
|
{
|
||||||
_harmony.Patch(TargetMethod, prefix: prefix);
|
_harmony!.Patch(TargetMethod, prefix: prefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var postfix in _postfixList)
|
foreach (var postfix in _postfixList)
|
||||||
{
|
{
|
||||||
_harmony.Patch(TargetMethod, postfix: postfix);
|
_harmony!.Patch(TargetMethod, postfix: postfix);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var transpiler in _transpilerList)
|
foreach (var transpiler in _transpilerList)
|
||||||
{
|
{
|
||||||
_harmony.Patch(TargetMethod, transpiler: transpiler);
|
_harmony!.Patch(TargetMethod, transpiler: transpiler);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var finalizer in _finalizerList)
|
foreach (var finalizer in _finalizerList)
|
||||||
{
|
{
|
||||||
_harmony.Patch(TargetMethod, finalizer: finalizer);
|
_harmony!.Patch(TargetMethod, finalizer: finalizer);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var ilmanipulator in _ilManipulatorList)
|
foreach (var ilmanipulator in _ilManipulatorList)
|
||||||
{
|
{
|
||||||
_harmony.Patch(TargetMethod, ilmanipulator: ilmanipulator);
|
_harmony!.Patch(TargetMethod, ilmanipulator: ilmanipulator);
|
||||||
}
|
}
|
||||||
|
|
||||||
ModPatchCache.AddPatch(this);
|
ModPatchCache.AddPatch(this);
|
||||||
@@ -135,10 +146,26 @@ public abstract class AbstractPatch
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
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>
|
/// <summary>
|
||||||
/// Remove applied patch from target
|
/// Remove applied patch from target
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -154,23 +181,44 @@ public abstract class AbstractPatch
|
|||||||
|
|
||||||
if (target == null)
|
if (target == null)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException($"{_harmony.Id}: TargetMethod is null");
|
throw new PatchException($"{HarmonyId}: TargetMethod is null");
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
throw new Exception($"{_harmony.Id}:", ex);
|
throw new PatchException($"{HarmonyId}:", ex);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ModPatchCache.RemovePatch(this))
|
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;
|
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)]
|
[AttributeUsage(AttributeTargets.Method)]
|
||||||
public class PatchIlManipulatorAttribute : Attribute { }
|
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>
|
<ItemGroup>
|
||||||
<PackageReference Include="HarmonyX" Version="2.14.0" />
|
<PackageReference Include="HarmonyX" Version="2.14.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\SPTarkov.DI\SPTarkov.DI.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Server.Kestrel.Https;
|
|||||||
using SPTarkov.Common.Semver;
|
using SPTarkov.Common.Semver;
|
||||||
using SPTarkov.Common.Semver.Implementations;
|
using SPTarkov.Common.Semver.Implementations;
|
||||||
using SPTarkov.DI;
|
using SPTarkov.DI;
|
||||||
|
using SPTarkov.Reflection.Patching;
|
||||||
using SPTarkov.Server.Core.Helpers;
|
using SPTarkov.Server.Core.Helpers;
|
||||||
using SPTarkov.Server.Core.Loaders;
|
using SPTarkov.Server.Core.Loaders;
|
||||||
using SPTarkov.Server.Core.Models.Spt.Config;
|
using SPTarkov.Server.Core.Models.Spt.Config;
|
||||||
@@ -46,6 +47,7 @@ public static class Program
|
|||||||
// register SPT components
|
// register SPT components
|
||||||
diHandler.AddInjectableTypesFromTypeAssembly(typeof(Program));
|
diHandler.AddInjectableTypesFromTypeAssembly(typeof(Program));
|
||||||
diHandler.AddInjectableTypesFromTypeAssembly(typeof(App));
|
diHandler.AddInjectableTypesFromTypeAssembly(typeof(App));
|
||||||
|
diHandler.AddInjectableTypesFromTypeAssembly(typeof(PatchManager));
|
||||||
|
|
||||||
List<SptMod> loadedMods = [];
|
List<SptMod> loadedMods = [];
|
||||||
if (ProgramStatics.MODS())
|
if (ProgramStatics.MODS())
|
||||||
|
|||||||
Reference in New Issue
Block a user