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; using MonoMod.Utils; namespace BepInEx.Preloader.Core.Patching; /// /// 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 static readonly string CurrentAssemblyName = Assembly.GetExecutingAssembly().GetName().Name; private Func assemblyLoader; private readonly List loadContexts = new(); private readonly List providers = new(); public AssemblyPatcher(Func assemblyLoader) { this.assemblyLoader = assemblyLoader; } /// /// The context of this assembly patcher instance that is passed to all patcher plugins. /// public PatcherContext PatcherContext { get; } = new() { DumpedAssembliesPath = Utility.CombinePaths(Paths.BepInExRootPath, "DumpedAssemblies", Paths.ProcessName) }; /// /// A cloned version of to ensure that any foreach loops do not break when the collection /// gets modified. /// private IEnumerable PatcherPluginsSafe => PatcherContext.PatcherPlugins.ToList(); private ManualLogSource Logger { get; } = BepInEx.Logging.Logger.CreateLogSource("AssemblyPatcher"); private static Regex allowedGuidRegex { get; } = new(@"^[a-zA-Z0-9\._\-]+$"); /// /// Performs work to dispose collection objects. /// public void Dispose() { foreach (var assembly in PatcherContext.AvailableAssemblies) assembly.Value.Dispose(); PatcherContext.AvailableAssemblies.Clear(); PatcherContext.AvailableAssembliesPaths.Clear(); // Clear to allow GC collection. PatcherContext.PatcherPlugins.Clear(); AppDomain.CurrentDomain.AssemblyResolve -= PatcherProvidersAssemblyResolver; } private PatcherPluginMetadata ToPatcherPlugin(TypeDefinition type, IPluginLoadContext loadContext, string assemblyPath) { if (type.IsInterface || type.IsAbstract && !type.IsSealed) return null; try { if (!type.IsSubtypeOf(typeof(T))) 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; } if (type.IsSubtypeOf(typeof(BasePatcherProvider))) return GetPatcherInfo(type, loadContext, PatcherProviderPluginInfoAttribute.FromCecilType(type)); return GetPatcherInfo(type, loadContext, PatcherPluginInfoAttribute.FromCecilType(type)); } private PatcherPluginMetadata GetPatcherInfo(TypeDefinition type, IPluginLoadContext loadContext, PatcherPluginInfoAttribute metadata) { // Perform checks that will prevent the plugin from being loaded in ALL cases if (metadata == null) { Logger.Log(LogLevel.Warning, $"Skipping over type [{type.FullName}] as no metadata attribute is specified"); return null; } if (string.IsNullOrEmpty(metadata.GUID) || !allowedGuidRegex.IsMatch(metadata.GUID)) { Logger.Log(LogLevel.Warning, $"Skipping type [{type.FullName}] because its GUID [{metadata.GUID}] is of an illegal format"); return null; } if (metadata.Version == null) { Logger.Log(LogLevel.Warning, $"Skipping type [{type.FullName}] because its version is invalid"); return null; } if (metadata.Name == null) { Logger.Log(LogLevel.Warning, $"Skipping type [{type.FullName}] because its name is null"); return null; } return new PatcherPluginMetadata { TypeName = type.FullName, LoadContext = loadContext }; } private bool HasPatcherType(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(T).FullName)) return false; return true; } /// /// Adds all patchers from all managed assemblies obtained from all patcher providers. /// public void AddPatchersFromProviders() { var allProviders = TypeLoader.FindPluginTypes(Paths.BepInExAssemblyDirectory, ToPatcherPlugin, HasPatcherType); var foundProviders = TypeLoader.FindPluginTypes(Paths.PatcherProviderPath, ToPatcherPlugin, HasPatcherType); allProviders.AddRange(foundProviders); AppDomain.CurrentDomain.AssemblyResolve += PatcherProvidersAssemblyResolver; foreach (var keyValuePair in allProviders) { var assemblyPath = keyValuePair.Key; var patcherCollection = keyValuePair.Value; if (patcherCollection.Count == 0) continue; var ass = Assembly.LoadFrom(assemblyPath); foreach (var patcherPlugin in patcherCollection) { try { var type = ass.GetType(patcherPlugin.TypeName); var instance = (BasePatcherProvider) Activator.CreateInstance(type); providers.Add(instance); loadContexts.AddRange(instance.GetPatchers()); } catch (Exception e) { Logger.Log(LogLevel.Error, $"Failed to load patchers from type [{patcherPlugin.TypeName}]: {(e is ReflectionTypeLoadException re ? TypeLoader.TypeLoadExceptionToString(re) : e.ToString())}"); } } } var patchers = TypeLoader.GetPluginsFromLoadContexts(loadContexts, ToPatcherPlugin, HasPatcherType); var sortedPatchers = new List(); // TODO: Add dependency ordering and process attribute filtering foreach (var patcherPlugin in patchers) { var ass = Assembly.Load(patcherPlugin.LoadContext.GetAssemblyData()); var patchDefinitions = LoadPatcherPlugin(ass, patcherPlugin); sortedPatchers.AddRange(patchDefinitions); var assName = ass.GetName(); Logger.Log(LogLevel.Debug, $"Loaded patcher {patcherPlugin.TypeName} from [{assName.Name} {assName.Version}]"); patcherPlugin.LoadContext.Dispose(); } PatcherContext.PatchDefinitions.AddRange(sortedPatchers); Logger.Log(LogLevel.Info, $"Loaded {sortedPatchers.Count} patcher{(sortedPatchers.Count > 0 ? "s" : "")}"); } private Assembly PatcherProvidersAssemblyResolver(object sender, ResolveEventArgs args) { BasePatcherProvider currentProvider = null; try { foreach (BasePatcherProvider provider in providers) { currentProvider = provider; var ass = provider.ResolveAssembly(args.Name); if (ass != null) return ass; } } catch (Exception e) { Logger.Log(LogLevel.Error, $"The assembly resolver of the patcher provider [{currentProvider?.Info.GUID}] " + $"threw an exception while resolving {args.Name}: {e}"); return null; } return null; } private List LoadPatcherPlugin(Assembly ass, PatcherPluginMetadata patcher) { var patchDefinitions = new List(); try { var type = ass.GetType(patcher.TypeName); var instance = (BasePatcher) Activator.CreateInstance(type); instance.Context = PatcherContext; PatcherContext.PatcherPlugins.Add(instance); var methods = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); foreach (var method in methods) { var targetAssemblies = MetadataHelper.GetAttributes(method); var targetTypes = MetadataHelper.GetAttributes(method); if (targetAssemblies.Length == 0 && targetTypes.Length == 0) continue; var parameters = method.GetParameters(); 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 .Log(LogLevel.Warning, $"Skipping method [{method.FullDescription()}] as it is not a valid patcher method"); continue; } void AddDefinition(PatchDefinition definition) { Logger.Log(LogLevel.Debug, $"Discovered patch [{definition.FullName}]"); patchDefinitions.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.Log(LogLevel.Error, $"Failed to load patchers from type [{patcher.TypeName}]: {(e is ReflectionTypeLoadException re ? TypeLoader.TypeLoadExceptionToString(re) : e.ToString())}"); } return patchDefinitions; } /// /// Adds all patchers from all managed assemblies specified in a directory. /// /// Directory to search patcher DLLs from. public void AddPatchersFromDirectory(string directory) { if (!Directory.Exists(directory)) return; var sortedPatchers = new List(); var patchers = TypeLoader.FindPluginTypes(directory, ToPatcherPlugin, HasPatcherType); // TODO: Add dependency ordering and process attribute filtering foreach (var keyValuePair in patchers) { var assemblyPath = keyValuePair.Key; var patcherCollection = keyValuePair.Value; if (patcherCollection.Count == 0) continue; var ass = Assembly.LoadFrom(assemblyPath); foreach (var patcherPlugin in patcherCollection) { var patchDefinitions = LoadPatcherPlugin(ass, patcherPlugin); sortedPatchers.AddRange(patchDefinitions); } var assName = ass.GetName(); Logger.Log(patcherCollection.Any() ? LogLevel.Info : LogLevel.Debug, $"Loaded {patcherCollection.Count} patcher type{(patcherCollection.Count == 1 ? "" : "s")} from [{assName.Name} {assName.Version}]"); } PatcherContext.PatchDefinitions.AddRange(sortedPatchers); } /// /// Adds all .dll assemblies in given directories to be patched and loaded by this patcher instance. Non-managed /// assemblies /// are skipped. /// /// The directories to search. public void LoadAssemblyDirectories(params string[] directories) => LoadAssemblyDirectories(directories, new[] { "dll" }); /// /// Adds all assemblies in given directories 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 LoadAssemblyDirectories(IEnumerable directories, IEnumerable assemblyExtensions) { var filesToSearch = assemblyExtensions .SelectMany(ext => Utility.GetUniqueFilesInDirectories(directories, $"*.{ext}")); foreach (var 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; } var fileName = Path.GetFileName(assemblyPath); PatcherContext.AvailableAssemblies.Add(fileName, assembly); PatcherContext.AvailableAssembliesPaths.Add(fileName, assemblyPath); 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, TypeLoader.ReaderParameters); return true; } catch (BadImageFormatException) { // Not a managed assembly assembly = null; return false; } } /// /// Applies patchers to all assemblies loaded into this assembly patcher and then loads patched assemblies into memory. /// public void PatchAndLoad() { // First, create a copy of the assembly dictionary as the initializer can change them var assemblies = new Dictionary(PatcherContext.AvailableAssemblies, StringComparer.InvariantCultureIgnoreCase); // Next, initialize all the patchers foreach (var assemblyPatcher in PatcherPluginsSafe) try { assemblyPatcher.Initialize(); } catch (Exception ex) { Logger.Log(LogLevel.Error, $"Failed to run initializer of {assemblyPatcher.Info.GUID}: {ex}"); } // Then, perform the actual patching var patchedAssemblies = new HashSet(StringComparer.InvariantCultureIgnoreCase); var resolvedAssemblies = new Dictionary(); // TODO: Maybe instead reload the assembly and repatch with other valid patchers? var invalidAssemblies = new HashSet(StringComparer.InvariantCultureIgnoreCase); Logger.Log(LogLevel.Message, $"Executing {PatcherContext.PatchDefinitions.Count} patch(es)"); foreach (var patchDefinition in PatcherContext.PatchDefinitions.ToList()) { var targetDll = patchDefinition.TargetAssembly?.TargetAssembly ?? patchDefinition.TargetType.TargetAssembly; var 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 { 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.Log(LogLevel.Error, $"Failed to run [{patchDefinition.FullName}] when patching [{assembly.Name.Name}]. This assembly will not be patched. Error: {e}"); patchedAssemblies.Remove(targetDll); invalidAssemblies.Add(targetDll); 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 var patchedAssemblyNames = new HashSet(assemblies.Where(kv => patchedAssemblies.Contains(kv.Key)).Select(kv => kv.Value.Name.Name), StringComparer.InvariantCultureIgnoreCase); var earlyLoadAssemblies = resolvedAssemblies.Where(kv => patchedAssemblyNames.Contains(kv.Key)).ToList(); if (earlyLoadAssemblies.Count != 0) Logger.Log(LogLevel.Warning, 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()); var dumpedAssemblyPaths = new Dictionary(); // Finally, load patched assemblies into memory if (ConfigDumpAssemblies.Value || ConfigLoadDumpedAssemblies.Value) { if (!Directory.Exists(PatcherContext.DumpedAssembliesPath)) Directory.CreateDirectory(PatcherContext.DumpedAssembliesPath); foreach (var kv in assemblies) { var filename = kv.Key; var name = Path.GetFileNameWithoutExtension(filename); var ext = Path.GetExtension(filename); var assembly = kv.Value; if (!patchedAssemblies.Contains(filename)) continue; for (var i = 0;; i++) { var postfix = i > 0 ? $"_{i}" : ""; var path = Path.Combine(PatcherContext.DumpedAssembliesPath, $"{name}{postfix}{ext}"); if (!Utility.TryOpenFileStream(path, FileMode.Create, out var fs)) continue; assembly.Write(fs); fs.Dispose(); dumpedAssemblyPaths[filename] = path; break; } } } if (ConfigBreakBeforeLoadAssemblies.Value) { Logger.Log(LogLevel.Info, $"BepInEx is about load the following assemblies:\n{string.Join("\n", patchedAssemblies.ToArray())}"); Logger.Log(LogLevel.Info, $"The assemblies were dumped into {PatcherContext.DumpedAssembliesPath}"); Logger.Log(LogLevel.Info, "Load any assemblies into the debugger, set breakpoints and continue execution."); Debugger.Break(); } foreach (var kv in assemblies) { var 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)) { Assembly loadedAssembly; if (ConfigLoadDumpedAssemblies.Value && dumpedAssemblyPaths.TryGetValue(filename, out var dumpedAssemblyPath)) { loadedAssembly = Assembly.LoadFrom(dumpedAssemblyPath); } else { using var assemblyStream = new MemoryStream(); assembly.Write(assemblyStream); loadedAssembly = assemblyLoader(assemblyStream.ToArray(), PatcherContext.AvailableAssembliesPaths[filename]); } PatcherContext.LoadedAssemblies.Add(filename, loadedAssembly); Logger.Log(LogLevel.Debug, $"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 assemblyPatcher in PatcherPluginsSafe) try { assemblyPatcher.Finalizer(); } catch (Exception ex) { Logger.Log(LogLevel.Error, $"Failed to run finalizer of {assemblyPatcher.Info.GUID}: {ex}"); } } #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 }