diff --git a/BepInEx.Core/Contract/Attributes.cs b/BepInEx.Core/Contract/Attributes.cs index 93402b5..951b22b 100644 --- a/BepInEx.Core/Contract/Attributes.cs +++ b/BepInEx.Core/Contract/Attributes.cs @@ -308,6 +308,15 @@ namespace BepInEx public static IEnumerable GetAttributes(object plugin) where T : Attribute => GetAttributes(plugin.GetType()); + /// + /// Gets the specified attributes of a reflection metadata type, if they exist. + /// + /// The attribute type to retrieve. + /// The reflection metadata instance. + /// The attributes of the instance, if existing. + public static T[] GetAttributes(MemberInfo member) where T : Attribute => + (T[]) member.GetCustomAttributes(typeof(T), true); + /// /// Retrieves the dependencies of the specified plugin type. /// diff --git a/BepInEx.IL2CPP/Preloader.cs b/BepInEx.IL2CPP/Preloader.cs index 303a573..ddca8d1 100644 --- a/BepInEx.IL2CPP/Preloader.cs +++ b/BepInEx.IL2CPP/Preloader.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Diagnostics; using System.Globalization; using System.IO; @@ -6,6 +6,7 @@ using BepInEx.Configuration; using BepInEx.Logging; using BepInEx.Preloader.Core; using BepInEx.Preloader.Core.Logging; +using BepInEx.Preloader.Core.Patching; using UnhollowerBaseLib; using UnhollowerBaseLib.Runtime; @@ -64,11 +65,11 @@ namespace BepInEx.IL2CPP { assemblyPatcher.AddPatchersFromDirectory(Paths.PatcherPluginPath); - Log.LogInfo($"{assemblyPatcher.PatcherPlugins.Count} patcher plugin{(assemblyPatcher.PatcherPlugins.Count == 1 ? "" : "s")} loaded"); + Log.LogInfo($"{assemblyPatcher.PatcherContext.PatcherPlugins.Count} patcher plugin{(assemblyPatcher.PatcherContext.PatcherPlugins.Count == 1 ? "" : "s")} loaded"); assemblyPatcher.LoadAssemblyDirectories(IL2CPPUnhollowedPath); - Log.LogInfo($"{assemblyPatcher.PatcherPlugins.Count} assemblies discovered"); + Log.LogInfo($"{assemblyPatcher.PatcherContext.PatcherPlugins.Count} assemblies discovered"); assemblyPatcher.PatchAndLoad(); } diff --git a/BepInEx.NetLauncher/NetPreloader.cs b/BepInEx.NetLauncher/NetPreloader.cs index 997b4e2..7ca1236 100644 --- a/BepInEx.NetLauncher/NetPreloader.cs +++ b/BepInEx.NetLauncher/NetPreloader.cs @@ -9,6 +9,7 @@ using BepInEx.Logging; using BepInEx.NetLauncher.RuntimeFixes; using BepInEx.Preloader.Core; using BepInEx.Preloader.Core.Logging; +using BepInEx.Preloader.Core.Patching; using MonoMod.Utils; namespace BepInEx.NetLauncher @@ -70,11 +71,11 @@ namespace BepInEx.NetLauncher { assemblyPatcher.AddPatchersFromDirectory(Paths.PatcherPluginPath); - Log.LogInfo($"{assemblyPatcher.PatcherPlugins.Count} patcher plugin(s) loaded"); + Log.LogInfo($"{assemblyPatcher.PatcherContext.PatchDefinitions.Count} patcher definition(s) loaded"); assemblyPatcher.LoadAssemblyDirectories(new[] { Paths.GameRootPath }, new[] { "dll", "exe" }); - Log.LogInfo($"{assemblyPatcher.AssembliesToPatch.Count} assemblies discovered"); + Log.LogInfo($"{assemblyPatcher.PatcherContext.AvailableAssemblies.Count} assemblies discovered"); assemblyPatcher.PatchAndLoad(); @@ -82,7 +83,7 @@ namespace BepInEx.NetLauncher var assemblyName = AssemblyName.GetAssemblyName(executablePath); entrypointAssembly = - assemblyPatcher.LoadedAssemblies.Values.FirstOrDefault(x => x.FullName == assemblyName.FullName); + assemblyPatcher.PatcherContext.LoadedAssemblies.Values.FirstOrDefault(x => x.FullName == assemblyName.FullName); if (entrypointAssembly != null) { diff --git a/BepInEx.Preloader.Core/Patching/AssemblyPatcher.cs b/BepInEx.Preloader.Core/Patching/AssemblyPatcher.cs index 84de3e6..1f8d93f 100644 --- a/BepInEx.Preloader.Core/Patching/AssemblyPatcher.cs +++ b/BepInEx.Preloader.Core/Patching/AssemblyPatcher.cs @@ -1,123 +1,116 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Reflection; using System.Text; +using System.Text.RegularExpressions; using BepInEx.Bootstrap; using BepInEx.Configuration; using BepInEx.Logging; +using HarmonyLib; using Mono.Cecil; -namespace BepInEx.Preloader.Core +namespace BepInEx.Preloader.Core.Patching { - /// - /// Delegate used in patching assemblies. - /// - /// The assembly that is being patched. - public delegate void AssemblyPatcherDelegate(ref AssemblyDefinition assembly); - /// /// Worker class which is used for loading and patching entire folders of assemblies, or alternatively patching and /// loading assemblies one at a time. /// public class AssemblyPatcher : IDisposable { - private const BindingFlags ALL = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | - BindingFlags.IgnoreCase; - /// - /// A list of plugins that will be initialized and executed, in the order of the list. + /// The context of this assembly patcher instance that is passed to all patcher plugins. /// - public List PatcherPlugins { get; } = new(); - + public PatcherContext PatcherContext { get; } = new() + { + DumpedAssembliesPath = Path.Combine(Paths.BepInExRootPath, "DumpedAssemblies") + }; /// /// A cloned version of to ensure that any foreach loops do not break when the collection /// gets modified. /// - private IEnumerable PatcherPluginsSafe => PatcherPlugins.ToList(); + private IEnumerable PatcherPluginsSafe => PatcherContext.PatcherPlugins.ToList(); - /// - /// Contains a list of assemblies that will be patched and loaded into the runtime. - /// - /// The dictionary has the name of the file, without any directories. These are used by the dumping - /// functionality, and as such, these are also required to be unique. They do not have to be exactly the same as - /// the real filename, however they have to be mapped deterministically. - /// - /// Order is not respected, as it will be sorted by dependencies. - /// - public Dictionary AssembliesToPatch { get; } = new(); - - /// - /// Contains a dictionary of assemblies that have been loaded as part of executing this assembly patcher.. - /// - /// The key is the same key as used in , while the value is the actual assembly - /// itself. - /// - /// - public Dictionary LoadedAssemblies { get; } = new(); - - /// - /// The directory location as to where patched assemblies will be saved to and loaded from disk, for debugging - /// purposes. Defaults to BepInEx/DumpedAssemblies - /// - public string DumpedAssembliesPath { get; set; } = Path.Combine(Paths.BepInExRootPath, "DumpedAssemblies"); - - public ManualLogSource Logger { get; } = BepInEx.Logging.Logger.CreateLogSource("AssemblyPatcher"); + private ManualLogSource Logger { get; } = BepInEx.Logging.Logger.CreateLogSource("AssemblyPatcher"); /// /// Performs work to dispose collection objects. /// public void Dispose() { - foreach (var assembly in AssembliesToPatch) + foreach (var assembly in PatcherContext.AvailableAssemblies) assembly.Value.Dispose(); - AssembliesToPatch.Clear(); + PatcherContext.AvailableAssemblies.Clear(); // Clear to allow GC collection. - PatcherPlugins.Clear(); + PatcherContext.PatcherPlugins.Clear(); } - private static T CreateDelegate(MethodInfo method) where T : class => - method != null ? Delegate.CreateDelegate(typeof(T), method) as T : null; - - private static PatcherPlugin ToPatcherPlugin(TypeDefinition type, string assemblyPath) + private static Regex allowedGuidRegex { get; } = new(@"^[a-zA-Z0-9\._\-]+$"); + private PatcherPluginMetadata ToPatcherPlugin(TypeDefinition type, string assemblyPath) { if (type.IsInterface || type.IsAbstract && !type.IsSealed) return null; - var targetDlls = - type.Methods.FirstOrDefault(m => m.Name.Equals("get_TargetDLLs", - StringComparison.InvariantCultureIgnoreCase) && - m.IsPublic && - m.IsStatic); - - if (targetDlls == null || - targetDlls.ReturnType.FullName != "System.Collections.Generic.IEnumerable`1") + try + { + if (!type.IsSubtypeOf(typeof(BasePatcher))) + return null; + } + catch (AssemblyResolutionException) + { + // Can happen if this type inherits a type from an assembly that can't be found. Safe to assume it's not a plugin. return null; + } - var patch = type.Methods.FirstOrDefault(m => m.Name.Equals("Patch") && - m.IsPublic && - m.IsStatic && - m.ReturnType.FullName == "System.Void" && - m.Parameters.Count == 1 && - (m.Parameters[0].ParameterType.FullName == - "Mono.Cecil.AssemblyDefinition&" || - m.Parameters[0].ParameterType.FullName == - "Mono.Cecil.AssemblyDefinition")); + var metadata = PatcherPluginInfoAttribute.FromCecilType(type); - if (patch == null) + // Perform checks that will prevent the plugin from being loaded in ALL cases + if (metadata == null) + { + Logger.LogWarning($"Skipping over type [{type.FullName}] as no metadata attribute is specified"); return null; + } - return new PatcherPlugin + if (string.IsNullOrEmpty(metadata.GUID) || !allowedGuidRegex.IsMatch(metadata.GUID)) + { + Logger.LogWarning($"Skipping type [{type.FullName}] because its GUID [{metadata.GUID}] is of an illegal format"); + return null; + } + + if (metadata.Version == null) + { + Logger.LogWarning($"Skipping type [{type.FullName}] because its version is invalid"); + return null; + } + + if (metadata.Name == null) + { + Logger.LogWarning($"Skipping type [{type.FullName}] because its name is null"); + return null; + } + + return new PatcherPluginMetadata { TypeName = type.FullName }; } + private static readonly string CurrentAssemblyName = Assembly.GetExecutingAssembly().GetName().Name; + private bool HasPatcherPlugins(AssemblyDefinition ass) + { + if (ass.MainModule.AssemblyReferences.All(r => r.Name != CurrentAssemblyName) && ass.Name.Name != CurrentAssemblyName) + return false; + if (ass.MainModule.GetTypeReferences().All(r => r.FullName != typeof(BasePatcher).FullName)) + return false; + + return true; + } + /// /// Adds all patchers from all managed assemblies specified in a directory. /// @@ -127,9 +120,11 @@ namespace BepInEx.Preloader.Core if (!Directory.Exists(directory)) return; - var sortedPatchers = new SortedDictionary(); + var sortedPatchers = new List(); - var patchers = TypeLoader.FindPluginTypes(directory, ToPatcherPlugin); + var patchers = TypeLoader.FindPluginTypes(directory, ToPatcherPlugin, HasPatcherPlugins); + + // TODO: Add dependency ordering and process attribute filtering foreach (var keyValuePair in patchers) { @@ -139,69 +134,71 @@ namespace BepInEx.Preloader.Core if (patcherCollection.Count == 0) continue; - var ass = Assembly.LoadFile(assemblyPath); + var ass = Assembly.LoadFrom(assemblyPath); foreach (var patcherPlugin in patcherCollection) try { var type = ass.GetType(patcherPlugin.TypeName); - var methods = type.GetMethods(ALL); + var instance = (BasePatcher)Activator.CreateInstance(type); + instance.Context = PatcherContext; - patcherPlugin.Initializer = - CreateDelegate(methods.FirstOrDefault(m => m.Name.Equals("Initialize", - StringComparison - .InvariantCultureIgnoreCase) && - m.GetParameters().Length == 0 && - m.ReturnType == typeof(void))); + PatcherContext.PatcherPlugins.Add(instance); - patcherPlugin.Finalizer = - CreateDelegate(methods.FirstOrDefault(m => - m.Name.Equals("Finish", - StringComparison - .InvariantCultureIgnoreCase) && - m.GetParameters().Length == 0 && - m.ReturnType == typeof(void))); + var methods = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); - patcherPlugin.TargetDLLs = - CreateDelegate>>(type.GetProperty("TargetDLLs", ALL) - .GetGetMethod()); - - var patcher = - methods.FirstOrDefault(m => - m.Name.Equals("Patch", - StringComparison.CurrentCultureIgnoreCase) && - m.ReturnType == typeof(void) && - m.GetParameters().Length == 1 && - (m.GetParameters()[0].ParameterType == - typeof(AssemblyDefinition) || - m.GetParameters()[0].ParameterType == - typeof(AssemblyDefinition).MakeByRefType())); - - patcherPlugin.Patcher = (ref AssemblyDefinition pAss) => + foreach (var method in methods) { - //we do the array fuckery here to get the ref result out - object[] args = { pAss }; + var targetAssemblies = MetadataHelper.GetAttributes(method); + var targetTypes = MetadataHelper.GetAttributes(method); - patcher.Invoke(null, args); + if (targetAssemblies.Length == 0 && targetTypes.Length == 0) + continue; + + var parameters = method.GetParameters(); - pAss = (AssemblyDefinition) args[0]; - }; + if (parameters.Length < 1 || parameters.Length > 2 + // Next few lines ensure that the first parameter is AssemblyDefinition and does not have any + // target type attributes, and vice versa + || !( + (parameters[0].ParameterType == typeof(AssemblyDefinition) + || parameters[0].ParameterType == typeof(AssemblyDefinition).MakeByRefType() + && targetTypes.Length == 0) + || (parameters[0].ParameterType == typeof(TypeDefinition) + && targetAssemblies.Length == 0) + ) + || (parameters.Length == 2 && parameters[1].ParameterType != typeof(string)) + || (method.ReturnType != typeof(void) && method.ReturnType != typeof(bool)) + ) + { + Logger.LogWarning($"Skipping method [{method.FullDescription()}] as it is not a valid patcher method"); + continue; + } - sortedPatchers.Add($"{ass.GetName().Name}/{type.FullName}", patcherPlugin); + void AddDefinition(PatchDefinition definition) + { + Logger.LogDebug($"Discovered patch [{definition.FullName}]"); + sortedPatchers.Add(definition); + } + + foreach (var targetAssembly in targetAssemblies) + AddDefinition(new PatchDefinition(targetAssembly, instance, method)); + foreach (var targetType in targetTypes) + AddDefinition(new PatchDefinition(targetType, instance, method)); + } } catch (Exception e) { - Logger.LogError($"Failed to load patcher [{patcherPlugin.TypeName}]: {(e is ReflectionTypeLoadException re ? TypeLoader.TypeLoadExceptionToString(re) : e)}"); + Logger.LogError($"Failed to load patchers from type [{patcherPlugin.TypeName}]: {(e is ReflectionTypeLoadException re ? TypeLoader.TypeLoadExceptionToString(re) : e)}"); } var assName = ass.GetName(); Logger.Log(patcherCollection.Any() ? LogLevel.Info : LogLevel.Debug, - $"Loaded {patcherCollection.Count} patcher method{(patcherCollection.Count == 1 ? "" : "s")} from [{assName.Name} {assName.Version}]"); + $"Loaded {patcherCollection.Count} patcher type{(patcherCollection.Count == 1 ? "" : "s")} from [{assName.Name} {assName.Version}]"); } - foreach (var patcher in sortedPatchers) - PatcherPlugins.Add(patcher.Value); + PatcherContext.PatchDefinitions.AddRange(sortedPatchers); } @@ -233,14 +230,14 @@ namespace BepInEx.Preloader.Core // System has an assembly reference to itself, and it also has a reference to Mono.Security causing a circular dependency // It's also generally dangerous to change system.dll since so many things rely on it, // and it's already loaded into the appdomain since this loader references it, so we might as well skip it - if (assembly.Name.Name == "System" || assembly.Name.Name == "mscorlib" - ) //mscorlib is already loaded into the appdomain so it can't be patched + if (assembly.Name.Name == "System" + || assembly.Name.Name == "mscorlib") //mscorlib is already loaded into the appdomain so it can't be patched { assembly.Dispose(); continue; } - AssembliesToPatch.Add(Path.GetFileName(assemblyPath), assembly); + PatcherContext.AvailableAssemblies.Add(Path.GetFileName(assemblyPath), assembly); Logger.LogDebug($"Assembly loaded: {Path.GetFileName(assemblyPath)}"); @@ -276,25 +273,24 @@ namespace BepInEx.Preloader.Core } /// - /// Applies patchers to all assemblies in the given directory and loads patched assemblies into memory. + /// Applies patchers to all assemblies loaded into this assembly patcher and then loads patched assemblies into memory. /// - /// Directory to load CLR assemblies from. public void PatchAndLoad() { // First, create a copy of the assembly dictionary as the initializer can change them var assemblies = - new Dictionary(AssembliesToPatch, + new Dictionary(PatcherContext.AvailableAssemblies, StringComparer.InvariantCultureIgnoreCase); // Next, initialize all the patchers foreach (var assemblyPatcher in PatcherPluginsSafe) try { - assemblyPatcher.Initializer?.Invoke(); + assemblyPatcher.Initialize(); } catch (Exception ex) { - Logger.LogError($"Failed to run initializer of {assemblyPatcher.TypeName}: {ex}"); + Logger.LogError($"Failed to run initializer of {assemblyPatcher.Info.GUID}: {ex}"); } // Then, perform the actual patching @@ -305,39 +301,99 @@ namespace BepInEx.Preloader.Core // TODO: Maybe instead reload the assembly and repatch with other valid patchers? var invalidAssemblies = new HashSet(StringComparer.InvariantCultureIgnoreCase); - foreach (var assemblyPatcher in PatcherPluginsSafe) - foreach (var targetDll in assemblyPatcher.TargetDLLs()) - if (AssembliesToPatch.TryGetValue(targetDll, out var assembly) && - !invalidAssemblies.Contains(targetDll)) - { - Logger.LogInfo($"Patching [{assembly.Name.Name}] with [{assemblyPatcher.TypeName}]"); + Logger.LogMessage($"Executing {PatcherContext.PatchDefinitions.Count} patch(es)"); + foreach (var patchDefinition in PatcherContext.PatchDefinitions.ToList()) + { + string targetDll = patchDefinition.TargetAssembly?.TargetAssembly ?? + patchDefinition.TargetType.TargetAssembly; + + bool isAssemblyPatch = patchDefinition.TargetAssembly != null; + + if (targetDll == TargetAssemblyAttribute.AllAssemblies) + { + foreach (var kv in PatcherContext.AvailableAssemblies.ToList()) + { + if (invalidAssemblies.Contains(kv.Key)) + continue; + + RunPatcher(kv.Value, kv.Key); + } + } + else + { + if (!PatcherContext.AvailableAssemblies.TryGetValue(targetDll, out var assembly) + || invalidAssemblies.Contains(targetDll)) + continue; + + RunPatcher(assembly, targetDll); + } + + + bool RunPatcher(AssemblyDefinition assembly, string targetDll) + { try { - assemblyPatcher.Patcher?.Invoke(ref assembly); + var arguments = new object[patchDefinition.MethodInfo.GetParameters().Length]; + + if (!isAssemblyPatch) + { + var targetType = assembly.MainModule.Types.FirstOrDefault(x => x.FullName == patchDefinition.TargetType.TargetType); + + if (targetType == null) + { + Logger.LogWarning($"Unable to find type [{patchDefinition.TargetType.TargetType}] defined in {patchDefinition.MethodInfo.Name}. Skipping patcher"); //TODO: Proper name + return false; + } + + arguments[0] = targetType; + } + else + { + arguments[0] = assembly; + } + + if (arguments.Length > 1) + arguments[1] = targetDll; + + var result = patchDefinition.MethodInfo.Invoke(patchDefinition.Instance, arguments); + + if (patchDefinition.MethodInfo.ReturnType == typeof(void) + || patchDefinition.MethodInfo.ReturnType == typeof(bool) && (bool)result) + { + if (isAssemblyPatch) + { + assembly = (AssemblyDefinition)arguments[0]; + PatcherContext.AvailableAssemblies[targetDll] = assembly; + } + + patchedAssemblies.Add(targetDll); + } + + return true; } catch (Exception e) { - Logger.LogError($"Failed to run [{assemblyPatcher.TypeName}] when patching [{assembly.Name.Name}]. This assembly will not be patched. Error: {e}"); + Logger.LogError($"Failed to run [{patchDefinition.FullName}] when patching [{assembly.Name.Name}]. This assembly will not be patched. Error: {e}"); patchedAssemblies.Remove(targetDll); invalidAssemblies.Add(targetDll); - continue; - } - - AssembliesToPatch[targetDll] = assembly; - patchedAssemblies.Add(targetDll); - - foreach (var resolvedAss in AppDomain.CurrentDomain.GetAssemblies()) - { - var name = Utility.TryParseAssemblyName(resolvedAss.FullName, out var assName) - ? assName.Name - : resolvedAss.FullName; - - // Report only the first type that caused the assembly to load, because any subsequent ones can be false positives - if (!resolvedAssemblies.ContainsKey(name)) - resolvedAssemblies[name] = assemblyPatcher.TypeName; + return false; } } + + + + foreach (var resolvedAss in AppDomain.CurrentDomain.GetAssemblies()) + { + var name = Utility.TryParseAssemblyName(resolvedAss.FullName, out var assName) + ? assName.Name + : resolvedAss.FullName; + + // Report only the first type that caused the assembly to load, because any subsequent ones can be false positives + if (!resolvedAssemblies.ContainsKey(name)) + resolvedAssemblies[name] = patchDefinition.MethodInfo.DeclaringType.ToString(); + } + } // Check if any patched assemblies have been already resolved by the CLR // If there are any, they cannot be loaded by the preloader @@ -361,8 +417,8 @@ namespace BepInEx.Preloader.Core // Finally, load patched assemblies into memory if (ConfigDumpAssemblies.Value || ConfigLoadDumpedAssemblies.Value) { - if (!Directory.Exists(DumpedAssembliesPath)) - Directory.CreateDirectory(DumpedAssembliesPath); + if (!Directory.Exists(PatcherContext.DumpedAssembliesPath)) + Directory.CreateDirectory(PatcherContext.DumpedAssembliesPath); foreach (var kv in assemblies) { @@ -370,14 +426,14 @@ namespace BepInEx.Preloader.Core var assembly = kv.Value; if (patchedAssemblies.Contains(filename)) - assembly.Write(Path.Combine(DumpedAssembliesPath, filename)); + assembly.Write(Path.Combine(PatcherContext.DumpedAssembliesPath, filename)); } } if (ConfigBreakBeforeLoadAssemblies.Value) { Logger.LogInfo($"BepInEx is about load the following assemblies:\n{string.Join("\n", patchedAssemblies.ToArray())}"); - Logger.LogInfo($"The assemblies were dumped into {DumpedAssembliesPath}"); + Logger.LogInfo($"The assemblies were dumped into {PatcherContext.DumpedAssembliesPath}"); Logger.LogInfo("Load any assemblies into the debugger, set breakpoints and continue execution."); Debugger.Break(); } @@ -395,7 +451,7 @@ namespace BepInEx.Preloader.Core Assembly loadedAssembly; if (ConfigLoadDumpedAssemblies.Value) - loadedAssembly = Assembly.LoadFrom(Path.Combine(DumpedAssembliesPath, filename)); + loadedAssembly = Assembly.LoadFrom(Path.Combine(PatcherContext.DumpedAssembliesPath, filename)); else using (var assemblyStream = new MemoryStream()) { @@ -403,7 +459,7 @@ namespace BepInEx.Preloader.Core loadedAssembly = Assembly.Load(assemblyStream.ToArray()); } - LoadedAssemblies.Add(filename, loadedAssembly); + PatcherContext.LoadedAssemblies.Add(filename, loadedAssembly); Logger.LogDebug($"Loaded '{assembly.FullName}' into memory"); } @@ -416,11 +472,11 @@ namespace BepInEx.Preloader.Core foreach (var assemblyPatcher in PatcherPluginsSafe) try { - assemblyPatcher.Finalizer?.Invoke(); + assemblyPatcher.Finalizer(); } catch (Exception ex) { - Logger.LogError($"Failed to run finalizer of {assemblyPatcher.TypeName}: {ex}"); + Logger.LogError($"Failed to run finalizer of {assemblyPatcher.Info.GUID}: {ex}"); } } diff --git a/BepInEx.Preloader.Core/Patching/Attributes.cs b/BepInEx.Preloader.Core/Patching/Attributes.cs new file mode 100644 index 0000000..c269dd2 --- /dev/null +++ b/BepInEx.Preloader.Core/Patching/Attributes.cs @@ -0,0 +1,129 @@ +using System; +using System.Linq; +using Mono.Cecil; +using Version = SemVer.Version; + +namespace BepInEx.Preloader.Core.Patching +{ + /// + /// This attribute denotes that a class is a patcher plugin, and specifies the required metadata. + /// + [AttributeUsage(AttributeTargets.Class)] + public class PatcherPluginInfoAttribute : Attribute + { + /// The unique identifier of the plugin. Should not change between plugin versions. + /// The user friendly name of the plugin. Is able to be changed between versions. + /// The specific version of the plugin. + public PatcherPluginInfoAttribute(string GUID, string Name, string Version) + { + this.GUID = GUID; + this.Name = Name; + this.Version = TryParseLongVersion(Version); + } + + /// + /// The unique identifier of the plugin. Should not change between plugin versions. + /// + public string GUID { get; protected set; } + + + /// + /// The user friendly name of the plugin. Is able to be changed between versions. + /// + public string Name { get; protected set; } + + + /// + /// The specific version of the plugin. + /// + public Version Version { get; protected set; } + + private static Version TryParseLongVersion(string version) + { + if (Version.TryParse(version, out var v)) + return v; + + // no System.Version.TryParse() on .NET 3.5 + try + { + var longVersion = new System.Version(version); + + return new Version(longVersion.Major, longVersion.Minor, + longVersion.Build != -1 ? longVersion.Build : 0); + } + catch { } + + return null; + } + + internal static PatcherPluginInfoAttribute FromCecilType(TypeDefinition td) + { + var attr = MetadataHelper.GetCustomAttributes(td, false).FirstOrDefault(); + + if (attr == null) + return null; + + return new PatcherPluginInfoAttribute((string)attr.ConstructorArguments[0].Value, + (string)attr.ConstructorArguments[1].Value, + (string)attr.ConstructorArguments[2].Value); + } + + internal static PatcherPluginInfoAttribute FromType(Type type) + { + var attributes = type.GetCustomAttributes(typeof(PatcherPluginInfoAttribute), false); + + if (attributes.Length == 0) + return null; + + return (PatcherPluginInfoAttribute)attributes[0]; + } + } + + /// + /// Defines an assembly that a patch method will target. + /// + [AttributeUsage(AttributeTargets.Method)] + public class TargetAssemblyAttribute : Attribute + { + /// + /// Marker used to indicate all possible assemblies to be targeted by a patch method. + /// + public const string AllAssemblies = "_all"; + + /// + /// The short filename of the assembly to target. + /// + public string TargetAssembly { get; } + + /// The short filename of the assembly. Use to mark all possible assemblies as targets. + public TargetAssemblyAttribute(string targetAssembly) + { + TargetAssembly = targetAssembly; + } + } + + /// + /// Defines a type that a patch method will target. + /// + [AttributeUsage(AttributeTargets.Method)] + public class TargetTypeAttribute : Attribute + { + /// + /// The short filename of the assembly to target. + /// + public string TargetAssembly { get; } + + /// + /// The full name of the type to target for patching. + /// + public string TargetType { get; } + + /// The short filename of the assembly of which belongs to. + /// The full name of the type to target for patching. + public TargetTypeAttribute(string targetAssembly, string targetType) + { + TargetAssembly = targetAssembly; + TargetType = targetType; + } + } +} diff --git a/BepInEx.Preloader.Core/Patching/BasePatcher.cs b/BepInEx.Preloader.Core/Patching/BasePatcher.cs new file mode 100644 index 0000000..6910402 --- /dev/null +++ b/BepInEx.Preloader.Core/Patching/BasePatcher.cs @@ -0,0 +1,51 @@ +using BepInEx.Configuration; +using BepInEx.Logging; + +namespace BepInEx.Preloader.Core.Patching +{ + /// + /// A patcher that can contain multiple methods for patching assemblies. + /// + public abstract class BasePatcher + { + /// + /// A instance created for use by this patcher plugin. + /// + public ManualLogSource Log { get; } + + /// + /// A configuration file binding created with the of this plugin as the filename. + /// + public ConfigFile Config { get; } + + /// + /// Metadata associated with this patcher plugin. + /// + public PatcherPluginInfoAttribute Info { get; } + + /// + /// The context of the this BasePatcher is associated with. + /// + public PatcherContext Context { get; set; } + + protected BasePatcher() + { + Info = PatcherPluginInfoAttribute.FromType(GetType()); + + Log = Logger.CreateLogSource(Info.Name); + + Config = new ConfigFile(Utility.CombinePaths(Paths.ConfigPath, Info.GUID + ".cfg"), false, + new BepInPlugin(Info.GUID, Info.Name, Info.Version.ToString())); + } + + /// + /// Executed before any patches from any plugin are applied. + /// + public virtual void Initialize() { } + + /// + /// Executed after all patches from all plugins have been applied. + /// + public virtual void Finalizer() { } + } +} diff --git a/BepInEx.Preloader.Core/Patching/PatcherContext.cs b/BepInEx.Preloader.Core/Patching/PatcherContext.cs new file mode 100644 index 0000000..44f503e --- /dev/null +++ b/BepInEx.Preloader.Core/Patching/PatcherContext.cs @@ -0,0 +1,97 @@ +using System.Collections.Generic; +using System.Reflection; +using Mono.Cecil; + +namespace BepInEx.Preloader.Core.Patching +{ + /// + /// A definition of an individual patch for use by . + /// + public class PatchDefinition + { + /// + /// The assembly / assemblies this patch will target, if there any. + /// + public TargetAssemblyAttribute TargetAssembly { get; } = null; + + /// + /// The type / types this patch will target, if there are any. + /// + public TargetTypeAttribute TargetType { get; } = null; + + /// + /// The instance of the this originates from. + /// + public BasePatcher Instance { get; } + + /// + /// The method that will perform the patching logic defined by this instance. + /// + public MethodInfo MethodInfo { get; } + + /// + /// A friendly name for this patch definition, for use in logging and error tracking. + /// + public string FullName { get; } + + public PatchDefinition(TargetAssemblyAttribute targetAssembly, BasePatcher instance, MethodInfo methodInfo) + { + TargetAssembly = targetAssembly; + Instance = instance; + MethodInfo = methodInfo; + + FullName = $"{MethodInfo.DeclaringType.FullName}/{MethodInfo.Name} -> {TargetAssembly.TargetAssembly}"; + } + + public PatchDefinition(TargetTypeAttribute targetType, BasePatcher instance, MethodInfo methodInfo) + { + TargetType = targetType; + Instance = instance; + MethodInfo = methodInfo; + + FullName = $"{MethodInfo.DeclaringType.FullName}/{MethodInfo.Name} -> {TargetType.TargetAssembly}/{TargetType.TargetType}"; + } + } + + /// + /// Context provided to patcher plugins from the associated patcher engine. + /// + public class PatcherContext + { + /// + /// Contains a list of assemblies that will be patched and loaded into the runtime. + /// + /// The dictionary has the name of the file, without any directories. These are used by the dumping + /// functionality, and as such, these are also required to be unique. They do not have to be exactly the same as + /// the real filename, however they have to be mapped deterministically. + /// + /// Order is not respected, as it will be sorted by dependencies. + /// + public Dictionary AvailableAssemblies { get; } = new(); + + /// + /// Contains a dictionary of assemblies that have been loaded as part of executing this assembly patcher. + /// + /// The key is the same key as used in , while the value is the actual assembly + /// itself. + /// + /// + public Dictionary LoadedAssemblies { get; } = new(); + + /// + /// A list of plugins that will be initialized and executed, in the order of the list. + /// + public List PatcherPlugins { get; } = new(); + + /// + /// A list of individual patches that will execute, generated by parsing . + /// + public List PatchDefinitions { get; } = new(); + + /// + /// The directory location as to where patched assemblies will be saved to and loaded from disk, for debugging + /// purposes. Defaults to BepInEx/DumpedAssemblies + /// + public string DumpedAssembliesPath { get; internal set; } + } +} diff --git a/BepInEx.Preloader.Core/Patching/PatcherPlugin.cs b/BepInEx.Preloader.Core/Patching/PatcherPlugin.cs deleted file mode 100644 index 15aae06..0000000 --- a/BepInEx.Preloader.Core/Patching/PatcherPlugin.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using BepInEx.Bootstrap; - -namespace BepInEx.Preloader.Core -{ - /// - /// A single assembly patcher. - /// - public class PatcherPlugin : ICacheable - { - /// - /// Target assemblies to patch. - /// - public Func> TargetDLLs { get; set; } = null; - - /// - /// Initializer method that is run before any patching occurs. - /// - public Action Initializer { get; set; } = null; - - /// - /// Finalizer method that is run after all patching is done. - /// - public Action Finalizer { get; set; } = null; - - /// - /// The main patcher method that is called on every DLL defined in . - /// - public AssemblyPatcherDelegate Patcher { get; set; } = null; - - /// - /// Type name of the patcher. - /// - public string TypeName { get; set; } = string.Empty; - - /// - public void Save(BinaryWriter bw) => bw.Write(TypeName); - - /// - public void Load(BinaryReader br) => TypeName = br.ReadString(); - } -} diff --git a/BepInEx.Preloader.Core/Patching/PatcherPluginMetadata.cs b/BepInEx.Preloader.Core/Patching/PatcherPluginMetadata.cs new file mode 100644 index 0000000..fcdc63c --- /dev/null +++ b/BepInEx.Preloader.Core/Patching/PatcherPluginMetadata.cs @@ -0,0 +1,22 @@ +using System.IO; +using BepInEx.Bootstrap; + +namespace BepInEx.Preloader.Core.Patching +{ + /// + /// A single cached assembly patcher. + /// + internal class PatcherPluginMetadata : ICacheable + { + /// + /// Type name of the patcher. + /// + public string TypeName { get; set; } = string.Empty; + + /// + public void Save(BinaryWriter bw) => bw.Write(TypeName); + + /// + public void Load(BinaryReader br) => TypeName = br.ReadString(); + } +} diff --git a/BepInEx.Preloader.Unity/EntrypointPatcher.cs b/BepInEx.Preloader.Unity/EntrypointPatcher.cs new file mode 100644 index 0000000..90f2618 --- /dev/null +++ b/BepInEx.Preloader.Unity/EntrypointPatcher.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using BepInEx.Bootstrap; +using BepInEx.Configuration; +using BepInEx.Preloader.Core.Patching; +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace BepInEx.Preloader.Unity +{ + [PatcherPluginInfo("io.bepinex.entrypointpatcher", "BepInEx Entrypoint", "1.0")] + internal class EntrypointPatcher : BasePatcher + { + private bool HasLoaded { get; set; } = false; + + [TargetAssembly(TargetAssemblyAttribute.AllAssemblies)] + public bool PatchEntrypoint(ref AssemblyDefinition assembly, string filename) + { + if (HasLoaded || filename != ConfigEntrypointAssembly.Value) + return false; + + if (assembly.MainModule.AssemblyReferences.Any(x => x.Name.Contains("BepInEx"))) + throw new + Exception("BepInEx has been detected to be patched! Please unpatch before using a patchless variant!"); + + var entrypointType = ConfigEntrypointType.Value; + var entrypointMethod = ConfigEntrypointMethod.Value; + + Log.LogDebug($"Hooking chainloader into {entrypointType}::{entrypointMethod}"); + + var isCctor = entrypointMethod.IsNullOrWhiteSpace() || entrypointMethod == ".cctor"; + + + var entryType = assembly.MainModule.Types.FirstOrDefault(x => x.Name == entrypointType); + + if (entryType == null) + throw new Exception("The entrypoint type is invalid! Please check your config/BepInEx.cfg file"); + + var chainloaderAssemblyPath = Path.Combine(Paths.BepInExAssemblyDirectory, "BepInEx.Unity.dll"); + + var readerParameters = new ReaderParameters + { + AssemblyResolver = TypeLoader.CecilResolver + }; + + using (var chainloaderAssemblyDefinition = + AssemblyDefinition.ReadAssembly(chainloaderAssemblyPath, readerParameters)) + { + var chainloaderType = + chainloaderAssemblyDefinition.MainModule.Types.First(x => x.Name == "UnityChainloader"); + + var originalStartMethod = chainloaderType.EnumerateAllMethods().First(x => x.Name == "StaticStart"); + + var startMethod = assembly.MainModule.ImportReference(originalStartMethod); + + var methods = new List(); + + if (isCctor) + { + var cctor = entryType.Methods.FirstOrDefault(m => m.IsConstructor && m.IsStatic); + + if (cctor == null) + { + cctor = new MethodDefinition(".cctor", + MethodAttributes.Static | MethodAttributes.Private + | MethodAttributes.HideBySig | MethodAttributes.SpecialName + | MethodAttributes.RTSpecialName, + assembly.MainModule.ImportReference(typeof(void))); + + entryType.Methods.Add(cctor); + var il = cctor.Body.GetILProcessor(); + il.Append(il.Create(OpCodes.Ret)); + } + + methods.Add(cctor); + } + else + { + methods.AddRange(entryType.Methods.Where(x => x.Name == entrypointMethod)); + } + + if (!methods.Any()) + throw new Exception("The entrypoint method is invalid! Please check your config.ini"); + + foreach (var method in methods) + { + var il = method.Body.GetILProcessor(); + + var ins = il.Body.Instructions.First(); + + il.InsertBefore(ins, + il.Create(OpCodes.Ldnull)); // gameExePath (always null, we initialize the Paths class in Entrypoint) + + il.InsertBefore(ins, + il.Create(OpCodes.Call, + startMethod)); // UnityChainloader.StaticStart(string gameExePath) + } + } + + HasLoaded = true; + + return true; + } + + public override void Finalizer() + { + if (!HasLoaded) + { + Log.LogFatal($"Failed to patch BepInEx chainloader into assembly '{ConfigEntrypointAssembly.Value}', either due to error or not being able to find it. Is it spelled correctly?"); + } + } + + private static readonly ConfigEntry ConfigEntrypointAssembly = ConfigFile.CoreConfig.Bind( + "Preloader.Entrypoint", "Assembly", + UnityPreloader.IsPostUnity2017 + ? "UnityEngine.CoreModule.dll" + : "UnityEngine.dll", + "The local filename of the assembly to target."); + + private static readonly ConfigEntry ConfigEntrypointType = ConfigFile.CoreConfig.Bind( + "Preloader.Entrypoint", "Type", + "Application", + "The name of the type in the entrypoint assembly to search for the entrypoint method."); + + private static readonly ConfigEntry ConfigEntrypointMethod = ConfigFile.CoreConfig.Bind( + "Preloader.Entrypoint", "Method", + ".cctor", + "The name of the method in the specified entrypoint assembly and type to hook and load Chainloader from."); + + } +} diff --git a/BepInEx.Preloader.Unity/UnityPreloader.cs b/BepInEx.Preloader.Unity/UnityPreloader.cs index b6467a6..078028f 100644 --- a/BepInEx.Preloader.Unity/UnityPreloader.cs +++ b/BepInEx.Preloader.Unity/UnityPreloader.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System; using System.ComponentModel; using System.Diagnostics; using System.IO; @@ -9,9 +8,8 @@ using BepInEx.Configuration; using BepInEx.Logging; using BepInEx.Preloader.Core; using BepInEx.Preloader.Core.Logging; +using BepInEx.Preloader.Core.Patching; using BepInEx.Preloader.RuntimeFixes; -using Mono.Cecil; -using Mono.Cecil.Cil; using MonoMod.Utils; namespace BepInEx.Preloader.Unity @@ -72,20 +70,14 @@ namespace BepInEx.Preloader.Unity using (var assemblyPatcher = new AssemblyPatcher()) { - assemblyPatcher.PatcherPlugins.Add(new PatcherPlugin - { - TargetDLLs = () => new[] { ConfigEntrypointAssembly.Value }, - Patcher = PatchEntrypoint, - TypeName = "BepInEx.Chainloader" - }); - + assemblyPatcher.AddPatchersFromDirectory(Paths.BepInExAssemblyDirectory); assemblyPatcher.AddPatchersFromDirectory(Paths.PatcherPluginPath); - Log.LogInfo($"{assemblyPatcher.PatcherPlugins.Count} patcher plugin{(assemblyPatcher.PatcherPlugins.Count == 1 ? "" : "s")} loaded"); + Log.LogInfo($"{assemblyPatcher.PatcherContext.PatcherPlugins.Count} patcher plugin{(assemblyPatcher.PatcherContext.PatcherPlugins.Count == 1 ? "" : "s")} loaded"); assemblyPatcher.LoadAssemblyDirectories(Paths.DllSearchPaths); - Log.LogInfo($"{assemblyPatcher.PatcherPlugins.Count} assemblies discovered"); + Log.LogInfo($"{assemblyPatcher.PatcherContext.AvailableAssemblies.Count} assemblies discovered"); assemblyPatcher.PatchAndLoad(); } @@ -133,91 +125,6 @@ namespace BepInEx.Preloader.Unity } } - /// - /// Inserts BepInEx's own chainloader entrypoint into UnityEngine. - /// - /// The assembly that will be attempted to be patched. - public static void PatchEntrypoint(ref AssemblyDefinition assembly) - { - if (assembly.MainModule.AssemblyReferences.Any(x => x.Name.Contains("BepInEx"))) - throw new - Exception("BepInEx has been detected to be patched! Please unpatch before using a patchless variant!"); - - var entrypointType = ConfigEntrypointType.Value; - var entrypointMethod = ConfigEntrypointMethod.Value; - - var isCctor = entrypointMethod.IsNullOrWhiteSpace() || entrypointMethod == ".cctor"; - - - var entryType = assembly.MainModule.Types.FirstOrDefault(x => x.Name == entrypointType); - - if (entryType == null) - throw new Exception("The entrypoint type is invalid! Please check your config/BepInEx.cfg file"); - - var chainloaderAssemblyPath = Path.Combine(Paths.BepInExAssemblyDirectory, "BepInEx.Unity.dll"); - - var readerParameters = new ReaderParameters - { - AssemblyResolver = TypeLoader.CecilResolver - }; - - using (var chainloaderAssemblyDefinition = - AssemblyDefinition.ReadAssembly(chainloaderAssemblyPath, readerParameters)) - { - var chainloaderType = - chainloaderAssemblyDefinition.MainModule.Types.First(x => x.Name == "UnityChainloader"); - - var originalStartMethod = chainloaderType.EnumerateAllMethods().First(x => x.Name == "StaticStart"); - - var startMethod = assembly.MainModule.ImportReference(originalStartMethod); - - var methods = new List(); - - if (isCctor) - { - var cctor = entryType.Methods.FirstOrDefault(m => m.IsConstructor && m.IsStatic); - - if (cctor == null) - { - cctor = new MethodDefinition(".cctor", - MethodAttributes.Static | MethodAttributes.Private | - MethodAttributes.HideBySig - | MethodAttributes.SpecialName | MethodAttributes.RTSpecialName, - assembly.MainModule.ImportReference(typeof(void))); - - entryType.Methods.Add(cctor); - var il = cctor.Body.GetILProcessor(); - il.Append(il.Create(OpCodes.Ret)); - } - - methods.Add(cctor); - } - else - { - methods.AddRange(entryType.Methods.Where(x => x.Name == entrypointMethod)); - } - - if (!methods.Any()) - throw new Exception("The entrypoint method is invalid! Please check your config.ini"); - - foreach (var method in methods) - { - var il = method.Body.GetILProcessor(); - - var ins = il.Body.Instructions.First(); - - il.InsertBefore(ins, - il - .Create(OpCodes - .Ldnull)); // gameExePath (always null, we initialize the Paths class in Entrypoint - - il.InsertBefore(ins, - il.Create(OpCodes.Call, - startMethod)); // UnityChainloader.StaticStart(string gameExePath) - } - } - } - /// /// Allocates a console window for use by BepInEx safely. /// @@ -280,21 +187,6 @@ namespace BepInEx.Preloader.Unity #region Config - private static readonly ConfigEntry ConfigEntrypointAssembly = ConfigFile.CoreConfig.Bind( - "Preloader.Entrypoint", "Assembly", - IsPostUnity2017 ? "UnityEngine.CoreModule.dll" : "UnityEngine.dll", - "The local filename of the assembly to target."); - - private static readonly ConfigEntry ConfigEntrypointType = ConfigFile.CoreConfig.Bind( - "Preloader.Entrypoint", "Type", - "Application", - "The name of the type in the entrypoint assembly to search for the entrypoint method."); - - private static readonly ConfigEntry ConfigEntrypointMethod = ConfigFile.CoreConfig.Bind( - "Preloader.Entrypoint", "Method", - ".cctor", - "The name of the method in the specified entrypoint assembly and type to hook and load Chainloader from."); - internal static readonly ConfigEntry ConfigApplyRuntimePatches = ConfigFile.CoreConfig.Bind( "Preloader", "ApplyRuntimePatches", true,