AssemblyPatcher.cs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. using System;
  2. using System.Collections.Generic;
  3. using System.Diagnostics;
  4. using System.IO;
  5. using System.Linq;
  6. using System.Reflection;
  7. using System.Text;
  8. using BepInEx.Bootstrap;
  9. using BepInEx.Configuration;
  10. using BepInEx.Logging;
  11. using Mono.Cecil;
  12. namespace BepInEx.Preloader.Core
  13. {
  14. /// <summary>
  15. /// Delegate used in patching assemblies.
  16. /// </summary>
  17. /// <param name="assembly">The assembly that is being patched.</param>
  18. public delegate void AssemblyPatcherDelegate(ref AssemblyDefinition assembly);
  19. /// <summary>
  20. /// Worker class which is used for loading and patching entire folders of assemblies, or alternatively patching and
  21. /// loading assemblies one at a time.
  22. /// </summary>
  23. public class AssemblyPatcher : IDisposable
  24. {
  25. private const BindingFlags ALL = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.IgnoreCase;
  26. /// <summary>
  27. /// A list of plugins that will be initialized and executed, in the order of the list.
  28. /// </summary>
  29. public List<PatcherPlugin> PatcherPlugins { get; } = new List<PatcherPlugin>();
  30. /// <summary>
  31. /// <para>Contains a list of assemblies that will be patched and loaded into the runtime.</para>
  32. /// <para>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.</para>
  33. /// <para>Order is not respected, as it will be sorted by dependencies.</para>
  34. /// </summary>
  35. public Dictionary<string, AssemblyDefinition> AssembliesToPatch { get; } = new Dictionary<string, AssemblyDefinition>();
  36. /// <summary>
  37. /// <para>Contains a dictionary of assemblies that have been loaded as part of executing this assembly patcher..</para>
  38. /// <para>The key is the same key as used in <see cref="LoadedAssemblies"/>, while the value is the actual assembly itself.</para>
  39. /// </summary>
  40. public Dictionary<string, Assembly> LoadedAssemblies { get; } = new Dictionary<string, Assembly>();
  41. /// <summary>
  42. /// The directory location as to where patched assemblies will be saved to and loaded from disk, for debugging purposes. Defaults to BepInEx/DumpedAssemblies
  43. /// </summary>
  44. public string DumpedAssembliesPath { get; set; } = Path.Combine(Paths.BepInExRootPath, "DumpedAssemblies");
  45. public ManualLogSource Logger { get; } = BepInEx.Logging.Logger.CreateLogSource("AssemblyPatcher");
  46. private static T CreateDelegate<T>(MethodInfo method) where T : class => method != null ? Delegate.CreateDelegate(typeof(T), method) as T : null;
  47. private static PatcherPlugin ToPatcherPlugin(TypeDefinition type, string assemblyPath)
  48. {
  49. if (type.IsInterface || type.IsAbstract && !type.IsSealed)
  50. return null;
  51. var targetDlls = type.Methods.FirstOrDefault(m => m.Name.Equals("get_TargetDLLs", StringComparison.InvariantCultureIgnoreCase) &&
  52. m.IsPublic &&
  53. m.IsStatic);
  54. if (targetDlls == null ||
  55. targetDlls.ReturnType.FullName != "System.Collections.Generic.IEnumerable`1<System.String>")
  56. return null;
  57. var patch = type.Methods.FirstOrDefault(m => m.Name.Equals("Patch") &&
  58. m.IsPublic &&
  59. m.IsStatic &&
  60. m.ReturnType.FullName == "System.Void" &&
  61. m.Parameters.Count == 1 &&
  62. (m.Parameters[0].ParameterType.FullName == "Mono.Cecil.AssemblyDefinition&" ||
  63. m.Parameters[0].ParameterType.FullName == "Mono.Cecil.AssemblyDefinition"));
  64. if (patch == null)
  65. return null;
  66. return new PatcherPlugin
  67. {
  68. TypeName = type.FullName
  69. };
  70. }
  71. /// <summary>
  72. /// Adds all patchers from all managed assemblies specified in a directory.
  73. /// </summary>
  74. /// <param name="directory">Directory to search patcher DLLs from.</param>
  75. /// <param name="patcherLocator">A function that locates assembly patchers in a given managed assembly.</param>
  76. public void AddPatchersFromDirectory(string directory)
  77. {
  78. if (!Directory.Exists(directory))
  79. return;
  80. var sortedPatchers = new SortedDictionary<string, PatcherPlugin>();
  81. var patchers = TypeLoader.FindPluginTypes(directory, ToPatcherPlugin);
  82. foreach (var keyValuePair in patchers)
  83. {
  84. var assemblyPath = keyValuePair.Key;
  85. var patcherCollection = keyValuePair.Value;
  86. if(patcherCollection.Count == 0)
  87. continue;
  88. var ass = Assembly.LoadFile(assemblyPath);
  89. foreach (var patcherPlugin in patcherCollection)
  90. {
  91. try
  92. {
  93. var type = ass.GetType(patcherPlugin.TypeName);
  94. var methods = type.GetMethods(ALL);
  95. patcherPlugin.Initializer = CreateDelegate<Action>(methods.FirstOrDefault(m => m.Name.Equals("Initialize", StringComparison.InvariantCultureIgnoreCase) &&
  96. m.GetParameters().Length == 0 &&
  97. m.ReturnType == typeof(void)));
  98. patcherPlugin.Finalizer = CreateDelegate<Action>(methods.FirstOrDefault(m => m.Name.Equals("Finish", StringComparison.InvariantCultureIgnoreCase) &&
  99. m.GetParameters().Length == 0 &&
  100. m.ReturnType == typeof(void)));
  101. patcherPlugin.TargetDLLs = CreateDelegate<Func<IEnumerable<string>>>(type.GetProperty("TargetDLLs", ALL).GetGetMethod());
  102. var patcher = methods.FirstOrDefault(m => m.Name.Equals("Patch", StringComparison.CurrentCultureIgnoreCase) &&
  103. m.ReturnType == typeof(void) &&
  104. m.GetParameters().Length == 1 &&
  105. (m.GetParameters()[0].ParameterType == typeof(AssemblyDefinition) ||
  106. m.GetParameters()[0].ParameterType == typeof(AssemblyDefinition).MakeByRefType()));
  107. patcherPlugin.Patcher = (ref AssemblyDefinition pAss) =>
  108. {
  109. //we do the array fuckery here to get the ref result out
  110. object[] args = { pAss };
  111. patcher.Invoke(null, args);
  112. pAss = (AssemblyDefinition)args[0];
  113. };
  114. sortedPatchers.Add($"{ass.GetName().Name}/{type.FullName}", patcherPlugin);
  115. }
  116. catch (Exception e)
  117. {
  118. Logger.LogError($"Failed to load patcher [{patcherPlugin.TypeName}]: {e.Message}");
  119. if (e is ReflectionTypeLoadException re)
  120. Logger.LogDebug(TypeLoader.TypeLoadExceptionToString(re));
  121. else
  122. Logger.LogDebug(e.ToString());
  123. }
  124. }
  125. Logger.Log(patcherCollection.Any() ? LogLevel.Info : LogLevel.Debug,
  126. $"Loaded {patcherCollection.Count} patcher methods from {ass.GetName().FullName}");
  127. }
  128. foreach (KeyValuePair<string, PatcherPlugin> patcher in sortedPatchers)
  129. PatcherPlugins.Add(patcher.Value);
  130. }
  131. /// <summary>
  132. /// Adds all .dll assemblies in a directory to be patched and loaded by this patcher instance. Non-managed assemblies are skipped.
  133. /// </summary>
  134. /// <param name="directory">The directory to search.</param>
  135. public void LoadAssemblyDirectory(string directory)
  136. {
  137. LoadAssemblyDirectory(directory, "dll");
  138. }
  139. /// <summary>
  140. /// Adds all assemblies in a directory to be patched and loaded by this patcher instance. Non-managed assemblies are skipped.
  141. /// </summary>
  142. /// <param name="directory">The directory to search.</param>
  143. /// <param name="assemblyExtensions">The file extensions to attempt to load.</param>
  144. public void LoadAssemblyDirectory(string directory, params string[] assemblyExtensions)
  145. {
  146. var filesToSearch = assemblyExtensions
  147. .SelectMany(ext => Directory.GetFiles(directory, "*." + ext, SearchOption.TopDirectoryOnly));
  148. foreach (string assemblyPath in filesToSearch)
  149. {
  150. if (!TryLoadAssembly(assemblyPath, out var assembly))
  151. continue;
  152. // NOTE: this is special cased here because the dependency handling for System.dll is a bit wonky
  153. // System has an assembly reference to itself, and it also has a reference to Mono.Security causing a circular dependency
  154. // It's also generally dangerous to change system.dll since so many things rely on it,
  155. // and it's already loaded into the appdomain since this loader references it, so we might as well skip it
  156. if (assembly.Name.Name == "System" || assembly.Name.Name == "mscorlib") //mscorlib is already loaded into the appdomain so it can't be patched
  157. {
  158. assembly.Dispose();
  159. continue;
  160. }
  161. AssembliesToPatch.Add(Path.GetFileName(assemblyPath), assembly);
  162. Logger.LogDebug($"Assembly loaded: {Path.GetFileName(assemblyPath)}");
  163. //if (UnityPatches.AssemblyLocations.ContainsKey(assembly.FullName))
  164. //{
  165. // Logger.LogWarning($"Tried to load duplicate assembly {Path.GetFileName(assemblyPath)} from Managed folder! Skipping...");
  166. // continue;
  167. //}
  168. //assemblies.Add(Path.GetFileName(assemblyPath), assembly);
  169. //UnityPatches.AssemblyLocations.Add(assembly.FullName, Path.GetFullPath(assemblyPath));
  170. }
  171. }
  172. /// <summary>
  173. /// Attempts to load a managed assembly as an <see cref="AssemblyDefinition"/>. Returns true if successful.
  174. /// </summary>
  175. /// <param name="path">The path of the assembly.</param>
  176. /// <param name="assembly">The loaded assembly. Null if not successful in loading.</param>
  177. public static bool TryLoadAssembly(string path, out AssemblyDefinition assembly)
  178. {
  179. try
  180. {
  181. assembly = AssemblyDefinition.ReadAssembly(path);
  182. return true;
  183. }
  184. catch (BadImageFormatException)
  185. {
  186. // Not a managed assembly
  187. assembly = null;
  188. return false;
  189. }
  190. }
  191. /// <summary>
  192. /// Performs work to dispose collection objects.
  193. /// </summary>
  194. public void Dispose()
  195. {
  196. foreach (var assembly in AssembliesToPatch)
  197. assembly.Value.Dispose();
  198. AssembliesToPatch.Clear();
  199. // Clear to allow GC collection.
  200. PatcherPlugins.Clear();
  201. }
  202. private static string GetAssemblyName(string fullName)
  203. {
  204. // We need to manually parse full name to avoid issues with encoding on mono
  205. try
  206. {
  207. return new AssemblyName(fullName).Name;
  208. }
  209. catch (Exception e)
  210. {
  211. return fullName;
  212. }
  213. }
  214. /// <summary>
  215. /// Applies patchers to all assemblies in the given directory and loads patched assemblies into memory.
  216. /// </summary>
  217. /// <param name="directory">Directory to load CLR assemblies from.</param>
  218. public void PatchAndLoad()
  219. {
  220. // First, create a copy of the assembly dictionary as the initializer can change them
  221. var assemblies = new Dictionary<string, AssemblyDefinition>(AssembliesToPatch, StringComparer.InvariantCultureIgnoreCase);
  222. // Next, initialize all the patchers
  223. foreach (var assemblyPatcher1 in PatcherPlugins)
  224. assemblyPatcher1.Initializer?.Invoke();
  225. // Then, perform the actual patching
  226. var patchedAssemblies = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase);
  227. var resolvedAssemblies = new Dictionary<string, string>();
  228. foreach (var assemblyPatcher in PatcherPlugins)
  229. foreach (string targetDll in assemblyPatcher.TargetDLLs())
  230. if (AssembliesToPatch.TryGetValue(targetDll, out var assembly))
  231. {
  232. Logger.LogInfo($"Patching [{assembly.Name.Name}] with [{assemblyPatcher.TypeName}]");
  233. assemblyPatcher.Patcher?.Invoke(ref assembly);
  234. AssembliesToPatch[targetDll] = assembly;
  235. patchedAssemblies.Add(targetDll);
  236. foreach (var resolvedAss in AppDomain.CurrentDomain.GetAssemblies())
  237. {
  238. var name = GetAssemblyName(resolvedAss.FullName);
  239. // Report only the first type that caused the assembly to load, because any subsequent ones can be false positives
  240. if (!resolvedAssemblies.ContainsKey(name))
  241. resolvedAssemblies[name] = assemblyPatcher.TypeName;
  242. }
  243. }
  244. // Check if any patched assemblies have been already resolved by the CLR
  245. // If there are any, they cannot be loaded by the preloader
  246. var patchedAssemblyNames = new HashSet<string>(assemblies.Where(kv => patchedAssemblies.Contains(kv.Key)).Select(kv => kv.Value.Name.Name), StringComparer.InvariantCultureIgnoreCase);
  247. var earlyLoadAssemblies = resolvedAssemblies.Where(kv => patchedAssemblyNames.Contains(kv.Key)).ToList();
  248. if (earlyLoadAssemblies.Count != 0)
  249. {
  250. Logger.LogWarning(new StringBuilder()
  251. .AppendLine("The following assemblies have been loaded too early and will not be patched by preloader:")
  252. .AppendLine(string.Join(Environment.NewLine, earlyLoadAssemblies.Select(kv => $"* [{kv.Key}] (first loaded by [{kv.Value}])").ToArray()))
  253. .AppendLine("Expect unexpected behavior and issues with plugins and patchers not being loaded.")
  254. .ToString());
  255. }
  256. // Finally, load patched assemblies into memory
  257. if (ConfigDumpAssemblies.Value || ConfigLoadDumpedAssemblies.Value)
  258. {
  259. if (!Directory.Exists(DumpedAssembliesPath))
  260. Directory.CreateDirectory(DumpedAssembliesPath);
  261. foreach (KeyValuePair<string, AssemblyDefinition> kv in assemblies)
  262. {
  263. string filename = kv.Key;
  264. var assembly = kv.Value;
  265. if (patchedAssemblies.Contains(filename))
  266. assembly.Write(Path.Combine(DumpedAssembliesPath, filename));
  267. }
  268. }
  269. if (ConfigBreakBeforeLoadAssemblies.Value)
  270. {
  271. Logger.LogInfo($"BepInEx is about load the following assemblies:\n{String.Join("\n", patchedAssemblies.ToArray())}");
  272. Logger.LogInfo($"The assemblies were dumped into {DumpedAssembliesPath}");
  273. Logger.LogInfo("Load any assemblies into the debugger, set breakpoints and continue execution.");
  274. Debugger.Break();
  275. }
  276. foreach (var kv in assemblies)
  277. {
  278. string filename = kv.Key;
  279. var assembly = kv.Value;
  280. // Note that since we only *load* assemblies, they shouldn't trigger dependency loading
  281. // Not loading all assemblies is very important not only because of memory reasons,
  282. // but because some games *rely* on that because of messed up internal dependencies.
  283. if (patchedAssemblies.Contains(filename))
  284. {
  285. Assembly loadedAssembly;
  286. if (ConfigLoadDumpedAssemblies.Value)
  287. loadedAssembly = Assembly.LoadFile(Path.Combine(DumpedAssembliesPath, filename));
  288. else
  289. {
  290. using (var assemblyStream = new MemoryStream())
  291. {
  292. assembly.Write(assemblyStream);
  293. loadedAssembly =Assembly.Load(assemblyStream.ToArray());
  294. }
  295. }
  296. LoadedAssemblies.Add(filename, loadedAssembly);
  297. Logger.LogDebug($"Loaded '{assembly.FullName}' into memory");
  298. }
  299. // Though we have to dispose of all assemblies regardless of them being patched or not
  300. assembly.Dispose();
  301. }
  302. // Finally, run all finalizers
  303. foreach (var assemblyPatcher2 in PatcherPlugins)
  304. assemblyPatcher2.Finalizer?.Invoke();
  305. }
  306. #region Config
  307. private static readonly ConfigEntry<bool> ConfigDumpAssemblies = ConfigFile.CoreConfig.Bind(
  308. "Preloader", "DumpAssemblies",
  309. false,
  310. "If enabled, BepInEx will save patched assemblies into BepInEx/DumpedAssemblies.\nThis can be used by developers to inspect and debug preloader patchers.");
  311. private static readonly ConfigEntry<bool> ConfigLoadDumpedAssemblies = ConfigFile.CoreConfig.Bind(
  312. "Preloader", "LoadDumpedAssemblies",
  313. false,
  314. "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.");
  315. private static readonly ConfigEntry<bool> ConfigBreakBeforeLoadAssemblies = ConfigFile.CoreConfig.Bind(
  316. "Preloader", "BreakBeforeLoadAssemblies",
  317. false,
  318. "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.");
  319. #endregion
  320. }
  321. }