diff --git a/Assembly-CSharp/Assembly-CSharp.csproj b/Assembly-CSharp/Assembly-CSharp.csproj index ae54a496..364fd6ea 100644 --- a/Assembly-CSharp/Assembly-CSharp.csproj +++ b/Assembly-CSharp/Assembly-CSharp.csproj @@ -27,6 +27,11 @@ $(SolutionDir)/OutputFinal + + C:/Program Files (x86)/Steam/steamapps/common/Hollow Knight + $(HOME)/.local/share/Steam/steamapps/common/Hollow Knight + $(GamePath)/hollow_knight_Data/Managed + mono @@ -78,6 +83,13 @@ + + + + + + + full bin\$(Configuration)\Assembly-CSharp.mm.xml diff --git a/Assembly-CSharp/Mod.cs b/Assembly-CSharp/Mod.cs index 3e476940..edef951f 100644 --- a/Assembly-CSharp/Mod.cs +++ b/Assembly-CSharp/Mod.cs @@ -110,13 +110,15 @@ private string GetGlobalSettingsPath() string globalSettingsFileName = $"{GetType().Name}.GlobalSettings.json"; string location = GetType().Assembly.Location; - string directory = Path.GetDirectoryName(location); - string globalSettingsOverride = Path.Combine(directory, globalSettingsFileName); + if (location != "") { // TODO: not set while hot reloading + string directory = Path.GetDirectoryName(location); + string globalSettingsOverride = Path.Combine(directory, globalSettingsFileName); - if (File.Exists(globalSettingsOverride)) - { - Log("Overriding Global Settings path with Mod directory"); - return globalSettingsOverride; + if (File.Exists(globalSettingsOverride)) + { + Log("Overriding Global Settings path with Mod directory"); + return globalSettingsOverride; + } } return Path.Combine(Application.persistentDataPath, globalSettingsFileName); diff --git a/Assembly-CSharp/ModHooksGlobalSettings.cs b/Assembly-CSharp/ModHooksGlobalSettings.cs index b26b4c7b..23a53a97 100644 --- a/Assembly-CSharp/ModHooksGlobalSettings.cs +++ b/Assembly-CSharp/ModHooksGlobalSettings.cs @@ -44,6 +44,24 @@ public class ModHooksGlobalSettings /// public int PreloadBatchSize = 5; + /// + /// When enabled, listens for filesystem changes to mod DLLs. + /// On modification, mods will be unloaded, and the new copy of the assembly gets loaded as well. + /// Limitations: + /// + /// + /// The old assembly does not get unloaded. If you have created any unity game objects or components, + /// make sure to Destroy them in the mod's Unload function. + /// k + /// + /// Dependencies of mods cannot be hot reloaded. When you modify a dependency DLL, no change will be made to the mod depending on it. + /// + /// + /// Assembly.location will return an empty string for hot-reloaded mods + /// + /// + /// + public bool EnableHotReload = false; /// /// Maximum number of days to preserve modlogs for. diff --git a/Assembly-CSharp/ModLoader.cs b/Assembly-CSharp/ModLoader.cs index 886beaff..7b1b7fc4 100644 --- a/Assembly-CSharp/ModLoader.cs +++ b/Assembly-CSharp/ModLoader.cs @@ -1,5 +1,6 @@ using System; using System.Collections; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; @@ -12,9 +13,17 @@ using UObject = UnityEngine.Object; using USceneManager = UnityEngine.SceneManagement.SceneManager; using Modding.Utils; +using Mono.Cecil; +using Mono.Cecil.Cil; namespace Modding { + internal class ModLoaderObject: MonoBehaviour { + private void Update() { + ModLoader.HandleHotReloadEvents(); + } + } + /// /// Handles loading of mods. /// @@ -38,6 +47,19 @@ public enum ModLoadState public static Dictionary ModInstanceNameMap { get; private set; } = new(); public static HashSet ModInstances { get; private set; } = new(); + private static Dictionary> ModInstancesByAssembly = new(); + + private static DefaultAssemblyResolver hotReloadAssemblyResolver; + + private static string ManagedPath = SystemInfo.operatingSystemFamily switch + { + OperatingSystemFamily.Windows => Path.Combine(Application.dataPath, "Managed"), + OperatingSystemFamily.MacOSX => Path.Combine(Application.dataPath, "Resources", "Data", "Managed"), + OperatingSystemFamily.Linux => Path.Combine(Application.dataPath, "Managed"), + OperatingSystemFamily.Other => null, + _ => throw new ArgumentOutOfRangeException(), + }; + /// /// Try to add a ModInstance to the internal dictionaries. /// @@ -85,23 +107,10 @@ public static IEnumerator LoadModsInit(GameObject coroutineHolder) Logger.APILogger.Log("Starting mod loading"); - string managed_path = SystemInfo.operatingSystemFamily switch - { - OperatingSystemFamily.Windows => Path.Combine(Application.dataPath, "Managed"), - OperatingSystemFamily.MacOSX => Path.Combine(Application.dataPath, "Resources", "Data", "Managed"), - OperatingSystemFamily.Linux => Path.Combine(Application.dataPath, "Managed"), - - OperatingSystemFamily.Other => null, - - _ => throw new ArgumentOutOfRangeException() - }; - - if (managed_path is null) + if (ManagedPath is null) { LoadState |= ModLoadState.Loaded; - UObject.Destroy(coroutineHolder); - yield break; } @@ -110,114 +119,26 @@ public static IEnumerator LoadModsInit(GameObject coroutineHolder) Logger.APILogger.LogDebug($"Loading assemblies and constructing mods"); - string mods = Path.Combine(managed_path, "Mods"); - - string[] files = Directory.GetDirectories(mods) - .Except(new string[] { Path.Combine(mods, "Disabled") }) - .SelectMany(d => Directory.GetFiles(d, "*.dll")) - .ToArray(); - - Logger.APILogger.LogDebug(string.Join(",\n", files)); - - Assembly Resolve(object sender, ResolveEventArgs args) - { - var asm_name = new AssemblyName(args.Name); - - if (files.FirstOrDefault(x => x.EndsWith($"{asm_name.Name}.dll")) is string path) - return Assembly.LoadFrom(path); - - return null; - } - - AppDomain.CurrentDomain.AssemblyResolve += Resolve; - - List asms = new(files.Length); - - // Load all the assemblies first to avoid dependency issues - // Dependencies are lazy-loaded, so we won't have attempted loads - // until the mod initialization. - foreach (string path in files) - { - Logger.APILogger.LogDebug($"Loading assembly `{path}`"); - - try - { - asms.Add(Assembly.LoadFrom(path)); - } - catch (FileLoadException e) - { - Logger.APILogger.LogError($"Unable to load assembly - {e}"); - } - catch (BadImageFormatException e) - { - Logger.APILogger.LogError($"Assembly is bad image. {e}"); - } - catch (PathTooLongException) - { - Logger.APILogger.LogError("Unable to load, path to dll is too long!"); - } + string mods = Path.Combine(ManagedPath, "Mods"); + + string[] modDirectories = Directory.GetDirectories(mods) + .Except([Path.Combine(mods, "Disabled")]) + .ToArray(); + string[] modFiles = modDirectories + .SelectMany(d => Directory.GetFiles(d, "*.dll")) + .ToArray(); + hotReloadAssemblyResolver = new DefaultAssemblyResolver(); + foreach (string modDirectory in modDirectories) { + hotReloadAssemblyResolver.AddSearchDirectory(modDirectory); } + + List<(string, Assembly)> modAssemblies = GetModAssemblies(modFiles); - foreach (Assembly asm in asms) + foreach ((string path, Assembly asm) in modAssemblies) { Logger.APILogger.LogDebug($"Loading mods in assembly `{asm.FullName}`"); - - bool foundMod = false; - - try - { - foreach (Type ty in asm.GetTypesSafely()) - { - if (!ty.IsClass || ty.IsAbstract || !ty.IsSubclassOf(typeof(Mod))) - continue; - - foundMod = true; - - Logger.APILogger.LogDebug($"Constructing mod `{ty.FullName}`"); - - try - { - if (ty.GetConstructor(Type.EmptyTypes)?.Invoke(Array.Empty()) is Mod mod) - { - TryAddModInstance( - ty, - new ModInstance - { - Mod = mod, - Enabled = false, - Error = null, - Name = mod.GetName() - } - ); - } - } - catch (Exception e) - { - Logger.APILogger.LogError(e); - - TryAddModInstance( - ty, - new ModInstance - { - Mod = null, - Enabled = false, - Error = ModErrorState.Construct, - Name = ty.Name - } - ); - } - } - } - catch (Exception e) - { - Logger.APILogger.LogError(e); - } - - if (!foundMod) - { - AssemblyName info = asm.GetName(); - Logger.APILogger.Log($"Assembly {info.Name} ({info.Version}) loaded with 0 mods"); - } + // ReSharper disable once InconsistentlySynchronizedField the watcher hasn't started yet + ModInstancesByAssembly[path] = InstantiateMods(asm); } var scenes = new List(); @@ -247,6 +168,7 @@ Assembly Resolve(object sender, ResolveEventArgs args) { Preloader pld = coroutineHolder.GetOrAddComponent(); yield return pld.Preload(toPreload, preloadedObjects, sceneHooks); + UObject.Destroy(pld); } foreach (ModInstance mod in orderedMods) @@ -283,6 +205,10 @@ Assembly Resolve(object sender, ResolveEventArgs args) UObject.DontDestroyOnLoad(version); UpdateModText(); + + if (ModHooks.GlobalSettings.EnableHotReload) { + StartFileSystemWatcher(mods); + } // Adding version nums to the modlog by default to make debugging significantly easier Logger.APILogger.Log("Finished loading mods:\n" + modVersionDraw.drawString); @@ -291,10 +217,232 @@ Assembly Resolve(object sender, ResolveEventArgs args) LoadState |= ModLoadState.Loaded; new ModListMenu().InitMenuCreation(); + } + + private static List InstantiateMods(Assembly asm) { + bool foundMod = false; + + List modInstances = []; + + try { + foreach (Type ty in asm.GetTypesSafely()) { + if (!ty.IsClass || ty.IsAbstract || !ty.IsSubclassOf(typeof(Mod))) + continue; + + foundMod = true; + + Logger.APILogger.LogDebug($"Constructing mod `{ty.FullName}`"); + + try { + if (ty.GetConstructor(Type.EmptyTypes)?.Invoke([]) is Mod mod) { + var instance = new ModInstance { + Mod = mod, + Enabled = false, + Error = null, + Name = mod.GetName(), + }; + modInstances.Add(instance); + TryAddModInstance(ty, instance); + } + } catch (Exception e) { + Logger.APILogger.LogError(e); + var instance = new ModInstance { + Mod = null, + Enabled = false, + Error = ModErrorState.Construct, + Name = ty.Name, + }; + modInstances.Add(instance); + TryAddModInstance(ty, instance); + } + } + } catch (Exception e) { + Logger.APILogger.LogError(e); + } + + if (!foundMod) { + AssemblyName info = asm.GetName(); + Logger.APILogger.Log($"Assembly {info.Name} ({info.Version}) loaded with 0 mods"); + } + + return modInstances; + } + + private static List<(string, Assembly)> GetModAssemblies(string[] files) { + Logger.APILogger.LogDebug(string.Join(",\n", files)); + + Assembly Resolve(object sender, ResolveEventArgs args) + { + var asm_name = new AssemblyName(args.Name); + + if (files.FirstOrDefault(x => x.EndsWith($"{asm_name.Name}.dll")) is string path) + return Assembly.LoadFrom(path); + + return null; + } + + AppDomain.CurrentDomain.AssemblyResolve += Resolve; + + List<(string, Assembly)> asms = new(files.Length); + + // Load all the assemblies first to avoid dependency issues + // Dependencies are lazy-loaded, so we won't have attempted loads + // until the mod initialization. + foreach (string path in files) + { + Logger.APILogger.LogDebug($"Loading assembly `{path}`"); + + try { + asms.Add((path, Assembly.LoadFrom(path))); + } + catch (FileLoadException e) + { + Logger.APILogger.LogError($"Unable to load assembly - {e}"); + } + catch (BadImageFormatException e) + { + Logger.APILogger.LogError($"Assembly is bad image. {e}"); + } + catch (PathTooLongException) + { + Logger.APILogger.LogError("Unable to load, path to dll is too long!"); + } + } + + return asms; + } + + private static ConcurrentQueue hotReloadEvents = new(); + + internal static void HandleHotReloadEvents() { + while (hotReloadEvents.TryDequeue(out var e)) { + Logger.APILogger.LogDebug($"Got file system event {e.ChangeType} at {e.FullPath}"); + switch (e.ChangeType) { + case WatcherChangeTypes.Created: + Logger.APILogger.Log($"Loading mods from {e.FullPath}"); + HotLoadModAssembly(e.FullPath); + break; + case WatcherChangeTypes.Deleted: + Logger.APILogger.Log($"Unloading mods from {e.FullPath}"); + HotUnloadModAssembly(e.FullPath); + break; + case WatcherChangeTypes.Changed: + Logger.APILogger.Log($"Reloading mods from {e.FullPath}"); + HotUnloadModAssembly(e.FullPath); + HotLoadModAssembly(e.FullPath); + break; + case WatcherChangeTypes.Renamed: + var renamedEvent = (RenamedEventArgs) e; + Logger.APILogger.Log($"Reloading mods from {e.FullPath}"); + HotUnloadModAssembly(renamedEvent.OldFullPath); + HotLoadModAssembly(renamedEvent.FullPath); + break; + + case WatcherChangeTypes.All: + default: throw new ArgumentOutOfRangeException(); + } + } + } - UObject.Destroy(coroutineHolder.gameObject); + private static void StartFileSystemWatcher(string mods) { + var fileSystemWatcher = new FileSystemWatcher(mods) { + IncludeSubdirectories = true, + }; + fileSystemWatcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName; + fileSystemWatcher.Filter = "*.dll"; + fileSystemWatcher.Created += (_, e) => hotReloadEvents.Enqueue(e); + fileSystemWatcher.Deleted += (_, e) => hotReloadEvents.Enqueue(e); + fileSystemWatcher.Changed += (_, e) => hotReloadEvents.Enqueue(e); + fileSystemWatcher.Renamed += (_, e) => hotReloadEvents.Enqueue(e); + fileSystemWatcher.EnableRaisingEvents = true; + } + + private static void HotUnloadModAssembly(string assemblyPath) { + try { + if (ModInstancesByAssembly.TryGetValue(assemblyPath, out List assemblyMods)) { + foreach (ModInstance mod in assemblyMods) { + if (mod.Mod is not ITogglableMod) { + Logger.APILogger.LogError("Hot reloaded mod contains non-togglable mods"); + return; + } + UnloadMod(mod); + ModInstances.Remove(mod); + ModInstanceNameMap.Remove(mod.Name); + ModInstanceTypeMap.Remove(mod.Mod.GetType()); + } + ModInstancesByAssembly.Remove(assemblyPath); + } else { + Logger.APILogger.LogWarn($"No mods loaded for changed assembly '{assemblyPath}'"); + } + } catch (Exception e) { + Logger.APILogger.LogError($"Error trying to unload mods in {assemblyPath}:\n{e}"); + } } + private static void HotLoadModAssembly(string assemblyPath) { + if (ModInstancesByAssembly.TryGetValue(assemblyPath, out _)) { + Logger.APILogger.LogError($"Did not hot reload mods because they old ones were still loaded {assemblyPath}"); + return; + } + + try { + // Renames sometimes emit [Changed, Created, Deleted] + if (!File.Exists(assemblyPath)) { + return; + } + var assembly = LoadHotReloadDll(assemblyPath); + List newAssemblyMods = InstantiateMods(assembly); + ModInstancesByAssembly[assemblyPath] = newAssemblyMods; + foreach (var mod in newAssemblyMods) { + LoadMod(mod, preloadedObjects: null); // TODO preloadedObjects + } + } catch (Exception e) { + Logger.APILogger.LogError($"Error trying to load mods in {assemblyPath}:\n{e}"); + } + } + + + private static List<(string, Assembly)> GetHotReloadModAssemblies(string hotReloadMods) { + string[] files = Directory.GetDirectories(hotReloadMods) + .Except([Path.Combine(hotReloadMods, "Disabled")]) + .SelectMany(d => Directory.GetFiles(d, "*.dll")) + .ToArray(); + Logger.APILogger.LogDebug("Hot reload: " + string.Join(",\n", files)); + + List<(string, Assembly)> asms = new(); + foreach (string path in files) { + asms.Add((path, Assembly.LoadFrom(path))); + } + + return asms; + } + + public class MemorySymbolWriterProvider : ISymbolWriterProvider { + public MemoryStream stream = new(); + public ISymbolWriter GetSymbolWriter(ModuleDefinition module, string fileName) { + return module.SymbolReader.GetWriterProvider().GetSymbolWriter(module, stream); + } + public ISymbolWriter GetSymbolWriter(ModuleDefinition module, Stream symbolStream) => throw new NotImplementedException(); + } + + private static Assembly LoadHotReloadDll(string path) { + using var dll = AssemblyDefinition.ReadAssembly(path, new ReaderParameters { + AssemblyResolver = hotReloadAssemblyResolver, + ReadSymbols = true, + }); + dll.Name.Name = $"{dll.Name.Name}-{DateTime.Now.Ticks}"; + + using var ms = new MemoryStream(); + var symbolWriter = new MemorySymbolWriterProvider(); + dll.Write(ms, new WriterParameters { + SymbolWriterProvider = symbolWriter, + }); + + byte[] symbols = symbolWriter.stream.ToArray(); + return Assembly.Load(ms.ToArray(), symbols); + } + + private static void GetPreloads ( ModInstance[] orderedMods, diff --git a/Assembly-CSharp/Patches/OnScreenDebugInfo.cs b/Assembly-CSharp/Patches/OnScreenDebugInfo.cs index a04d41a0..dde1cdfd 100644 --- a/Assembly-CSharp/Patches/OnScreenDebugInfo.cs +++ b/Assembly-CSharp/Patches/OnScreenDebugInfo.cs @@ -19,14 +19,13 @@ private void Awake() Logger.APILogger.Log("Main menu loading"); ModLoader.LoadState = ModLoader.ModLoadState.Started; - GameObject obj = new GameObject(); + GameObject obj = new GameObject("Mod Loader"); DontDestroyOnLoad(obj); // Preload reflection new Thread(ReflectionHelper.PreloadCommonTypes).Start(); - // NonBouncer does absolutely nothing, which makes it a good dummy to run the loader - obj.AddComponent().StartCoroutine(ModLoader.LoadModsInit(obj)); + obj.AddComponent().StartCoroutine(ModLoader.LoadModsInit(obj)); } else { diff --git a/HollowKnight.Modding.API.sln.DotSettings b/HollowKnight.Modding.API.sln.DotSettings index 98201e24..0d6f08de 100644 --- a/HollowKnight.Modding.API.sln.DotSettings +++ b/HollowKnight.Modding.API.sln.DotSettings @@ -1,3 +1,4 @@  FSM - <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="_" Suffix="" Style="AaBb" /></Policy> \ No newline at end of file + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="_" Suffix="" Style="AaBb" /></Policy> + True \ No newline at end of file