using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Reflection; using System.Text; using BepInEx.Bootstrap; using BepInEx.Configuration; using BepInEx.Logging; using Mono.Cecil; namespace BepInEx.Preloader.Core { /// /// 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. /// public List PatcherPlugins { get; } = new List(); /// /// 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 Dictionary(); /// /// 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 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) { 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") 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")); if (patch == null) return null; return new PatcherPlugin { TypeName = type.FullName }; } /// /// Adds all patchers from all managed assemblies specified in a directory. /// /// Directory to search patcher DLLs from. /// A function that locates assembly patchers in a given managed assembly. public void AddPatchersFromDirectory(string directory) { if (!Directory.Exists(directory)) return; var sortedPatchers = new SortedDictionary(); var patchers = TypeLoader.FindPluginTypes(directory, ToPatcherPlugin); foreach (var keyValuePair in patchers) { var assemblyPath = keyValuePair.Key; var patcherCollection = keyValuePair.Value; if(patcherCollection.Count == 0) continue; var ass = Assembly.LoadFile(assemblyPath); foreach (var patcherPlugin in patcherCollection) { try { var type = ass.GetType(patcherPlugin.TypeName); var methods = type.GetMethods(ALL); patcherPlugin.Initializer = CreateDelegate(methods.FirstOrDefault(m => m.Name.Equals("Initialize", StringComparison.InvariantCultureIgnoreCase) && m.GetParameters().Length == 0 && m.ReturnType == typeof(void))); patcherPlugin.Finalizer = CreateDelegate(methods.FirstOrDefault(m => m.Name.Equals("Finish", StringComparison.InvariantCultureIgnoreCase) && m.GetParameters().Length == 0 && m.ReturnType == typeof(void))); 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) => { //we do the array fuckery here to get the ref result out object[] args = { pAss }; patcher.Invoke(null, args); pAss = (AssemblyDefinition)args[0]; }; sortedPatchers.Add($"{ass.GetName().Name}/{type.FullName}", patcherPlugin); } catch (Exception e) { Logger.LogError($"Failed to load patcher [{patcherPlugin.TypeName}]: {e.Message}"); if (e is ReflectionTypeLoadException re) Logger.LogDebug(TypeLoader.TypeLoadExceptionToString(re)); else Logger.LogDebug(e.ToString()); } } Logger.Log(patcherCollection.Any() ? LogLevel.Info : LogLevel.Debug, $"Loaded {patcherCollection.Count} patcher methods from {ass.GetName().FullName}"); } foreach (KeyValuePair patcher in sortedPatchers) PatcherPlugins.Add(patcher.Value); } /// /// Adds all .dll assemblies in a directory to be patched and loaded by this patcher instance. Non-managed assemblies are skipped. /// /// The directory to search. public void LoadAssemblyDirectory(string directory) { LoadAssemblyDirectory(directory, "dll"); } /// /// Adds all assemblies in a directory to be patched and loaded by this patcher instance. Non-managed assemblies are skipped. /// /// The directory to search. /// The file extensions to attempt to load. public void LoadAssemblyDirectory(string directory, params string[] assemblyExtensions) { var filesToSearch = assemblyExtensions .SelectMany(ext => Directory.GetFiles(directory, "*." + ext, SearchOption.TopDirectoryOnly)); foreach (string assemblyPath in filesToSearch) { if (!TryLoadAssembly(assemblyPath, out var assembly)) continue; // NOTE: this is special cased here because the dependency handling for System.dll is a bit wonky // 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 { assembly.Dispose(); continue; } AssembliesToPatch.Add(Path.GetFileName(assemblyPath), assembly); Logger.LogDebug($"Assembly loaded: {Path.GetFileName(assemblyPath)}"); //if (UnityPatches.AssemblyLocations.ContainsKey(assembly.FullName)) //{ // Logger.LogWarning($"Tried to load duplicate assembly {Path.GetFileName(assemblyPath)} from Managed folder! Skipping..."); // continue; //} //assemblies.Add(Path.GetFileName(assemblyPath), assembly); //UnityPatches.AssemblyLocations.Add(assembly.FullName, Path.GetFullPath(assemblyPath)); } } /// /// Attempts to load a managed assembly as an . Returns true if successful. /// /// The path of the assembly. /// The loaded assembly. Null if not successful in loading. public static bool TryLoadAssembly(string path, out AssemblyDefinition assembly) { try { assembly = AssemblyDefinition.ReadAssembly(path); return true; } catch (BadImageFormatException) { // Not a managed assembly assembly = null; return false; } } /// /// Performs work to dispose collection objects. /// public void Dispose() { foreach (var assembly in AssembliesToPatch) assembly.Value.Dispose(); AssembliesToPatch.Clear(); // Clear to allow GC collection. PatcherPlugins.Clear(); } private static string GetAssemblyName(string fullName) { // We need to manually parse full name to avoid issues with encoding on mono try { return new AssemblyName(fullName).Name; } catch (Exception e) { return fullName; } } /// /// Applies patchers to all assemblies in the given directory and 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); // Next, initialize all the patchers foreach (var assemblyPatcher1 in PatcherPlugins) assemblyPatcher1.Initializer?.Invoke(); // Then, perform the actual patching var patchedAssemblies = new HashSet(); var resolvedAssemblies = new Dictionary(); foreach (var assemblyPatcher in PatcherPlugins) foreach (string targetDll in assemblyPatcher.TargetDLLs()) if (AssembliesToPatch.TryGetValue(targetDll, out var assembly)) { Logger.LogInfo($"Patching [{assembly.Name.Name}] with [{assemblyPatcher.TypeName}]"); assemblyPatcher.Patcher?.Invoke(ref assembly); AssembliesToPatch[targetDll] = assembly; patchedAssemblies.Add(targetDll); foreach (var resolvedAss in AppDomain.CurrentDomain.GetAssemblies()) { var name = GetAssemblyName(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; } } // Check if any patched assemblies have been already resolved by the CLR // If there are any, they cannot be loaded by the preloader var patchedAssemblyNames = new HashSet(assemblies.Where(kv => patchedAssemblies.Contains(kv.Key)).Select(kv => kv.Value.Name.Name)); var earlyLoadAssemblies = resolvedAssemblies.Where(kv => patchedAssemblyNames.Contains(kv.Key)).ToList(); if (earlyLoadAssemblies.Count != 0) { Logger.LogWarning(new StringBuilder() .AppendLine("The following assemblies have been loaded too early and will not be patched by preloader:") .AppendLine(string.Join(Environment.NewLine, earlyLoadAssemblies.Select(kv => $"* [{kv.Key}] (first loaded by [{kv.Value}])").ToArray())) .AppendLine("Expect unexpected behavior and issues with plugins and patchers not being loaded.") .ToString()); } // Finally, load patched assemblies into memory if (ConfigDumpAssemblies.Value || ConfigLoadDumpedAssemblies.Value) { if (!Directory.Exists(DumpedAssembliesPath)) Directory.CreateDirectory(DumpedAssembliesPath); foreach (KeyValuePair kv in assemblies) { string filename = kv.Key; var assembly = kv.Value; if (patchedAssemblies.Contains(filename)) assembly.Write(Path.Combine(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("Load any assemblies into the debugger, set breakpoints and continue execution."); Debugger.Break(); } foreach (var kv in assemblies) { string filename = kv.Key; var assembly = kv.Value; // Note that since we only *load* assemblies, they shouldn't trigger dependency loading // Not loading all assemblies is very important not only because of memory reasons, // but because some games *rely* on that because of messed up internal dependencies. if (patchedAssemblies.Contains(filename)) { if (ConfigLoadDumpedAssemblies.Value) Assembly.LoadFile(Path.Combine(DumpedAssembliesPath, filename)); else { using (var assemblyStream = new MemoryStream()) { assembly.Write(assemblyStream); Assembly.Load(assemblyStream.ToArray()); } } Logger.LogDebug($"Loaded '{assembly.FullName}' into memory"); } // Though we have to dispose of all assemblies regardless of them being patched or not assembly.Dispose(); } // Finally, run all finalizers foreach (var assemblyPatcher2 in PatcherPlugins) assemblyPatcher2.Finalizer?.Invoke(); } #region Config private static readonly ConfigEntry ConfigDumpAssemblies = ConfigFile.CoreConfig.Bind( "Preloader", "DumpAssemblies", false, "If enabled, BepInEx will save patched assemblies into BepInEx/DumpedAssemblies.\nThis can be used by developers to inspect and debug preloader patchers."); private static readonly ConfigEntry ConfigLoadDumpedAssemblies = ConfigFile.CoreConfig.Bind( "Preloader", "LoadDumpedAssemblies", false, "If enabled, BepInEx will load patched assemblies from BepInEx/DumpedAssemblies instead of memory.\nThis can be used to be able to load patched assemblies into debuggers like dnSpy.\nIf set to true, will override DumpAssemblies."); private static readonly ConfigEntry ConfigBreakBeforeLoadAssemblies = ConfigFile.CoreConfig.Bind( "Preloader", "BreakBeforeLoadAssemblies", false, "If enabled, BepInEx will call Debugger.Break() once before loading patched assemblies.\nThis can be used with debuggers like dnSpy to install breakpoints into patched assemblies before they are loaded."); #endregion } }