From 41c9f5462dde0f9aa210b162419333bfe75e607c Mon Sep 17 00:00:00 2001 From: Jakob Hellermann Date: Wed, 25 Jun 2025 10:51:08 +0200 Subject: [PATCH 1/9] build: copy files to if set --- Assembly-CSharp/Assembly-CSharp.csproj | 12 ++++++++++++ HollowKnight.Modding.API.sln.DotSettings | 3 ++- 2 files changed, 14 insertions(+), 1 deletion(-) 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/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 From a8d1c21375afa4506f8eddbbe69f6cc1218f9127 Mon Sep 17 00:00:00 2001 From: Jakob Hellermann Date: Mon, 7 Jul 2025 18:27:08 +0200 Subject: [PATCH 2/9] hot reload: load mods from `ModsHotReload` folder --- Assembly-CSharp/Mod.cs | 14 +- Assembly-CSharp/ModLoader.cs | 332 ++++++++++++++++++++++------------- 2 files changed, 222 insertions(+), 124 deletions(-) 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/ModLoader.cs b/Assembly-CSharp/ModLoader.cs index 886beaff..290e465d 100644 --- a/Assembly-CSharp/ModLoader.cs +++ b/Assembly-CSharp/ModLoader.cs @@ -12,6 +12,7 @@ using UObject = UnityEngine.Object; using USceneManager = UnityEngine.SceneManagement.SceneManager; using Modding.Utils; +using Mono.Cecil; namespace Modding { @@ -38,6 +39,17 @@ 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 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 +97,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 +109,22 @@ 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); + string mods = Path.Combine(ManagedPath, "Mods"); + string hotReloadMods = Path.Combine(ManagedPath, "ModsHotReload"); + + List<(string, Assembly)> modAssemblies = GetModAssemblies(mods); + List<(string, Assembly)> hotReloadModAssemblies = GetHotReloadModAssemblies(hotReloadMods); + StartFileSystemWatcher(hotReloadMods); - // 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) + foreach ((string _, Assembly asm) in modAssemblies) { - 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!"); - } + Logger.APILogger.LogDebug($"Loading mods in assembly `{asm.FullName}`"); + InstantiateMods(asm); } - - foreach (Assembly asm in asms) + foreach ((string path, Assembly asm) in hotReloadModAssemblies) { - 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"); - } + Logger.APILogger.LogDebug($"Loading mods in hot reload assembly `{asm.FullName}`"); + ModInstancesByAssembly[path] = InstantiateMods(asm); } var scenes = new List(); @@ -295,6 +202,195 @@ Assembly Resolve(object sender, ResolveEventArgs args) UObject.Destroy(coroutineHolder.gameObject); } + 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 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<(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 void StartFileSystemWatcher(string mods) { + var fileSystemWatcher = new FileSystemWatcher(mods) { + IncludeSubdirectories = true, + }; + fileSystemWatcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName; + fileSystemWatcher.Filter = "*.dll"; + fileSystemWatcher.Created += (_, e) => { + Logger.APILogger.Log($"Loading mods from {e.FullPath}"); + LoadModAssembly(e.FullPath); + }; + fileSystemWatcher.Deleted += (_, e) => { + Logger.APILogger.Log($"Unloading mods from {e.FullPath}"); + UnloadModAssembly(e.FullPath); + }; + fileSystemWatcher.Changed += (_, e) => { + Logger.APILogger.Log($"Reloading mods from {e.FullPath}"); + UnloadModAssembly(e.FullPath); + LoadModAssembly(e.FullPath); + }; + fileSystemWatcher.Renamed += (_, e) => { + Logger.APILogger.Log($"Reloading mods from {e.FullPath}"); + UnloadModAssembly(e.OldFullPath); + LoadModAssembly(e.FullPath); + }; + fileSystemWatcher.EnableRaisingEvents = true; + + return; + + static void UnloadModAssembly(string assemblyPath) { + 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}'"); + } + } + + static void LoadModAssembly(string assemblyPath) { + if (ModInstancesByAssembly.TryGetValue(assemblyPath, out _)) { + Logger.APILogger.LogError($"Did not hot reload mods because they old ones were still loaded {assemblyPath}"); + return; + } + + var assembly = LoadHotReloadDll(assemblyPath); + List newAssemblyMods = InstantiateMods(assembly); + ModInstancesByAssembly[assemblyPath] = newAssemblyMods; + foreach (var mod in newAssemblyMods) { + LoadMod(mod, false, preloadedObjects: null); // TODO preloadedObjects + } + } + } + + 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; + } + + private static Assembly LoadHotReloadDll(string path) { + var resolver = new DefaultAssemblyResolver(); // perf: reuse + resolver.AddSearchDirectory(ManagedPath); + + using var dll = AssemblyDefinition.ReadAssembly(path, new ReaderParameters { + AssemblyResolver = resolver, + ReadSymbols = true, + }); + dll.Name.Name = $"{dll.Name.Name}-{DateTime.Now.Ticks}"; + using var ms = new MemoryStream(); + dll.Write(ms); + return Assembly.Load(ms.ToArray()); + } + + private static void GetPreloads ( ModInstance[] orderedMods, From 3d45283673f003160df10af6414da6fe82c067cc Mon Sep 17 00:00:00 2001 From: Jakob Hellermann Date: Mon, 7 Jul 2025 19:31:39 +0200 Subject: [PATCH 3/9] hot reload: just reload mods from regular mods folder --- Assembly-CSharp/ModLoader.cs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/Assembly-CSharp/ModLoader.cs b/Assembly-CSharp/ModLoader.cs index 290e465d..485ef855 100644 --- a/Assembly-CSharp/ModLoader.cs +++ b/Assembly-CSharp/ModLoader.cs @@ -110,20 +110,14 @@ public static IEnumerator LoadModsInit(GameObject coroutineHolder) Logger.APILogger.LogDebug($"Loading assemblies and constructing mods"); string mods = Path.Combine(ManagedPath, "Mods"); - string hotReloadMods = Path.Combine(ManagedPath, "ModsHotReload"); List<(string, Assembly)> modAssemblies = GetModAssemblies(mods); - List<(string, Assembly)> hotReloadModAssemblies = GetHotReloadModAssemblies(hotReloadMods); - StartFileSystemWatcher(hotReloadMods); + StartFileSystemWatcher(mods); - foreach ((string _, Assembly asm) in modAssemblies) + foreach ((string path, Assembly asm) in modAssemblies) { Logger.APILogger.LogDebug($"Loading mods in assembly `{asm.FullName}`"); InstantiateMods(asm); - } - foreach ((string path, Assembly asm) in hotReloadModAssemblies) - { - Logger.APILogger.LogDebug($"Loading mods in hot reload assembly `{asm.FullName}`"); ModInstancesByAssembly[path] = InstantiateMods(asm); } From 7f96f2c589ad1bae8b9e45d3ed00d3ccb6223534 Mon Sep 17 00:00:00 2001 From: Jakob Hellermann Date: Mon, 7 Jul 2025 19:57:51 +0200 Subject: [PATCH 4/9] hot reload: add mod directories to hot reload search path --- Assembly-CSharp/ModLoader.cs | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/Assembly-CSharp/ModLoader.cs b/Assembly-CSharp/ModLoader.cs index 485ef855..23b2b330 100644 --- a/Assembly-CSharp/ModLoader.cs +++ b/Assembly-CSharp/ModLoader.cs @@ -41,6 +41,8 @@ public enum ModLoadState private static Dictionary> ModInstancesByAssembly = new(); + private static DefaultAssemblyResolver hotReloadAssemblyResolver; + private static string ManagedPath = SystemInfo.operatingSystemFamily switch { OperatingSystemFamily.Windows => Path.Combine(Application.dataPath, "Managed"), @@ -111,13 +113,23 @@ public static IEnumerator LoadModsInit(GameObject coroutineHolder) string mods = Path.Combine(ManagedPath, "Mods"); - List<(string, Assembly)> modAssemblies = GetModAssemblies(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); StartFileSystemWatcher(mods); foreach ((string path, Assembly asm) in modAssemblies) { Logger.APILogger.LogDebug($"Loading mods in assembly `{asm.FullName}`"); - InstantiateMods(asm); ModInstancesByAssembly[path] = InstantiateMods(asm); } @@ -245,12 +257,7 @@ private static List InstantiateMods(Assembly asm) { return modInstances; } - private static List<(string, Assembly)> GetModAssemblies(string mods) { - string[] files = Directory.GetDirectories(mods) - .Except(new string[] { Path.Combine(mods, "Disabled") }) - .SelectMany(d => Directory.GetFiles(d, "*.dll")) - .ToArray(); - + private static List<(string, Assembly)> GetModAssemblies(string[] files) { Logger.APILogger.LogDebug(string.Join(",\n", files)); Assembly Resolve(object sender, ResolveEventArgs args) @@ -371,11 +378,8 @@ static void LoadModAssembly(string assemblyPath) { } private static Assembly LoadHotReloadDll(string path) { - var resolver = new DefaultAssemblyResolver(); // perf: reuse - resolver.AddSearchDirectory(ManagedPath); - using var dll = AssemblyDefinition.ReadAssembly(path, new ReaderParameters { - AssemblyResolver = resolver, + AssemblyResolver = hotReloadAssemblyResolver, ReadSymbols = true, }); dll.Name.Name = $"{dll.Name.Name}-{DateTime.Now.Ticks}"; From 82f5fb140022b7cc6aa03153c3ec4aec8de0d898 Mon Sep 17 00:00:00 2001 From: Jakob Hellermann Date: Mon, 7 Jul 2025 20:42:49 +0200 Subject: [PATCH 5/9] hot reload: fix out of order event processing and idempotence --- Assembly-CSharp/ModLoader.cs | 76 +++++++++++++++++++++++------------- 1 file changed, 49 insertions(+), 27 deletions(-) diff --git a/Assembly-CSharp/ModLoader.cs b/Assembly-CSharp/ModLoader.cs index 23b2b330..19ce7e8c 100644 --- a/Assembly-CSharp/ModLoader.cs +++ b/Assembly-CSharp/ModLoader.cs @@ -308,42 +308,56 @@ private static void StartFileSystemWatcher(string mods) { fileSystemWatcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName; fileSystemWatcher.Filter = "*.dll"; fileSystemWatcher.Created += (_, e) => { - Logger.APILogger.Log($"Loading mods from {e.FullPath}"); - LoadModAssembly(e.FullPath); + lock (fileSystemWatcher) { + Logger.APILogger.Log($"Loading mods from {e.FullPath}"); + LoadModAssembly(e.FullPath); + } }; fileSystemWatcher.Deleted += (_, e) => { - Logger.APILogger.Log($"Unloading mods from {e.FullPath}"); - UnloadModAssembly(e.FullPath); + lock (fileSystemWatcher) { + Logger.APILogger.Log($"Unloading mods from {e.FullPath}"); + UnloadModAssembly(e.FullPath); + } }; + // Change may also be called for deletions and renames, so it's important + // to be idempotent here fileSystemWatcher.Changed += (_, e) => { - Logger.APILogger.Log($"Reloading mods from {e.FullPath}"); - UnloadModAssembly(e.FullPath); - LoadModAssembly(e.FullPath); + lock (fileSystemWatcher) { + Logger.APILogger.Log($"Reloading mods from {e.FullPath}"); + UnloadModAssembly(e.FullPath); + LoadModAssembly(e.FullPath); + } }; fileSystemWatcher.Renamed += (_, e) => { - Logger.APILogger.Log($"Reloading mods from {e.FullPath}"); - UnloadModAssembly(e.OldFullPath); - LoadModAssembly(e.FullPath); + lock (fileSystemWatcher) { + Logger.APILogger.Log($"Reloading mods from {e.FullPath}"); + UnloadModAssembly(e.OldFullPath); + LoadModAssembly(e.FullPath); + } }; fileSystemWatcher.EnableRaisingEvents = true; return; static void UnloadModAssembly(string assemblyPath) { - 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; + 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()); } - 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}'"); } - 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}"); } } @@ -353,11 +367,19 @@ static void LoadModAssembly(string assemblyPath) { return; } - var assembly = LoadHotReloadDll(assemblyPath); - List newAssemblyMods = InstantiateMods(assembly); - ModInstancesByAssembly[assemblyPath] = newAssemblyMods; - foreach (var mod in newAssemblyMods) { - LoadMod(mod, false, preloadedObjects: null); // TODO preloadedObjects + 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, false, preloadedObjects: null); // TODO preloadedObjects + } + } catch (Exception e) { + Logger.APILogger.LogError($"Error trying to load mods in {assemblyPath}:\n{e}"); } } } From 723c58ec89451532f9cd2f678f847165bfaa1556 Mon Sep 17 00:00:00 2001 From: Jakob Hellermann Date: Mon, 7 Jul 2025 21:02:39 +0200 Subject: [PATCH 6/9] hot reload: read debug symbols --- Assembly-CSharp/ModLoader.cs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/Assembly-CSharp/ModLoader.cs b/Assembly-CSharp/ModLoader.cs index 19ce7e8c..e7ac9562 100644 --- a/Assembly-CSharp/ModLoader.cs +++ b/Assembly-CSharp/ModLoader.cs @@ -13,6 +13,7 @@ using USceneManager = UnityEngine.SceneManagement.SceneManager; using Modding.Utils; using Mono.Cecil; +using Mono.Cecil.Cil; namespace Modding { @@ -399,15 +400,29 @@ static void LoadModAssembly(string assemblyPath) { 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(); - dll.Write(ms); - return Assembly.Load(ms.ToArray()); + var symbolWriter = new MemorySymbolWriterProvider(); + dll.Write(ms, new WriterParameters { + SymbolWriterProvider = symbolWriter, + }); + + byte[] symbols = symbolWriter.stream.ToArray(); + return Assembly.Load(ms.ToArray(), symbols); } From 9e4a3e41886783c013d6b7024bd652e7a5e9dcff Mon Sep 17 00:00:00 2001 From: Jakob Hellermann Date: Mon, 7 Jul 2025 21:09:38 +0200 Subject: [PATCH 7/9] hot reload: silence warning and hot reload only after startup --- Assembly-CSharp/ModLoader.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Assembly-CSharp/ModLoader.cs b/Assembly-CSharp/ModLoader.cs index e7ac9562..f623ffc7 100644 --- a/Assembly-CSharp/ModLoader.cs +++ b/Assembly-CSharp/ModLoader.cs @@ -126,11 +126,11 @@ public static IEnumerator LoadModsInit(GameObject coroutineHolder) } List<(string, Assembly)> modAssemblies = GetModAssemblies(modFiles); - StartFileSystemWatcher(mods); foreach ((string path, Assembly asm) in modAssemblies) { Logger.APILogger.LogDebug($"Loading mods in assembly `{asm.FullName}`"); + // ReSharper disable once InconsistentlySynchronizedField the watcher hasn't started yet ModInstancesByAssembly[path] = InstantiateMods(asm); } @@ -197,6 +197,8 @@ public static IEnumerator LoadModsInit(GameObject coroutineHolder) UObject.DontDestroyOnLoad(version); UpdateModText(); + + StartFileSystemWatcher(mods); // Adding version nums to the modlog by default to make debugging significantly easier Logger.APILogger.Log("Finished loading mods:\n" + modVersionDraw.drawString); From 9ef27939d39827949def3e119b27535c5b1eef9e Mon Sep 17 00:00:00 2001 From: Jakob Hellermann Date: Mon, 7 Jul 2025 21:23:29 +0200 Subject: [PATCH 8/9] hot reload: handle events in unity update thread It seemed to work before but I think calling unity APIs from background threads is undefined behaviour --- Assembly-CSharp/ModLoader.cs | 149 ++++++++++--------- Assembly-CSharp/Patches/OnScreenDebugInfo.cs | 5 +- 2 files changed, 83 insertions(+), 71 deletions(-) diff --git a/Assembly-CSharp/ModLoader.cs b/Assembly-CSharp/ModLoader.cs index f623ffc7..5b4331e1 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; @@ -17,6 +18,12 @@ namespace Modding { + internal class ModLoaderObject: MonoBehaviour { + private void Update() { + ModLoader.HandleHotReloadEvents(); + } + } + /// /// Handles loading of mods. /// @@ -115,8 +122,8 @@ public static IEnumerator LoadModsInit(GameObject coroutineHolder) string mods = Path.Combine(ManagedPath, "Mods"); string[] modDirectories = Directory.GetDirectories(mods) - .Except([Path.Combine(mods, "Disabled")]) - .ToArray(); + .Except([Path.Combine(mods, "Disabled")]) + .ToArray(); string[] modFiles = modDirectories .SelectMany(d => Directory.GetFiles(d, "*.dll")) .ToArray(); @@ -161,6 +168,7 @@ public static IEnumerator LoadModsInit(GameObject coroutineHolder) { Preloader pld = coroutineHolder.GetOrAddComponent(); yield return pld.Preload(toPreload, preloadedObjects, sceneHooks); + UObject.Destroy(pld); } foreach (ModInstance mod in orderedMods) @@ -207,8 +215,6 @@ public static IEnumerator LoadModsInit(GameObject coroutineHolder) LoadState |= ModLoadState.Loaded; new ModListMenu().InitMenuCreation(); - - UObject.Destroy(coroutineHolder.gameObject); } private static List InstantiateMods(Assembly asm) { @@ -303,6 +309,38 @@ Assembly Resolve(object sender, ResolveEventArgs args) 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(); + } + } + } private static void StartFileSystemWatcher(string mods) { var fileSystemWatcher = new FileSystemWatcher(mods) { @@ -310,82 +348,57 @@ private static void StartFileSystemWatcher(string mods) { }; fileSystemWatcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName; fileSystemWatcher.Filter = "*.dll"; - fileSystemWatcher.Created += (_, e) => { - lock (fileSystemWatcher) { - Logger.APILogger.Log($"Loading mods from {e.FullPath}"); - LoadModAssembly(e.FullPath); - } - }; - fileSystemWatcher.Deleted += (_, e) => { - lock (fileSystemWatcher) { - Logger.APILogger.Log($"Unloading mods from {e.FullPath}"); - UnloadModAssembly(e.FullPath); - } - }; - // Change may also be called for deletions and renames, so it's important - // to be idempotent here - fileSystemWatcher.Changed += (_, e) => { - lock (fileSystemWatcher) { - Logger.APILogger.Log($"Reloading mods from {e.FullPath}"); - UnloadModAssembly(e.FullPath); - LoadModAssembly(e.FullPath); - } - }; - fileSystemWatcher.Renamed += (_, e) => { - lock (fileSystemWatcher) { - Logger.APILogger.Log($"Reloading mods from {e.FullPath}"); - UnloadModAssembly(e.OldFullPath); - LoadModAssembly(e.FullPath); - } - }; + 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; - - return; + } - static void UnloadModAssembly(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()); + 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; } - ModInstancesByAssembly.Remove(assemblyPath); - } else { - Logger.APILogger.LogWarn($"No mods loaded for changed assembly '{assemblyPath}'"); + UnloadMod(mod); + ModInstances.Remove(mod); + ModInstanceNameMap.Remove(mod.Name); + ModInstanceTypeMap.Remove(mod.Mod.GetType()); } - } catch (Exception e) { - Logger.APILogger.LogError($"Error trying to unload mods in {assemblyPath}:\n{e}"); + 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}"); } + } - static void LoadModAssembly(string assemblyPath) { - if (ModInstancesByAssembly.TryGetValue(assemblyPath, out _)) { - Logger.APILogger.LogError($"Did not hot reload mods because they old ones were still loaded {assemblyPath}"); + 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; } - - 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, false, preloadedObjects: null); // TODO preloadedObjects - } - } catch (Exception e) { - Logger.APILogger.LogError($"Error trying to load mods in {assemblyPath}:\n{e}"); + 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) 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 { From 0325ec902ebe00668693264a95dd154c10a64660 Mon Sep 17 00:00:00 2001 From: Jakob Hellermann Date: Mon, 7 Jul 2025 21:48:37 +0200 Subject: [PATCH 9/9] hot reload: make configurable via `EnableHotReload` setting --- Assembly-CSharp/ModHooksGlobalSettings.cs | 18 ++++++++++++++++++ Assembly-CSharp/ModLoader.cs | 4 +++- 2 files changed, 21 insertions(+), 1 deletion(-) 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 5b4331e1..7b1b7fc4 100644 --- a/Assembly-CSharp/ModLoader.cs +++ b/Assembly-CSharp/ModLoader.cs @@ -206,7 +206,9 @@ public static IEnumerator LoadModsInit(GameObject coroutineHolder) UpdateModText(); - StartFileSystemWatcher(mods); + 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);