From 91700f242990f9c694f97e9c2369469a8612fccb Mon Sep 17 00:00:00 2001 From: elliotttate Date: Mon, 30 Mar 2026 22:45:01 -0400 Subject: [PATCH 1/4] Add hot reload support for live mod code reloading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reload mod assemblies at runtime without restarting the game. Change a card's damage, fix a relic's effect, or add new entities — rebuild and type `hotreload MyMod` in the dev console. The system loads the new assembly into Godot's ALC, removes old entities from ModelDb, creates fresh instances (which auto-register in pools via BaseLib's constructors), reloads localization, and refreshes live card/relic/power nodes in the scene tree. Unchanged entities are detected via IL signature hashing and skipped for speed. Build integration via BaseLib.props stamps each Debug build with a unique assembly name (e.g. MyMod_hr143052789) so the ALC accepts it alongside the previous version. The stamped filename also avoids file lock conflicts with the running game. Mods using the BaseLib NuGet package get this automatically. Console commands: hotreload, hotreload_list, hotreload_status, hotreload_test. Optional file watcher for automatic reload on build (enable in BaseLib settings). 73 startup self-tests verify all reflection targets on every game launch. --- Abstracts/CustomCharacterModel.cs | 9 + Abstracts/CustomOrbModel.cs | 14 + BaseLib.csproj | 16 +- BaseLibMain.cs | 4 +- Commands/HotReloadCommand.cs | 188 +++++ Config/BaseLibConfig.cs | 3 + HotReload/AssemblyStamper.cs | 35 + HotReload/HotReloadEngine.cs | 255 ++++++ HotReload/HotReloadPipeline.cs | 1004 +++++++++++++++++++++++ HotReload/HotReloadResult.cs | 94 +++ HotReload/HotReloadSelfTests.cs | 561 +++++++++++++ HotReload/HotReloadSession.cs | 34 + HotReload/LiveInstanceRefresher.cs | 427 ++++++++++ HotReload/ModFileWatcher.cs | 199 +++++ HotReload/SerializationCacheSnapshot.cs | 84 ++ HotReload/TypeSignatureHasher.cs | 204 +++++ Patches/Content/ContentPatches.cs | 62 +- Patches/Content/CustomEnums.cs | 39 +- Patches/PostModInitPatch.cs | 26 +- Patches/Utils/ModelLocPatch.cs | 23 +- Patches/Utils/SavedSpireFieldPatch.cs | 9 + Utils/NodeFactories/NodeFactory.cs | 404 ++++++++- build/BaseLib.props | 62 +- docs/hot_reload.md | 304 +++++++ 24 files changed, 4026 insertions(+), 34 deletions(-) create mode 100644 Commands/HotReloadCommand.cs create mode 100644 HotReload/AssemblyStamper.cs create mode 100644 HotReload/HotReloadEngine.cs create mode 100644 HotReload/HotReloadPipeline.cs create mode 100644 HotReload/HotReloadResult.cs create mode 100644 HotReload/HotReloadSelfTests.cs create mode 100644 HotReload/HotReloadSession.cs create mode 100644 HotReload/LiveInstanceRefresher.cs create mode 100644 HotReload/ModFileWatcher.cs create mode 100644 HotReload/SerializationCacheSnapshot.cs create mode 100644 HotReload/TypeSignatureHasher.cs create mode 100644 docs/hot_reload.md diff --git a/Abstracts/CustomCharacterModel.cs b/Abstracts/CustomCharacterModel.cs index 9c7f23c..e9a7db4 100644 --- a/Abstracts/CustomCharacterModel.cs +++ b/Abstracts/CustomCharacterModel.cs @@ -274,6 +274,15 @@ public static void Register(CustomCharacterModel character) { CustomCharacters.Add(character); } + + /// + /// Remove all custom characters belonging to the given assembly. + /// Called during hot reload cleanup before re-registration from the new assembly. + /// + internal static void RemoveByAssembly(System.Reflection.Assembly asm) + { + CustomCharacters.RemoveAll(c => c.GetType().Assembly == asm); + } } diff --git a/Abstracts/CustomOrbModel.cs b/Abstracts/CustomOrbModel.cs index c4e246f..e4baa41 100644 --- a/Abstracts/CustomOrbModel.cs +++ b/Abstracts/CustomOrbModel.cs @@ -35,6 +35,17 @@ public CustomOrbModel() { RegisteredOrbs.Add(this); } + + /// + /// Remove all orbs belonging to the given assembly and clear the random pool + /// cache so it gets rebuilt on next access. Called during hot reload cleanup. + /// + internal static void RemoveByAssembly(System.Reflection.Assembly asm) + { + RegisteredOrbs.RemoveAll(o => o.GetType().Assembly == asm); + // Force the random pool cache to rebuild with the new set of orbs + CustomOrbRandomPool.ClearCache(); + } /// /// Override this to define localization directly in your class. @@ -92,6 +103,9 @@ class CustomOrbRandomPool { private static List? _eligibleCache; + /// Null the cache so it gets rebuilt from the current RegisteredOrbs list. + internal static void ClearCache() => _eligibleCache = null; + static void Postfix(Rng rng, ref OrbModel __result) { _eligibleCache ??= CustomOrbModel.RegisteredOrbs diff --git a/BaseLib.csproj b/BaseLib.csproj index 673265b..0c59a79 100644 --- a/BaseLib.csproj +++ b/BaseLib.csproj @@ -97,6 +97,12 @@ + + + + + + @@ -112,10 +118,18 @@ - + + + + + diff --git a/BaseLibMain.cs b/BaseLibMain.cs index 1bc5b8d..0c14a47 100644 --- a/BaseLibMain.cs +++ b/BaseLibMain.cs @@ -1,6 +1,7 @@ using System.Reflection; using System.Runtime.InteropServices; using BaseLib.Config; +using BaseLib.HotReload; using BaseLib.Patches.Content; using BaseLib.Utils.NodeFactories; using HarmonyLib; @@ -30,8 +31,9 @@ public static void Initialize() TheBigPatchToCardPileCmdAdd.Patch(harmony); harmony.PatchAll(); - + NodeFactory.Init(); + HotReloadEngine.Init(); } //Hopefully temporary fix for linux diff --git a/Commands/HotReloadCommand.cs b/Commands/HotReloadCommand.cs new file mode 100644 index 0000000..1aae035 --- /dev/null +++ b/Commands/HotReloadCommand.cs @@ -0,0 +1,188 @@ +using BaseLib.HotReload; +using MegaCrit.Sts2.Core.DevConsole; +using MegaCrit.Sts2.Core.DevConsole.ConsoleCommands; +using MegaCrit.Sts2.Core.Entities.Players; + +namespace BaseLib.Commands; + +/// +/// Console command to hot reload a mod assembly at runtime. +/// +/// Usage: +/// hotreload MyMod — reload by mod ID (auto-detects tier from folder contents) +/// hotreload E:/mods/MyMod/MyMod.dll — reload a specific DLL path +/// hotreload MyMod 3 — reload with explicit tier 3 (includes PCK remount) +/// hotreload MyMod 1 — reload patches only (no entity refresh) +/// +public class HotReloadCommand : AbstractConsoleCmd +{ + public override string CmdName => "hotreload"; + public override string Args => " [tier]"; + public override string Description => "Hot reload a mod assembly (tier: 1=patches, 2=entities, 3=+PCK, 0=auto)"; + public override bool IsNetworked => false; + + public override CmdResult Process(Player? issuingPlayer, string[] args) + { + // tier=0 means auto-detect (has .pck → 3, otherwise → 2) + int tier = 0; + string? target = null; + + // Parse arguments — numbers 1-3 are tier, everything else is the target + foreach (var arg in args) + { + if (int.TryParse(arg, out var t) && t is >= 1 and <= 3) + tier = t; + else if (!string.IsNullOrWhiteSpace(arg)) + target = arg; + } + + if (target == null) + return new CmdResult(false, "Usage: hotreload [tier]\nUse hotreload_list to see available mods."); + + HotReloadResult result; + + if (target.Contains('/') || target.Contains('\\') || target.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + { + // Looks like a file path — use tier 2 as default if not specified + result = HotReloadEngine.Reload(target, tier == 0 ? 2 : tier); + } + else + { + // Looks like a mod ID — auto-detect tier from directory contents + result = HotReloadEngine.ReloadByModId(target, tier); + } + + return new CmdResult(result.Success, result.Summary); + } +} + +/// +/// Console command to list mods available for hot reload. +/// +/// Usage: +/// hotreload_list — show all mod folders with their latest DLL timestamps +/// +public class HotReloadListCommand : AbstractConsoleCmd +{ + public override string CmdName => "hotreload_list"; + public override string Args => ""; + public override string Description => "List mods available for hot reload"; + public override bool IsNetworked => false; + + public override CmdResult Process(Player? issuingPlayer, string[] args) + { + var modsDir = FindModsDirectory(); + if (modsDir == null) + return new CmdResult(false, "Could not find game mods directory."); + + var lines = new List { $"Mods in {modsDir}:" }; + + foreach (var modDir in Directory.GetDirectories(modsDir).OrderBy(d => d)) + { + var modName = Path.GetFileName(modDir); + var dlls = Directory.GetFiles(modDir, "*.dll"); + if (dlls.Length == 0) continue; + + // Find the most recently modified DLL and how long ago it was written + var latest = dlls.OrderByDescending(File.GetLastWriteTimeUtc).First(); + var age = DateTime.UtcNow - File.GetLastWriteTimeUtc(latest); + var ageStr = age.TotalMinutes < 1 ? "just now" + : age.TotalMinutes < 60 ? $"{age.TotalMinutes:0}m ago" + : age.TotalHours < 24 ? $"{age.TotalHours:0}h ago" + : $"{age.TotalDays:0}d ago"; + + var hasPck = Directory.GetFiles(modDir, "*.pck").Length > 0; + var pckTag = hasPck ? " [PCK]" : ""; + + lines.Add($" {modName} — {Path.GetFileName(latest)} ({ageStr}){pckTag}"); + } + + if (lines.Count == 1) + lines.Add(" (no mods with DLLs found)"); + + return new CmdResult(true, string.Join("\n", lines)); + } + + private static string? FindModsDirectory() + { + var executable = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName; + if (executable == null) return null; + var gameDir = Path.GetDirectoryName(executable); + if (gameDir == null) return null; + var modsDir = Path.Combine(gameDir, "mods"); + return Directory.Exists(modsDir) ? modsDir : null; + } +} + +/// +/// Console command to show hot reload status and history. +/// +/// Usage: +/// hotreload_status — show last reload result and watcher state +/// +public class HotReloadStatusCommand : AbstractConsoleCmd +{ + public override string CmdName => "hotreload_status"; + public override string Args => ""; + public override string Description => "Show hot reload status and recent history"; + public override bool IsNetworked => false; + + public override CmdResult Process(Player? issuingPlayer, string[] args) + { + var progress = HotReloadEngine.CurrentProgress; + var history = HotReloadEngine.ReloadHistory; + var watcher = HotReloadEngine.FileWatcher; + + var lines = new List(); + + if (!string.IsNullOrEmpty(progress)) + lines.Add($"In progress: {progress}"); + else + lines.Add("No reload in progress"); + + lines.Add($"Watcher: {(watcher?.IsWatching == true ? "active" : "inactive")}"); + lines.Add($"Reload history: {history.Count} entries"); + + if (history.Count > 0) + { + var last = history[^1]; + lines.Add($"Last: {last.Summary}"); + } + + return new CmdResult(true, string.Join("\n", lines)); + } +} + +/// +/// Console command to run the hot reload integration test suite. +/// These tests exercise the full pipeline with the live game state. +/// +/// Usage: +/// hotreload_test — run all integration tests and report results +/// +public class HotReloadTestCommand : AbstractConsoleCmd +{ + public override string CmdName => "hotreload_test"; + public override string Args => ""; + public override string Description => "Run hot reload integration tests against live game state"; + public override bool IsNetworked => false; + + public override CmdResult Process(Player? issuingPlayer, string[] args) + { + var (passed, failed, failures) = HotReloadSelfTests.RunIntegrationTests(); + + var lines = new List(); + if (failed == 0) + { + lines.Add($"All {passed} integration tests passed!"); + } + else + { + lines.Add($"{passed} passed, {failed} FAILED:"); + foreach (var f in failures) + lines.Add($" FAIL: {f}"); + } + + return new CmdResult(failed == 0, string.Join("\n", lines)); + } +} diff --git a/Config/BaseLibConfig.cs b/Config/BaseLibConfig.cs index 874ed8f..306bfd2 100644 --- a/Config/BaseLibConfig.cs +++ b/Config/BaseLibConfig.cs @@ -18,4 +18,7 @@ internal class BaseLibConfig : SimpleModConfig [ConfigHideInUI] public static bool LogUseRegex { get; set; } = false; [ConfigHideInUI] public static bool LogInvertFilter { get; set; } = false; [ConfigHideInUI] public static string LogLastFilter { get; set; } = ""; + + [ConfigSection("HotReloadSection")] + public static bool EnableFileWatcher { get; set; } = false; } \ No newline at end of file diff --git a/HotReload/AssemblyStamper.cs b/HotReload/AssemblyStamper.cs new file mode 100644 index 0000000..50dca52 --- /dev/null +++ b/HotReload/AssemblyStamper.cs @@ -0,0 +1,35 @@ +using System.Text.RegularExpressions; + +namespace BaseLib.HotReload; + +/// +/// Hot reload builds stamp each assembly with a unique timestamp suffix like _hr143052789. +/// These helpers strip that suffix to recover the canonical mod name, and detect whether +/// a given DLL was produced by a hot reload build. +/// +internal static class AssemblyStamper +{ + private static readonly Regex HotReloadSuffixRegex = new(@"_hr\d{6,9}$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + /// + /// Strip the _hrNNNNNNNNN suffix from an assembly name or file path to get the canonical mod key. + /// E.g. "MyMod_hr143052789" → "MyMod", "E:/mods/MyMod/MyMod_hr143052789.dll" → "MyMod" + /// + public static string NormalizeModKey(string? assemblyNameOrPath) + { + if (string.IsNullOrWhiteSpace(assemblyNameOrPath)) + return ""; + var fileOrAssemblyName = Path.GetFileNameWithoutExtension(assemblyNameOrPath); + return HotReloadSuffixRegex.Replace(fileOrAssemblyName, ""); + } + + /// + /// Returns true if this DLL was produced by a hot reload build (has the _hrNNN suffix). + /// Used by the file watcher to ignore dependency DLLs and manifests. + /// + public static bool IsHotReloadStamped(string path) + { + var name = Path.GetFileNameWithoutExtension(path); + return HotReloadSuffixRegex.IsMatch(name); + } +} diff --git a/HotReload/HotReloadEngine.cs b/HotReload/HotReloadEngine.cs new file mode 100644 index 0000000..573100e --- /dev/null +++ b/HotReload/HotReloadEngine.cs @@ -0,0 +1,255 @@ +using System.Reflection; +using BaseLib.Config; + +namespace BaseLib.HotReload; + +/// +/// Public API for hot-reloading mod assemblies at runtime. +/// No external dependencies — pure C# + Harmony + GodotSharp. +/// +/// Usage: +/// HotReloadEngine.Reload("E:/game/mods/mymod/MyMod_hr143052789.dll"); +/// HotReloadEngine.ReloadByModId("MyMod"); +/// +/// Or use the in-game console command: hotreload [dll_path_or_mod_id] +/// +public static class HotReloadEngine +{ + // Only one reload at a time — we lock to prevent concurrent reloads + // (e.g., file watcher triggering while a manual reload is in progress) + private static readonly object _hotReloadLock = new(); + + // Per-mod session tracking — survives across multiple reloads of the same mod + private static readonly Dictionary _sessions = new(StringComparer.OrdinalIgnoreCase); + + // History of recent reloads for diagnostics + private static readonly List _history = []; + private const int MaxHistory = 20; + + // The file watcher that auto-triggers reload when a DLL changes + private static ModFileWatcher? _fileWatcher; + + /// + /// Current reload step name, or empty if no reload is in progress. + /// Check this to show progress in UI or respond to queries. + /// + public static string CurrentProgress { get; internal set; } = ""; + + /// + /// History of the most recent hot reloads (up to 20). + /// + public static IReadOnlyList ReloadHistory => _history; + + /// + /// The active file watcher, or null if disabled. + /// + public static ModFileWatcher? FileWatcher => _fileWatcher; + + /// + /// Fired after every reload attempt (success or failure). + /// Subscribe to this to perform custom cleanup or refresh logic. + /// + public static event Action? OnReloadComplete; + + /// + /// Reload a mod from a DLL path. Thread-safe (serialized via lock). + /// Should be called from the main thread (Godot scene tree access in Step 12). + /// + /// Absolute path to the mod DLL. + /// 1 = patch-only, 2 = entities + patches + loc, 3 = full + PCK. + /// Path to PCK file for tier 3 reloads. + public static HotReloadResult Reload(string dllPath, int tier = 2, string? pckPath = null) + { + if (!Monitor.TryEnter(_hotReloadLock)) + { + return new HotReloadResult + { + Success = false, + Errors = ["Hot reload already in progress. Wait for the current reload to finish."], + }; + } + + try + { + string modKey = AssemblyStamper.NormalizeModKey(dllPath); + CurrentProgress = "starting"; + + var session = GetOrCreateSession(modKey); + var result = HotReloadPipeline.Execute(dllPath, tier, pckPath, session); + + // Store in history + lock (_history) + { + _history.Add(result); + while (_history.Count > MaxHistory) + _history.RemoveAt(0); + } + + // Notify listeners + try { OnReloadComplete?.Invoke(result); } + catch (Exception ex) { BaseLibMain.Logger.Error($"[HotReload] OnReloadComplete handler error: {ex}"); } + + return result; + } + finally + { + CurrentProgress = ""; + Monitor.Exit(_hotReloadLock); + } + } + + /// + /// Scan the mods directory for the most recently modified DLL matching the given + /// mod ID and reload it. Convenient when you don't have the exact DLL path. + /// Pass tier=0 to auto-detect the tier from the mod directory contents. + /// + public static HotReloadResult ReloadByModId(string modId, int tier = 0) + { + // Look for the mod's folder in the game's mods directory + var modsDir = FindModsDirectory(); + if (modsDir == null) + { + return new HotReloadResult + { + Success = false, + Errors = [$"Could not find game mods directory"], + }; + } + + var modDir = Path.Combine(modsDir, modId); + if (!Directory.Exists(modDir)) + { + return new HotReloadResult + { + Success = false, + Errors = [$"Mod directory not found: {modDir}"], + }; + } + + // Find the most recently modified DLL in the mod directory + var latestDll = Directory.GetFiles(modDir, "*.dll") + .OrderByDescending(File.GetLastWriteTimeUtc) + .FirstOrDefault(); + + if (latestDll == null) + { + return new HotReloadResult + { + Success = false, + Errors = [$"No DLL found in {modDir}"], + }; + } + + // Auto-detect tier if not specified: + // - Has a .pck file → tier 3 (entities + patches + Godot resources) + // - Otherwise → tier 2 (entities + patches + localization) + // Tier 1 (patch-only) must be explicitly requested since it's rare. + if (tier <= 0) + tier = Directory.GetFiles(modDir, "*.pck").Length > 0 ? 3 : 2; + + string? pckPath = null; + if (tier >= 3) + pckPath = Directory.GetFiles(modDir, "*.pck").FirstOrDefault(); + + return Reload(latestDll, tier, pckPath); + } + + /// + /// Initialize the hot reload subsystem. Called from BaseLibMain.Initialize(). + /// Sets up the file watcher if enabled in config. + /// + internal static void Init() + { + // Run startup self-tests to verify all reflection targets exist in this game version. + // If any fail, something in the game changed and the pipeline will break at runtime. + HotReloadSelfTests.RunStartupTests(); + + // Register assembly resolution handlers early (at game startup) so they're in place + // before any hot reload happens. The MCP bridge does this in its ModEntry.Init(). + // Two handlers are needed: + // AppDomain.AssemblyResolve — fires for version mismatches + // ALC.Resolving — fires when default probing fails + // Both redirect by assembly short name to already-loaded assemblies. + AppDomain.CurrentDomain.AssemblyResolve += (_, args) => + { + var requestedName = new System.Reflection.AssemblyName(args.Name); + return AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => string.Equals(a.GetName().Name, requestedName.Name, StringComparison.Ordinal)); + }; + System.Runtime.Loader.AssemblyLoadContext.Default.Resolving += TypeSignatureHasher.DefaultAlcResolving; + HotReloadPipeline.MarkResolversRegistered(); + + BaseLibMain.Logger.Info("[HotReload] Hot reload engine initialized"); + + if (BaseLibConfig.EnableFileWatcher) + { + var modsDir = FindModsDirectory(); + if (modsDir != null) + { + _fileWatcher = new ModFileWatcher(modsDir); + _fileWatcher.OnModDllChanged += OnWatcherDetectedChange; + _fileWatcher.Start(); + BaseLibMain.Logger.Info($"[HotReload] File watcher started on {modsDir}"); + } + else + { + BaseLibMain.Logger.Warn("[HotReload] File watcher enabled but could not find mods directory"); + } + } + } + + // ─── Private helpers ──────────────────────────────────────────────── + + private static HotReloadSession GetOrCreateSession(string modKey) + { + lock (_sessions) + { + if (!_sessions.TryGetValue(modKey, out var session)) + { + session = new HotReloadSession(modKey); + _sessions[modKey] = session; + } + return session; + } + } + + /// + /// Called by the file watcher when a DLL changes. Dispatches to main thread + /// since the reload pipeline needs Godot scene tree access. + /// + private static void OnWatcherDetectedChange(string dllPath) + { + BaseLibMain.Logger.Info($"[HotReload] File watcher detected change: {dllPath}"); + + // The file watcher fires on a background thread, but the reload pipeline needs + // Godot's main thread (scene tree access, node property writes). CallDeferred + // queues the lambda to run on the next idle frame. + Godot.Callable.From(() => + { + var result = Reload(dllPath); + BaseLibMain.Logger.Info($"[HotReload] Auto-reload result: {result.Summary}"); + }).CallDeferred(); + } + + /// + /// Try to find the game's mods directory. Returns null if not found. + /// + private static string? FindModsDirectory() + { + // The game executable is typically at {GameDir}/SlayTheSpire2.exe + // and mods are at {GameDir}/mods/ + var executable = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName; + if (executable != null) + { + var gameDir = Path.GetDirectoryName(executable); + if (gameDir != null) + { + var modsDir = Path.Combine(gameDir, "mods"); + if (Directory.Exists(modsDir)) + return modsDir; + } + } + + return null; + } +} diff --git a/HotReload/HotReloadPipeline.cs b/HotReload/HotReloadPipeline.cs new file mode 100644 index 0000000..6ef5141 --- /dev/null +++ b/HotReload/HotReloadPipeline.cs @@ -0,0 +1,1004 @@ +using System.Collections; +using System.Diagnostics; +using System.Reflection; +using System.Runtime.Loader; +using BaseLib.Abstracts; +using BaseLib.Patches; +using BaseLib.Patches.Content; +using BaseLib.Patches.Utils; +// NodeFactory scene cleanup will be added when the auto-conversion branch merges +using Godot; +using HarmonyLib; +using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.Localization; +using MegaCrit.Sts2.Core.Modding; +using MegaCrit.Sts2.Core.Models; + +namespace BaseLib.HotReload; + +/// +/// The core hot reload pipeline. Executes a 12-step transactional process to reload +/// a mod assembly into the running game: load assembly, swap Harmony patches, replace +/// entities in ModelDb, refresh pools, re-inject localization, and update live instances. +/// +/// This runs on the main thread — the game UI is effectively paused during the reload. +/// Thread safety is enforced by a lock in HotReloadEngine (only one reload at a time). +/// +internal static class HotReloadPipeline +{ + private const BindingFlags StaticNonPublic = BindingFlags.NonPublic | BindingFlags.Static; + private const BindingFlags StaticPublic = BindingFlags.Public | BindingFlags.Static; + + // We only want to register the assembly resolution handlers once per game session. + // They stay active forever and handle all future hot-reloaded assemblies. + // Two handlers are needed: + // 1. AssemblyLoadContext.Default.Resolving — fires when default ALC probing fails + // 2. AppDomain.CurrentDomain.AssemblyResolve — fires for version mismatches + // Both redirect by assembly name (ignoring version) to already-loaded assemblies. + private static bool _defaultAlcResolvingRegistered; + + /// + /// Called by HotReloadEngine.Init() after registering the resolvers at startup. + /// Prevents the pipeline from double-registering them. + /// + internal static void MarkResolversRegistered() => _defaultAlcResolvingRegistered = true; + + /// + /// Run the full hot reload pipeline. Returns a structured result describing + /// everything that happened (or failed). + /// + public static HotReloadResult Execute( + string dllPath, + int tier, + string? pckPath, + HotReloadSession session) + { + // ─── Bookkeeping ───────────────────────────────────────────── + var actions = new List(); + var errors = new List(); + var warnings = new List(); + var changedEntities = new List(); + int entitiesRemoved = 0, entitiesInjected = 0, entitiesSkipped = 0; + int poolsUnfrozen = 0, poolRegs = 0, patchCount = 0; + int verified = 0, verifyFailed = 0, mutableOk = 0, mutableFailed = 0; + int liveRefreshed = 0, depsLoaded = 0; + bool locReloaded = false, pckReloaded = false; + bool alcCollectible = false; + var sw = Stopwatch.StartNew(); + var stepTimings = new Dictionary(); + long lastLap = 0; + + string modKey = session.ModKey; + Assembly? assembly = null; + string? assemblyName = null; + + // These track staged state so we can roll back if something goes wrong + // before we've committed to the new assembly. + var priorLoadContext = session.LoadContext; + var priorHarmony = session.HotReloadHarmony; + AssemblyLoadContext? stagedLoadContext = null; + Harmony? stagedHarmony = null; + bool sessionCommitted = false; + SerializationCacheSnapshot? serializationSnapshot = null; + List newModelTypes = []; + var previousModAssemblyRefs = new List<(object mod, FieldInfo field, Assembly prev)>(); + + // If anything goes wrong before we commit, undo the staged Harmony patches + // and unload the collectible ALC so we don't leak state. + void CleanupStaged() + { + if (sessionCommitted) return; + if (stagedHarmony != null) + { + try { stagedHarmony.UnpatchAll(stagedHarmony.Id); } + catch (Exception ex) { warnings.Add($"staged_harmony_cleanup: {ex.Message}"); } + stagedHarmony = null; + } + if (stagedLoadContext != null) + { + UnloadCollectibleAlc(stagedLoadContext, warnings); + stagedLoadContext = null; + } + } + + // Shorthand to build the result at any point (success or failure) + HotReloadResult Finish() => new() + { + Success = errors.Count == 0, + Tier = tier, + AssemblyName = assemblyName, + PatchCount = patchCount, + EntitiesRemoved = entitiesRemoved, + EntitiesInjected = entitiesInjected, + EntitiesSkipped = entitiesSkipped, + PoolsUnfrozen = poolsUnfrozen, + PoolRegistrations = poolRegs, + LocalizationReloaded = locReloaded, + PckReloaded = pckReloaded, + LiveInstancesRefreshed = liveRefreshed, + MutableCheckPassed = mutableOk, + MutableCheckFailed = mutableFailed, + AlcCollectible = alcCollectible, + TotalMs = sw.ElapsedMilliseconds, + Timestamp = DateTime.UtcNow.ToString("o"), + StepTimings = stepTimings, + Actions = actions, + Errors = errors, + Warnings = warnings, + ChangedEntities = changedEntities, + }; + + // ─── Validate input ────────────────────────────────────────── + if (string.IsNullOrWhiteSpace(dllPath) || !File.Exists(dllPath)) + { + errors.Add($"dll_not_found: {dllPath}"); + return Finish(); + } + tier = Math.Clamp(tier, 1, 3); + + BaseLibMain.Logger.Info($"[HotReload] Starting tier {tier} reload from {dllPath}"); + + // ═════════════════════════════════════════════════════════════ + // STEP 1: Load the new assembly + // ═════════════════════════════════════════════════════════════ + // Tier 1 (patch-only) uses a collectible ALC so the old assembly can be + // garbage collected. Tier 2+ uses the default ALC because cross-ALC type + // identity breaks ModelDb entity injection and runtime type casts. + // The caller's build must stamp a unique assembly name (e.g., MyMod_hr143052789) + // so the default ALC accepts it even if a previous version is loaded. + HotReloadEngine.CurrentProgress = "loading_assembly"; + try + { + var modDir = Path.GetDirectoryName(dllPath)!; + var mainDllName = Path.GetFileNameWithoutExtension(dllPath); + string[] sharedDlls = ["GodotSharp", "0Harmony", "sts2"]; + + // Load dependency DLLs (NuGet packages, other mod libs) into default ALC. + // These must live in the default context for shared type identity. + // IMPORTANT: Skip the main mod DLL, framework DLLs, old stamped versions + // of this mod, and any DLL that's already loaded. + foreach (var depDll in Directory.GetFiles(modDir, "*.dll")) + { + var depName = Path.GetFileNameWithoutExtension(depDll); + var depNormalized = AssemblyStamper.NormalizeModKey(depName); + + // Skip framework DLLs, the main mod DLL, and any old hot-reload + // versions of this mod (stamped or unstamped) + if (sharedDlls.Any(s => string.Equals(s, depName, StringComparison.OrdinalIgnoreCase)) + || string.Equals(depName, mainDllName, StringComparison.OrdinalIgnoreCase) + || string.Equals(depNormalized, modKey, StringComparison.OrdinalIgnoreCase)) + continue; + + // Also skip DLLs that look like old MCP-stamped versions of this mod + // (e.g., HotReloadTest_132829197112.dll — no _hr prefix, just digits) + if (depName.StartsWith(modKey, StringComparison.OrdinalIgnoreCase) + && depName.Length > modKey.Length + && depName[modKey.Length] == '_') + continue; + + var existing = AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => a.GetName().Name == depName); + + if (existing == null) + { + try + { + // Load deps into the same ALC as the game so type identity is preserved + var depAlc = AssemblyLoadContext.GetLoadContext(typeof(AbstractModel).Assembly) + ?? AssemblyLoadContext.Default; + depAlc.LoadFromAssemblyPath(Path.GetFullPath(depDll)); + depsLoaded++; + } + catch (Exception ex) { warnings.Add($"dep_load_{depName}: {ex.Message}"); } + } + else + { + // Warn if the on-disk version doesn't match what's loaded — + // dependency changes require a game restart. + try + { + var onDisk = AssemblyName.GetAssemblyName(Path.GetFullPath(depDll)).Version; + var loaded = existing.GetName().Version; + if (onDisk != null && loaded != null && onDisk != loaded) + warnings.Add($"dep_stale_{depName}: loaded={loaded}, on_disk={onDisk}. Restart required for dep changes."); + } + catch { /* version check is best-effort */ } + } + } + + if (tier <= 1) + { + // Collectible ALC: can be unloaded later to reclaim memory. + // Patches don't need type identity with ModelDb. + try + { + var alc = new AssemblyLoadContext($"HotReload-{DateTime.Now.Ticks}", isCollectible: true); + alc.Resolving += (_, name) => + AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(a => a.GetName().Name == name.Name); + assembly = alc.LoadFromAssemblyPath(dllPath); + stagedLoadContext = alc; + alcCollectible = true; + } + catch (Exception ex) + { + // Fall back to default ALC if collectible fails + warnings.Add($"collectible_alc_fallback: {ex.Message}"); + assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(dllPath); + } + } + else + { + // Tier 2+: load into the SAME ALC as the game's sts2.dll so type identity + // works (IsSubclassOf, is checks, etc.). Godot may use a custom ALC for mods + // rather than the default, so we can't assume Default is correct. + if (!_defaultAlcResolvingRegistered) + { + AssemblyLoadContext.Default.Resolving += TypeSignatureHasher.DefaultAlcResolving; + _defaultAlcResolvingRegistered = true; + } + + var targetAlc = AssemblyLoadContext.GetLoadContext(typeof(AbstractModel).Assembly) + ?? AssemblyLoadContext.Default; + assembly = targetAlc.LoadFromAssemblyPath(dllPath); + } + + assemblyName = assembly.FullName; + actions.Add(alcCollectible ? "assembly_loaded_collectible" : "assembly_loaded"); + if (depsLoaded > 0) + actions.Add($"dependencies_loaded:{depsLoaded}"); + BaseLibMain.Logger.Info($"[HotReload] Assembly: {assembly.FullName} (+{depsLoaded} deps, collectible={alcCollectible})"); + } + catch (Exception ex) + { + errors.Add($"assembly_load: {ex.Message}"); + CleanupStaged(); + return Finish(); + } + stepTimings["step1_assembly_load"] = sw.ElapsedMilliseconds - lastLap; + lastLap = sw.ElapsedMilliseconds; + + // ═════════════════════════════════════════════════════════════ + // STEP 2: Stage new Harmony patches + // ═════════════════════════════════════════════════════════════ + // We create a new Harmony instance with a unique ID per reload so patches + // from different reloads don't collide. The staged patches are applied now + // but can be unpatched during rollback if entity injection fails later. + HotReloadEngine.CurrentProgress = "patching_harmony"; + try + { + var harmonyId = $"baselib.hotreload.{modKey}.{Guid.NewGuid():N}"; + stagedHarmony = new Harmony(harmonyId); + stagedHarmony.PatchAll(assembly); + + patchCount = Harmony.GetAllPatchedMethods() + .Select(m => Harmony.GetPatchInfo(m)) + .Where(info => info != null) + .Select(info => info!.Prefixes.Count(p => p.owner == harmonyId) + + info.Postfixes.Count(p => p.owner == harmonyId) + + info.Transpilers.Count(p => p.owner == harmonyId)) + .Sum(); + + actions.Add("harmony_staged"); + } + catch (Exception ex) + { + errors.Add($"harmony: {ex.Message}"); + CleanupStaged(); + } + stepTimings["step2_harmony"] = sw.ElapsedMilliseconds - lastLap; + lastLap = sw.ElapsedMilliseconds; + + // ── Tier 1 stops here (patch-only reload) ──────────────────── + if (tier < 2) + { + if (stagedHarmony != null) + { + CommitSession(session, stagedHarmony, stagedLoadContext, assembly, ref sessionCommitted); + UnpatchPrevious(priorHarmony, stagedHarmony, actions, warnings); + RemoveStalePatchesForMod(modKey, assembly, actions); + if (priorLoadContext != null && !ReferenceEquals(priorLoadContext, stagedLoadContext)) + UnloadCollectibleAlc(priorLoadContext, warnings); + actions.Add("harmony_repatched"); + } + else + { + errors.Add("harmony: no staged instance was created"); + } + return Finish(); + } + + // ═════════════════════════════════════════════════════════════ + // STEP 3: Update Mod.assembly reference in ModManager + // ═════════════════════════════════════════════════════════════ + // The game's ModManager tracks each mod's assembly. We swap it to the new + // one so that ReflectionHelper.ModTypes picks up the new types. + HotReloadEngine.CurrentProgress = "updating_mod_reference"; + try + { + var loadedModsField = typeof(ModManager).GetField("_mods", StaticNonPublic); + if (loadedModsField?.GetValue(null) is IList loadedMods) + { + int updated = 0; + foreach (var mod in loadedMods) + { + var asmField = mod.GetType().GetField("assembly", BindingFlags.Public | BindingFlags.Instance); + if (asmField?.GetValue(mod) is not Assembly currentAsm) continue; + if (!string.Equals(AssemblyStamper.NormalizeModKey(currentAsm.GetName().Name), modKey, StringComparison.OrdinalIgnoreCase)) + continue; + + previousModAssemblyRefs.Add((mod, asmField, currentAsm)); + asmField.SetValue(mod, assembly); + updated++; + } + if (updated > 0) actions.Add("mod_reference_updated"); + } + } + catch (Exception ex) { errors.Add($"mod_ref: {ex.Message}"); } + stepTimings["step3_mod_reference"] = sw.ElapsedMilliseconds - lastLap; + lastLap = sw.ElapsedMilliseconds; + + // ═════════════════════════════════════════════════════════════ + // STEP 4: Invalidate ReflectionHelper._modTypes cache + // ═════════════════════════════════════════════════════════════ + // The game caches the list of mod types. Nulling it forces a rebuild + // that includes types from the new assembly. + HotReloadEngine.CurrentProgress = "invalidating_reflection_cache"; + try + { + typeof(ReflectionHelper).GetField("_modTypes", StaticNonPublic)?.SetValue(null, null); + actions.Add("reflection_cache_invalidated"); + } + catch (Exception ex) { errors.Add($"reflection_cache: {ex.Message}"); } + stepTimings["step4_reflection_cache"] = sw.ElapsedMilliseconds - lastLap; + lastLap = sw.ElapsedMilliseconds; + + // ═════════════════════════════════════════════════════════════ + // STEP 5: Register new entity IDs in ModelIdSerializationCache + // ═════════════════════════════════════════════════════════════ + // The serialization cache maps category/entry names to net IDs for multiplayer. + // It's built at boot time, so our new entities aren't in it. We must register + // them BEFORE constructing any ModelId objects. We also snapshot the cache so + // we can roll back if entity injection fails. + HotReloadEngine.CurrentProgress = "registering_entity_ids"; + + // Collect AbstractModel subtypes sorted by injection priority: + // Powers first (cards reference them via PowerVar), events last. + newModelTypes = TypeSignatureHasher.GetLoadableTypes(assembly, warnings, "new_assembly_types") + .Where(t => !t.IsAbstract && !t.IsInterface && TypeSignatureHasher.InheritsFromByName(t, nameof(AbstractModel))) + .OrderBy(TypeSignatureHasher.GetInjectionPriority) + .ToList(); + + try + { + var cacheType = typeof(ModelId).Assembly.GetType("MegaCrit.Sts2.Core.Multiplayer.Serialization.ModelIdSerializationCache"); + if (cacheType != null) + { + serializationSnapshot = SerializationCacheSnapshot.Capture(cacheType); + var categoryMap = cacheType.GetField("_categoryNameToNetIdMap", StaticNonPublic)?.GetValue(null) as Dictionary; + var categoryList = cacheType.GetField("_netIdToCategoryNameMap", StaticNonPublic)?.GetValue(null) as List; + var entryMap = cacheType.GetField("_entryNameToNetIdMap", StaticNonPublic)?.GetValue(null) as Dictionary; + var entryList = cacheType.GetField("_netIdToEntryNameMap", StaticNonPublic)?.GetValue(null) as List; + + int registered = 0; + foreach (var newType in newModelTypes) + { + var (category, entry) = TypeSignatureHasher.GetCategoryAndEntry(newType); + if (categoryMap != null && categoryList != null && !categoryMap.ContainsKey(category)) + { + categoryMap[category] = categoryList.Count; + categoryList.Add(category); + } + if (entryMap != null && entryList != null && !entryMap.ContainsKey(entry)) + { + entryMap[entry] = entryList.Count; + entryList.Add(entry); + registered++; + } + } + if (registered > 0) + { + // Update bit sizes so network serialization doesn't truncate + var catBitProp = cacheType.GetProperty("CategoryIdBitSize", StaticPublic); + var entBitProp = cacheType.GetProperty("EntryIdBitSize", StaticPublic); + if (catBitProp?.SetMethod != null && categoryList != null) + catBitProp.SetValue(null, TypeSignatureHasher.ComputeBitSize(categoryList.Count)); + if (entBitProp?.SetMethod != null && entryList != null) + entBitProp.SetValue(null, TypeSignatureHasher.ComputeBitSize(entryList.Count)); + actions.Add($"serialization_cache_updated:{registered}"); + } + } + } + catch (Exception ex) { warnings.Add($"serialization_cache: {ex.Message}"); } + stepTimings["step5_serialization_cache"] = sw.ElapsedMilliseconds - lastLap; + lastLap = sw.ElapsedMilliseconds; + + // ═════════════════════════════════════════════════════════════ + // STEP 6: Transactionally replace entities in ModelDb + // ═════════════════════════════════════════════════════════════ + // This is the heart of hot reload. We snapshot the current ModelDb state, + // compute signature hashes to detect which types actually changed, create + // new instances for changed types, and commit them atomically. If anything + // fails during staging, we roll back the entire ModelDb to its pre-reload state. + HotReloadEngine.CurrentProgress = "reloading_entities"; + var entitySnapshot = new Dictionary(); + try + { + var contentByIdField = typeof(ModelDb).GetField("_contentById", StaticNonPublic); + var typedDict = contentByIdField?.GetValue(null) as Dictionary + ?? throw new InvalidOperationException("ModelDb._contentById not found"); + + // Snapshot everything we might touch + var affectedIds = new HashSet(); + var oldTypeSignatures = new Dictionary(StringComparer.Ordinal); + var removedTypeNames = new Dictionary(); + + // Find entities from previous versions of this mod's assembly + foreach (var oldAssembly in TypeSignatureHasher.GetAssembliesForMod(modKey, assembly)) + { + foreach (var oldType in TypeSignatureHasher.GetLoadableTypes(oldAssembly, warnings, $"old_assembly_types:{oldAssembly.FullName}") + .Where(t => !t.IsAbstract && !t.IsInterface && TypeSignatureHasher.InheritsFromByName(t, nameof(AbstractModel)))) + { + try + { + var id = TypeSignatureHasher.BuildModelId(oldType); + affectedIds.Add(id); + removedTypeNames[id] = oldType.Name; + if (typedDict.TryGetValue(id, out var existing)) + entitySnapshot[id] = existing; + oldTypeSignatures[oldType.FullName ?? oldType.Name] = TypeSignatureHasher.ComputeHash(oldType); + } + catch (Exception ex) { warnings.Add($"snapshot_{oldType.Name}: {ex.Message}"); } + } + } + + // Also snapshot any new type IDs in case they're already there somehow + foreach (var newType in newModelTypes) + { + try + { + var id = TypeSignatureHasher.BuildModelId(newType); + affectedIds.Add(id); + if (typedDict.TryGetValue(id, out var existing)) + entitySnapshot[id] = existing; + } + catch (Exception ex) { warnings.Add($"snapshot_{newType.Name}: {ex.Message}"); } + } + + // ── Clean up old registrations BEFORE creating new instances ── + // Two reasons this must happen first: + // 1. The game's AbstractModel constructor throws DuplicateModelException if a + // canonical model of the same type already exists in ModelDb._contentById. + // We must remove old entries before Activator.CreateInstance(). + // 2. BaseLib entity constructors auto-register in pools via + // CustomContentDictionary.AddModel(). Old pool entries must be gone first. + foreach (var oldAssembly in TypeSignatureHasher.GetAssembliesForMod(modKey, assembly)) + { + CustomContentDictionary.RemoveByAssembly(oldAssembly); + ModelDbSharedCardPoolsPatch.RemoveByAssembly(oldAssembly); + ModelDbSharedRelicPoolsPatch.RemoveByAssembly(oldAssembly); + ModelDbSharedPotionPoolsPatch.RemoveByAssembly(oldAssembly); + } + + // Remove all affected entities from ModelDb BEFORE creating new instances + int removed = 0; + foreach (var id in affectedIds) + { + if (typedDict.Remove(id)) + removed++; + } + entitiesRemoved = removed; + + // Log entities that won't be re-injected (type was deleted from new assembly) + foreach (var (id, removedName) in removedTypeNames) + { + if (!newModelTypes.Any(t => TypeSignatureHasher.BuildModelId(t).Equals(id))) + changedEntities.Add(new ChangedEntity { Name = removedName, Action = "removed" }); + } + + // Now create new entity instances — safe because old ones are gone from ModelDb + var stagedModels = new Dictionary(); + foreach (var newType in newModelTypes) + { + try + { + var id = TypeSignatureHasher.BuildModelId(newType); + var fullName = newType.FullName ?? newType.Name; + + // Incremental: skip types whose signature hash hasn't changed + if (oldTypeSignatures.TryGetValue(fullName, out var oldHash) + && entitySnapshot.TryGetValue(id, out var existingModel) + && TypeSignatureHasher.ComputeHash(newType) == oldHash) + { + stagedModels[id] = existingModel; + entitiesSkipped++; + changedEntities.Add(new ChangedEntity { Name = newType.Name, Action = "unchanged" }); + continue; + } + + // Create a fresh instance — this triggers BaseLib constructor auto-registration + // (CustomContentDictionary.AddModel → ModHelper.AddModelToPool) + var instance = Activator.CreateInstance(newType); + if (instance is not AbstractModel model) + throw new InvalidOperationException($"{newType.FullName} is not assignable to AbstractModel at runtime"); + + model.InitId(id); + stagedModels[id] = model; + entitiesInjected++; + changedEntities.Add(new ChangedEntity { Name = newType.Name, Action = "injected", Id = id.ToString() }); + } + catch (Exception ex) + { + var inner = ex.InnerException ?? ex; + errors.Add($"inject_{newType.Name}: {inner.GetType().Name}: {inner.Message}"); + BaseLibMain.Logger.Error($"[HotReload] Failed to stage {newType.Name}: {inner}"); + } + } + + // If any entity failed to stage, abort the entire commit + if (errors.Any(e => e.StartsWith("inject_", StringComparison.Ordinal))) + throw new InvalidOperationException("Entity staging failed; ModelDb changes were not committed."); + + // ── Insert staged models into ModelDb ──────────────── + // Old entities were already removed above (before Activator.CreateInstance). + foreach (var (id, model) in stagedModels) + typedDict[id] = model; + + actions.Add("entities_reregistered"); + if (entitiesSkipped > 0) actions.Add($"entities_unchanged:{entitiesSkipped}"); + } + catch (Exception ex) + { + // ── ROLLBACK: restore ModelDb to pre-reload state ──────── + errors.Add($"entity_reload: {ex.Message}"); + BaseLibMain.Logger.Error($"[HotReload] Entity reload error, rolling back: {ex}"); + + try + { + var contentByIdField = typeof(ModelDb).GetField("_contentById", StaticNonPublic); + if (contentByIdField?.GetValue(null) is Dictionary rollbackDict) + RestoreEntitySnapshot(rollbackDict, entitySnapshot, modKey); + } + catch (Exception rbEx) { errors.Add($"rollback_entities: {rbEx.Message}"); } + + // Restore Mod.assembly references to previous values + foreach (var (modRef, field, prev) in previousModAssemblyRefs) + { + try { field.SetValue(modRef, prev); } + catch (Exception rbEx) { warnings.Add($"rollback_mod_ref: {rbEx.Message}"); } + } + + // Re-invalidate type cache so it rebuilds without new assembly + try { typeof(ReflectionHelper).GetField("_modTypes", StaticNonPublic)?.SetValue(null, null); } + catch { /* best effort */ } + + serializationSnapshot?.Restore(); + CleanupStaged(); + return Finish(); + } + stepTimings["step6_entity_reload"] = sw.ElapsedMilliseconds - lastLap; + lastLap = sw.ElapsedMilliseconds; + + // ═════════════════════════════════════════════════════════════ + // STEP 7: Null ModelDb cached enumerables + // ═════════════════════════════════════════════════════════════ + // ModelDb lazily caches collections like AllCards, AllRelics, etc. + // We null them so the next access re-enumerates from _contentById + // and picks up our newly injected entities. + HotReloadEngine.CurrentProgress = "clearing_modeldb_caches"; + try + { + string[] cacheFields = + [ + "_allCards", "_allCardPools", "_allCharacterCardPools", + "_allSharedEvents", "_allEvents", "_allEncounters", "_allPotions", + "_allPotionPools", "_allCharacterPotionPools", "_allSharedPotionPools", + "_allPowers", "_allRelics", "_allCharacterRelicPools", "_achievements" + ]; + foreach (var fieldName in cacheFields) + typeof(ModelDb).GetField(fieldName, StaticNonPublic)?.SetValue(null, null); + actions.Add("modeldb_caches_cleared"); + } + catch (Exception ex) { errors.Add($"modeldb_caches: {ex.Message}"); } + stepTimings["step7_modeldb_caches"] = sw.ElapsedMilliseconds - lastLap; + lastLap = sw.ElapsedMilliseconds; + + // ═════════════════════════════════════════════════════════════ + // STEP 8: Unfreeze pools + null pool instance caches + // ═════════════════════════════════════════════════════════════ + // ModHelper._moddedContentForPools entries have an isFrozen flag that blocks + // new registrations. We unfreeze them, remove entries belonging to the old + // assembly, and then null the lazy caches on pool model instances. + // + // NOTE: We don't need to explicitly re-register pool entries here! + // Step 6's Activator.CreateInstance() already triggered BaseLib's + // constructor auto-registration (CustomContentDictionary.AddModel → + // ModHelper.AddModelToPool). This is the big simplification vs the bridge. + HotReloadEngine.CurrentProgress = "refreshing_pools"; + try + { + var result = UnfreezeAndCleanPools(assembly, modKey, warnings); + poolsUnfrozen = result.unfrozen; + actions.Add("pools_refreshed"); + } + catch (Exception ex) { errors.Add($"pool_refresh: {ex.Message}"); } + stepTimings["step8_pools"] = sw.ElapsedMilliseconds - lastLap; + lastLap = sw.ElapsedMilliseconds; + + // ═════════════════════════════════════════════════════════════ + // STEP 8.5: Refresh BaseLib subsystems + // ═════════════════════════════════════════════════════════════ + // This is NEW — the bridge mod couldn't do this because it doesn't have + // access to BaseLib internals. We re-run PostModInit processing (ModInterop, + // SavedProperty, SavedSpireField) and custom enum generation for the new types. + HotReloadEngine.CurrentProgress = "refreshing_baselib_subsystems"; + try + { + var newTypes = TypeSignatureHasher.GetLoadableTypes(assembly).ToList(); + + // Clean up old registrations from all previous versions of this mod + foreach (var oldAsm in TypeSignatureHasher.GetAssembliesForMod(modKey, assembly)) + { + SavedSpireFieldPatch.RemoveByAssembly(oldAsm); + ModelDbCustomCharacters.RemoveByAssembly(oldAsm); + CustomOrbModel.RemoveByAssembly(oldAsm); + } + + // Re-run PostModInit logic: ModInterop, SavedProperty, SavedSpireField + PostModInitPatch.ProcessTypes(newTypes); + SavedSpireFieldPatch.AddFieldsSorted(); + + // Re-generate custom enum values (skips already-generated fields via dedup) + GenEnumValues.GenerateForTypes(newTypes); + + actions.Add("baselib_subsystems_refreshed"); + } + catch (Exception ex) { warnings.Add($"baselib_subsystems: {ex.Message}"); } + stepTimings["step8_5_baselib"] = sw.ElapsedMilliseconds - lastLap; + lastLap = sw.ElapsedMilliseconds; + + // ═════════════════════════════════════════════════════════════ + // STEP 9: Reload localization + // ═════════════════════════════════════════════════════════════ + // Two-part: reload file-based loc tables (SetLanguage), then re-inject + // ILocalizationProvider entries from our newly injected entities. + // The bridge just calls SetLanguage and hopes for the best — we can do better + // because we own ModelLocPatch and can call it directly. + HotReloadEngine.CurrentProgress = "reloading_localization"; + try + { + var locManager = LocManager.Instance; + if (locManager != null) + { + // Reload file-based localization tables + locManager.SetLanguage(locManager.Language); + + // Re-inject ILocalizationProvider entries for ALL entities (not just + // the reloaded mod) because SetLanguage wipes the loc tables clean. + var contentByIdField = typeof(ModelDb).GetField("_contentById", StaticNonPublic); + if (contentByIdField?.GetValue(null) is Dictionary contentById) + ModelLocPatch.InjectLocalization(contentById); + + locReloaded = true; + actions.Add("localization_reloaded"); + } + } + catch (Exception ex) { errors.Add($"localization: {ex.Message}"); } + stepTimings["step9_localization"] = sw.ElapsedMilliseconds - lastLap; + lastLap = sw.ElapsedMilliseconds; + + // ═════════════════════════════════════════════════════════════ + // STEP 10: Remount PCK (tier 3 only) + // ═════════════════════════════════════════════════════════════ + // Godot resources (scenes, textures, etc.) live in PCK files. + // LoadResourcePack overlays new data onto the virtual filesystem. + HotReloadEngine.CurrentProgress = "remounting_pck"; + if (tier >= 3 && !string.IsNullOrEmpty(pckPath) && File.Exists(pckPath)) + { + try + { + if (ProjectSettings.LoadResourcePack(pckPath)) + { + pckReloaded = true; + actions.Add("pck_remounted"); + // Re-trigger loc to pick up new PCK loc files + try { LocManager.Instance?.SetLanguage(LocManager.Instance.Language); } catch { } + } + else + { + errors.Add($"pck_load_failed: Godot returned false for {pckPath}"); + } + } + catch (Exception ex) { errors.Add($"pck: {ex.Message}"); } + } + else if (tier >= 3 && string.IsNullOrEmpty(pckPath)) + { + warnings.Add("Tier 3 requested but no pck_path provided"); + } + + if (!alcCollectible) + warnings.Add("Old assembly loaded into default ALC (non-collectible); memory will accumulate"); + stepTimings["step10_pck"] = sw.ElapsedMilliseconds - lastLap; + lastLap = sw.ElapsedMilliseconds; + + // ═════════════════════════════════════════════════════════════ + // STEP 11: Verify injected entities exist in ModelDb + // ═════════════════════════════════════════════════════════════ + // Sanity check: make sure everything we staged actually landed in ModelDb. + // Also test ToMutable() on cards to catch PowerVar resolution failures + // early (e.g., when a card references a power that wasn't injected). + HotReloadEngine.CurrentProgress = "verifying_entities"; + if (entitiesInjected > 0) + { + try + { + foreach (var type in newModelTypes) + { + if (ModelDb.Contains(type)) + verified++; + else + verifyFailed++; + } + if (verifyFailed > 0) + warnings.Add($"verify: {verifyFailed}/{newModelTypes.Count} injected types missing from ModelDb"); + else + actions.Add($"verified:{verified}_entities_in_modeldb"); + + // ToMutable check on cards + var contentByIdField = typeof(ModelDb).GetField("_contentById", StaticNonPublic); + if (contentByIdField?.GetValue(null) is Dictionary typedDict) + { + foreach (var cardType in newModelTypes.Where(t => TypeSignatureHasher.InheritsFromByName(t, nameof(CardModel)))) + { + try + { + var id = TypeSignatureHasher.BuildModelId(cardType); + if (typedDict.TryGetValue(id, out var cardModel) && cardModel is CardModel card) + { + card.ToMutable(); + mutableOk++; + } + } + catch (Exception ex) + { + mutableFailed++; + var inner = ex.InnerException ?? ex; + warnings.Add($"ToMutable_{cardType.Name}: {inner.GetType().Name}: {inner.Message}"); + } + } + if (mutableOk > 0) actions.Add($"mutable_check_passed:{mutableOk}"); + if (mutableFailed > 0) actions.Add($"mutable_check_failed:{mutableFailed}"); + } + } + catch (Exception ex) { warnings.Add($"verify: {ex.Message}"); } + } + stepTimings["step11_verify"] = sw.ElapsedMilliseconds - lastLap; + lastLap = sw.ElapsedMilliseconds; + + // ═════════════════════════════════════════════════════════════ + // STEP 12: Refresh live instances in scene tree and run state + // ═════════════════════════════════════════════════════════════ + // Walk the Godot scene tree and swap Model properties on card/relic/power/ + // potion/creature nodes. Then walk the current run's player state and replace + // mutable instances in the deck, combat piles, relics, potions, and powers. + HotReloadEngine.CurrentProgress = "refreshing_live_instances"; + if (entitiesInjected > 0) + { + try + { + liveRefreshed = LiveInstanceRefresher.RefreshSceneTree(); + if (liveRefreshed > 0) actions.Add($"live_instances_refreshed:{liveRefreshed}"); + } + catch (Exception ex) { warnings.Add($"live_refresh: {ex.Message}"); } + + try + { + int runRefreshed = LiveInstanceRefresher.RefreshRunInstances(assembly, modKey); + if (runRefreshed > 0) + { + liveRefreshed += runRefreshed; + actions.Add($"run_instances_refreshed:{runRefreshed}"); + } + } + catch (Exception ex) { warnings.Add($"run_refresh: {ex.Message}"); } + } + stepTimings["step12_live_refresh"] = sw.ElapsedMilliseconds - lastLap; + + // ═════════════════════════════════════════════════════════════ + // FINALIZE: Commit session, clean up old patches + // ═════════════════════════════════════════════════════════════ + try + { + if (stagedHarmony != null) + { + CommitSession(session, stagedHarmony, stagedLoadContext, assembly, ref sessionCommitted); + actions.Add("harmony_repatched"); + } + UnpatchPrevious(priorHarmony, stagedHarmony, actions, warnings); + RemoveStalePatchesForMod(modKey, assembly, actions); + if (priorLoadContext != null && !ReferenceEquals(priorLoadContext, stagedLoadContext)) + UnloadCollectibleAlc(priorLoadContext, warnings); + } + catch (Exception ex) { warnings.Add($"session_commit: {ex.Message}"); } + + BaseLibMain.Logger.Info($"[HotReload] {(errors.Count == 0 ? "Complete" : "Failed")} — " + + $"{entitiesInjected} entities, {patchCount} patches, {liveRefreshed} live ({sw.ElapsedMilliseconds}ms)"); + + return Finish(); + } + + // ─── Helper methods ───────────────────────────────────────────────── + + private static void CommitSession( + HotReloadSession session, Harmony harmony, AssemblyLoadContext? alc, + Assembly? assembly, ref bool committed) + { + session.HotReloadHarmony = harmony; + session.LoadContext = alc; + session.LastLoadedAssembly = assembly; + committed = true; + } + + /// + /// Unpatch the previous Harmony instance for this mod (the one from the last reload). + /// + private static void UnpatchPrevious( + Harmony? prior, Harmony? staged, List actions, List warnings) + { + if (prior == null || staged == null || prior.Id == staged.Id) return; + try + { + prior.UnpatchAll(prior.Id); + actions.Add("previous_harmony_unpatched"); + } + catch (Exception ex) { warnings.Add($"previous_harmony_unpatch: {ex.Message}"); } + } + + /// + /// Scan all patched methods and remove any patches that came from old versions + /// of this mod's assembly (not the current one). This catches patches that were + /// applied by the mod's Init() at game boot but are now stale. + /// + private static void RemoveStalePatchesForMod(string modKey, Assembly? currentAssembly, List actions) + { + int staleRemoved = 0; + foreach (var method in Harmony.GetAllPatchedMethods().ToList()) + { + var info = Harmony.GetPatchInfo(method); + if (info == null) continue; + + foreach (var patch in info.Prefixes.Concat(info.Postfixes).Concat(info.Transpilers)) + { + if (patch.PatchMethod?.DeclaringType?.Assembly is not Assembly patchAsm) continue; + if (patchAsm == currentAssembly) continue; + if (!string.Equals(AssemblyStamper.NormalizeModKey(patchAsm.GetName().Name), modKey, StringComparison.OrdinalIgnoreCase)) + continue; + + try + { + new Harmony(patch.owner).Unpatch(method, patch.PatchMethod); + staleRemoved++; + } + catch { /* best effort */ } + } + } + if (staleRemoved > 0) actions.Add($"stale_patches_removed:{staleRemoved}"); + } + + /// + /// Unfreeze ModHelper._moddedContentForPools and null the lazy caches on + /// pool model instances (CardPoolModel._allCards, etc.). This is game code + /// so we have to use reflection. + /// + private static (int unfrozen, int registered) UnfreezeAndCleanPools( + Assembly newAssembly, string modKey, List warnings) + { + int unfrozen = 0; + + // Build set of type names from old + new assembly for targeted cleanup + var reloadedTypeNames = new HashSet( + TypeSignatureHasher.GetLoadableTypes(newAssembly, warnings, "pool_type_scan") + .Where(t => !t.IsAbstract && !t.IsInterface) + .Select(t => t.FullName ?? t.Name)); + foreach (var oldAsm in TypeSignatureHasher.GetAssembliesForMod(modKey, newAssembly)) + { + foreach (var t in TypeSignatureHasher.GetLoadableTypes(oldAsm).Where(t => !t.IsAbstract && !t.IsInterface)) + reloadedTypeNames.Add(t.FullName ?? t.Name); + } + + // Unfreeze and remove old entries from ModHelper._moddedContentForPools + var poolsField = typeof(ModHelper).GetField("_moddedContentForPools", StaticNonPublic); + if (poolsField?.GetValue(null) is IDictionary pools) + { + foreach (var key in pools.Keys.Cast().ToList()) + { + var content = pools[key]; + if (content == null) continue; + var contentType = content.GetType(); + + // Unfreeze so ModHelper.AddModelToPool works + var frozenField = contentType.GetField("isFrozen"); + if (frozenField != null && (bool)frozenField.GetValue(content)!) + { + frozenField.SetValue(content, false); + unfrozen++; + } + + // Remove only entries belonging to the reloaded mod + if (contentType.GetField("modelsToAdd")?.GetValue(content) is IList modelsList) + { + for (int i = modelsList.Count - 1; i >= 0; i--) + { + var entry = modelsList[i]; + if (entry != null) + { + var typeName = entry.GetType().FullName ?? entry.GetType().Name; + if (reloadedTypeNames.Contains(typeName)) + modelsList.RemoveAt(i); + } + } + } + } + } + + // Null lazy caches on pool model instances so they re-enumerate + NullPoolInstanceCaches(typeof(CardPoolModel), "_allCards", "_allCardIds"); + NullPoolInstanceCaches(typeof(RelicPoolModel), "_relics", "_allRelicIds"); + NullPoolInstanceCaches(typeof(PotionPoolModel), "_allPotions", "_allPotionIds"); + + return (unfrozen, 0); // registered count comes from constructor auto-registration + } + + /// + /// Null the lazy-computed fields on all instances of a pool base type. + /// Pool instances are singletons stored in ModelDb._contentById. + /// + private static void NullPoolInstanceCaches(Type basePoolType, params string[] fieldNames) + { + var contentByIdField = typeof(ModelDb).GetField("_contentById", StaticNonPublic); + if (contentByIdField?.GetValue(null) is not IDictionary contentById) return; + + foreach (var value in contentById.Values) + { + if (value == null || !basePoolType.IsAssignableFrom(value.GetType())) continue; + foreach (var fieldName in fieldNames) + { + var field = value.GetType().GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance) + ?? basePoolType.GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance); + field?.SetValue(value, null); + } + } + } + + private static void RestoreEntitySnapshot( + Dictionary target, + Dictionary snapshot, + string modKey) + { + // Remove any entries that were injected from the new assembly + var snapshotIds = snapshot.Keys.ToHashSet(); + foreach (var id in target.Keys.ToList()) + { + if (snapshotIds.Contains(id)) continue; + if (target[id] is AbstractModel model + && string.Equals(AssemblyStamper.NormalizeModKey(model.GetType().Assembly.GetName().Name), modKey, StringComparison.OrdinalIgnoreCase)) + target.Remove(id); + } + + // Put back the originals + foreach (var (id, model) in snapshot) + target[id] = model; + } + + private static void UnloadCollectibleAlc(AssemblyLoadContext? alc, List warnings) + { + if (alc == null) return; + try + { + alc.Unload(); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + catch (Exception ex) { warnings.Add($"alc_unload: {ex.Message}"); } + } +} diff --git a/HotReload/HotReloadResult.cs b/HotReload/HotReloadResult.cs new file mode 100644 index 0000000..50c5da3 --- /dev/null +++ b/HotReload/HotReloadResult.cs @@ -0,0 +1,94 @@ +namespace BaseLib.HotReload; + +/// +/// Describes a single entity that was affected by a hot reload. +/// For example, a card that was re-injected with new stats, or a relic +/// that was unchanged and skipped. +/// +public sealed class ChangedEntity +{ + public string Name { get; init; } = ""; + + /// + /// What happened to this entity: "injected" (new/changed), "removed" (gone from new assembly), + /// or "unchanged" (signature hash matched, skipped for efficiency). + /// + public string Action { get; init; } = ""; + + /// + /// The ModelId string (e.g. "card/my-strike"), or null for unchanged/removed entities. + /// + public string? Id { get; init; } +} + +/// +/// Everything that happened during a hot reload, good or bad. +/// Check Success first — if false, look at Errors for what went wrong. +/// StepTimings tells you where the time was spent. +/// ChangedEntities tells you exactly which entities were affected. +/// +public sealed class HotReloadResult +{ + public bool Success { get; init; } + + /// 1 = patches only, 2 = entities + patches + loc, 3 = full + PCK resources. + public int Tier { get; init; } + + public string? AssemblyName { get; init; } + public int PatchCount { get; init; } + public int EntitiesRemoved { get; init; } + public int EntitiesInjected { get; init; } + + /// How many entities were unchanged (same signature hash) and skipped. + public int EntitiesSkipped { get; init; } + + public int PoolsUnfrozen { get; init; } + public int PoolRegistrations { get; init; } + public bool LocalizationReloaded { get; init; } + public bool PckReloaded { get; init; } + public int LiveInstancesRefreshed { get; init; } + + /// How many cards passed the ToMutable() sanity check. + public int MutableCheckPassed { get; init; } + + /// How many cards failed ToMutable() — usually a PowerVar resolution issue. + public int MutableCheckFailed { get; init; } + + /// Whether the assembly was loaded into a collectible ALC (tier 1 only). + public bool AlcCollectible { get; init; } + + public long TotalMs { get; init; } + + /// UTC timestamp of when the reload completed (ISO 8601). + public string Timestamp { get; init; } = ""; + + /// Time spent on each step, keyed like "step1_assembly_load", "step6_entity_reload", etc. + public Dictionary StepTimings { get; init; } = []; + + /// What the pipeline did, in order. Useful for debugging reload issues. + public List Actions { get; init; } = []; + + /// Things that went wrong and caused (or would cause) failure. + public List Errors { get; init; } = []; + + /// Things that were slightly off but didn't prevent the reload from succeeding. + public List Warnings { get; init; } = []; + + public List ChangedEntities { get; init; } = []; + + /// + /// One-line summary suitable for console output. + /// + public string Summary + { + get + { + if (!Success) + { + var firstError = Errors.Count > 0 ? Errors[0] : "unknown error"; + return $"Hot reload FAILED: {firstError}"; + } + return $"Hot reload OK — tier {Tier}, {EntitiesInjected} entities, {PatchCount} patches, {LiveInstancesRefreshed} live refreshed ({TotalMs}ms)"; + } + } +} diff --git a/HotReload/HotReloadSelfTests.cs b/HotReload/HotReloadSelfTests.cs new file mode 100644 index 0000000..4288e72 --- /dev/null +++ b/HotReload/HotReloadSelfTests.cs @@ -0,0 +1,561 @@ +using System.Collections; +using System.Reflection; +using System.Runtime.Loader; +using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.Localization; +using MegaCrit.Sts2.Core.Modding; +using MegaCrit.Sts2.Core.Models; + +namespace BaseLib.HotReload; + +/// +/// Self-tests for the hot reload system, following the same pattern as NodeFactory's self-tests. +/// +/// Two phases: +/// 1. RunStartupTests() — called from HotReloadEngine.Init() at mod load time. +/// Tests pure helper logic and verifies game reflection targets exist. +/// These run early, before ModelDb is populated, so they can't test the full pipeline. +/// +/// 2. RunIntegrationTests() — called from the "hotreload_test" console command. +/// Tests the full pipeline with a real assembly build, deploy, and reload. +/// Requires the game to be fully loaded (ModelDb populated, LocManager ready). +/// +internal static class HotReloadSelfTests +{ + private static int _passed; + private static int _failed; + + private static void Assert(bool condition, string testName) + { + if (condition) + _passed++; + else + { + _failed++; + BaseLibMain.Logger.Error($"[HotReload] FAIL: {testName}"); + } + } + + // ═══════════════════════════════════════════════════════════════════ + // Phase 1: Startup tests — pure logic + reflection target existence + // ═══════════════════════════════════════════════════════════════════ + + /// + /// Run at mod init time. Tests helper functions and verifies that every private + /// field/property we access via reflection actually exists in the current game version. + /// If these fail, the hot reload pipeline WILL break at runtime. + /// + public static void RunStartupTests() + { + _passed = 0; + _failed = 0; + + TestAssemblyStamperNormalize(); + TestAssemblyStamperIsStamped(); + TestComputeBitSize(); + TestInheritsFromByName(); + TestGetInjectionPriority(); + TestGetLoadableTypes(); + TestTypeSignatureHashDeterminism(); + TestTypeSignatureHashChangesOnModification(); + TestHotReloadResultSummary(); + TestSessionCreation(); + + // These are the critical ones — if any fail, the pipeline will crash at runtime + TestReflectionTargets_ModelDb(); + TestReflectionTargets_ModManager(); + TestReflectionTargets_ReflectionHelper(); + TestReflectionTargets_ModHelper(); + TestReflectionTargets_SerializationCache(); + TestReflectionTargets_LocTable(); + TestReflectionTargets_PoolModels(); + + if (_failed == 0) + BaseLibMain.Logger.Info($"[HotReload] All {_passed} startup self-tests passed"); + else + BaseLibMain.Logger.Error($"[HotReload] Startup self-tests: {_passed} passed, {_failed} FAILED"); + + _passed = 0; + _failed = 0; + } + + // ── AssemblyStamper tests ──────────────────────────────────────── + + private static void TestAssemblyStamperNormalize() + { + // Normal mod name — no change + Assert(AssemblyStamper.NormalizeModKey("MyMod") == "MyMod", + "NormalizeModKey: plain name unchanged"); + + // Hot-reload stamped name — strip suffix + Assert(AssemblyStamper.NormalizeModKey("MyMod_hr143052789") == "MyMod", + "NormalizeModKey: strips _hr9 suffix"); + + // 6-digit stamp + Assert(AssemblyStamper.NormalizeModKey("MyMod_hr143052") == "MyMod", + "NormalizeModKey: strips _hr6 suffix"); + + // Full DLL path + Assert(AssemblyStamper.NormalizeModKey("E:/mods/MyMod/MyMod_hr143052789.dll") == "MyMod", + "NormalizeModKey: handles full DLL path"); + + // Null and empty + Assert(AssemblyStamper.NormalizeModKey(null) == "", + "NormalizeModKey: null returns empty"); + Assert(AssemblyStamper.NormalizeModKey("") == "", + "NormalizeModKey: empty returns empty"); + Assert(AssemblyStamper.NormalizeModKey(" ") == "", + "NormalizeModKey: whitespace returns empty"); + + // Name with underscores that aren't _hr stamps + Assert(AssemblyStamper.NormalizeModKey("My_Mod_Name") == "My_Mod_Name", + "NormalizeModKey: preserves non-stamp underscores"); + + // Short _hr suffix (too few digits — shouldn't strip) + Assert(AssemblyStamper.NormalizeModKey("MyMod_hr12345") == "MyMod_hr12345", + "NormalizeModKey: doesn't strip <6 digit suffix"); + } + + private static void TestAssemblyStamperIsStamped() + { + Assert(AssemblyStamper.IsHotReloadStamped("MyMod_hr143052789.dll"), + "IsHotReloadStamped: 9-digit stamp recognized"); + Assert(AssemblyStamper.IsHotReloadStamped("MyMod_hr143052.dll"), + "IsHotReloadStamped: 6-digit stamp recognized"); + Assert(!AssemblyStamper.IsHotReloadStamped("MyMod.dll"), + "IsHotReloadStamped: unstamped rejected"); + Assert(!AssemblyStamper.IsHotReloadStamped("0Harmony.dll"), + "IsHotReloadStamped: framework DLL rejected"); + Assert(AssemblyStamper.IsHotReloadStamped("E:/mods/MyMod/MyMod_hr143052789.dll"), + "IsHotReloadStamped: full path with stamp recognized"); + } + + // ── TypeSignatureHasher tests ──────────────────────────────────── + + private static void TestComputeBitSize() + { + Assert(TypeSignatureHasher.ComputeBitSize(0) == 0, "ComputeBitSize: 0 → 0"); + Assert(TypeSignatureHasher.ComputeBitSize(1) == 0, "ComputeBitSize: 1 → 0"); + Assert(TypeSignatureHasher.ComputeBitSize(2) == 1, "ComputeBitSize: 2 → 1"); + Assert(TypeSignatureHasher.ComputeBitSize(3) == 2, "ComputeBitSize: 3 → 2"); + Assert(TypeSignatureHasher.ComputeBitSize(4) == 2, "ComputeBitSize: 4 → 2"); + Assert(TypeSignatureHasher.ComputeBitSize(256) == 8, "ComputeBitSize: 256 → 8"); + } + + private static void TestInheritsFromByName() + { + // CardModel inherits from AbstractModel (via the game's type hierarchy) + Assert(TypeSignatureHasher.InheritsFromByName(typeof(CardModel), nameof(AbstractModel)), + "InheritsFromByName: CardModel → AbstractModel"); + + Assert(TypeSignatureHasher.InheritsFromByName(typeof(RelicModel), nameof(AbstractModel)), + "InheritsFromByName: RelicModel → AbstractModel"); + + // CardModel does NOT inherit from RelicModel + Assert(!TypeSignatureHasher.InheritsFromByName(typeof(CardModel), nameof(RelicModel)), + "InheritsFromByName: CardModel !→ RelicModel"); + + // object does not inherit from anything by this check + Assert(!TypeSignatureHasher.InheritsFromByName(typeof(object), nameof(AbstractModel)), + "InheritsFromByName: object !→ AbstractModel"); + } + + private static void TestGetInjectionPriority() + { + // Powers should come before cards (cards reference powers via PowerVar) + int powerPri = TypeSignatureHasher.GetInjectionPriority(typeof(PowerModel)); + int cardPri = TypeSignatureHasher.GetInjectionPriority(typeof(CardModel)); + Assert(powerPri < cardPri, + $"InjectionPriority: PowerModel ({powerPri}) < CardModel ({cardPri})"); + + // Monsters should come before encounters + int monsterPri = TypeSignatureHasher.GetInjectionPriority(typeof(MonsterModel)); + int encounterPri = TypeSignatureHasher.GetInjectionPriority(typeof(EncounterModel)); + Assert(monsterPri < encounterPri, + $"InjectionPriority: MonsterModel ({monsterPri}) < EncounterModel ({encounterPri})"); + + // Unknown type gets high priority (injected last, least likely to break things) + int unknownPri = TypeSignatureHasher.GetInjectionPriority(typeof(string)); + Assert(unknownPri > cardPri, + $"InjectionPriority: unknown ({unknownPri}) > CardModel ({cardPri})"); + } + + private static void TestGetLoadableTypes() + { + // Should successfully return types from our own assembly + var types = TypeSignatureHasher.GetLoadableTypes(typeof(HotReloadSelfTests).Assembly).ToList(); + Assert(types.Count > 0, + "GetLoadableTypes: returns types from BaseLib assembly"); + Assert(types.Contains(typeof(HotReloadSelfTests)), + "GetLoadableTypes: contains our own type"); + } + + private static void TestTypeSignatureHashDeterminism() + { + // Same type should always produce the same hash + int hash1 = TypeSignatureHasher.ComputeHash(typeof(CardModel)); + int hash2 = TypeSignatureHasher.ComputeHash(typeof(CardModel)); + Assert(hash1 == hash2, + "TypeSignatureHash: deterministic for same type"); + + // Different types should produce different hashes + int cardHash = TypeSignatureHasher.ComputeHash(typeof(CardModel)); + int relicHash = TypeSignatureHasher.ComputeHash(typeof(RelicModel)); + Assert(cardHash != relicHash, + "TypeSignatureHash: different types produce different hashes"); + } + + private static void TestTypeSignatureHashChangesOnModification() + { + // This tests that types from different assemblies with the same name would + // produce different hashes (simulated by comparing two different game types). + // In real hot reload, the old and new assembly would have the same type names + // but different IL, which is what we're detecting. + int hash1 = TypeSignatureHasher.ComputeHash(typeof(PowerModel)); + int hash2 = TypeSignatureHasher.ComputeHash(typeof(PotionModel)); + Assert(hash1 != hash2, + "TypeSignatureHash: structurally different types have different hashes"); + } + + // ── HotReloadResult tests ──────────────────────────────────────── + + private static void TestHotReloadResultSummary() + { + var success = new HotReloadResult + { + Success = true, Tier = 2, EntitiesInjected = 5, PatchCount = 3, + LiveInstancesRefreshed = 10, TotalMs = 150, + Errors = [], Actions = [], + }; + Assert(success.Summary.Contains("OK"), "HotReloadResult.Summary: success contains 'OK'"); + Assert(success.Summary.Contains("5 entities"), "HotReloadResult.Summary: success shows entity count"); + + var failure = new HotReloadResult + { + Success = false, Errors = ["assembly_load: file not found"], + }; + Assert(failure.Summary.Contains("FAILED"), "HotReloadResult.Summary: failure contains 'FAILED'"); + Assert(failure.Summary.Contains("file not found"), "HotReloadResult.Summary: failure shows error"); + + var emptyError = new HotReloadResult { Success = false, Errors = [] }; + Assert(emptyError.Summary.Contains("unknown"), "HotReloadResult.Summary: no errors shows 'unknown'"); + } + + // ── Session tests ──────────────────────────────────────────────── + + private static void TestSessionCreation() + { + var session = new HotReloadSession("TestMod"); + Assert(session.ModKey == "TestMod", "HotReloadSession: ModKey set correctly"); + Assert(session.LoadContext == null, "HotReloadSession: LoadContext initially null"); + Assert(session.LastLoadedAssembly == null, "HotReloadSession: LastLoadedAssembly initially null"); + Assert(session.HotReloadHarmony == null, "HotReloadSession: HotReloadHarmony initially null"); + } + + // ═══════════════════════════════════════════════════════════════════ + // Reflection target existence tests + // ═══════════════════════════════════════════════════════════════════ + // If any of these fail, the hot reload pipeline will crash when it tries + // to access that field/property. These catch game updates that rename or + // remove internal fields before the pipeline hits them at runtime. + + private const BindingFlags StaticNonPublic = BindingFlags.NonPublic | BindingFlags.Static; + private const BindingFlags StaticPublic = BindingFlags.Public | BindingFlags.Static; + private const BindingFlags InstanceNonPublic = BindingFlags.NonPublic | BindingFlags.Instance; + + private static void TestReflectionTargets_ModelDb() + { + // Step 6: we access _contentById to inject entities + Assert(typeof(ModelDb).GetField("_contentById", StaticNonPublic) != null, + "Reflection: ModelDb._contentById exists"); + + // Step 7: we null these 14 cached fields + string[] cacheFields = + [ + "_allCards", "_allCardPools", "_allCharacterCardPools", + "_allSharedEvents", "_allEvents", "_allEncounters", "_allPotions", + "_allPotionPools", "_allCharacterPotionPools", "_allSharedPotionPools", + "_allPowers", "_allRelics", "_allCharacterRelicPools", "_achievements" + ]; + foreach (var fieldName in cacheFields) + { + Assert(typeof(ModelDb).GetField(fieldName, StaticNonPublic) != null, + $"Reflection: ModelDb.{fieldName} exists"); + } + } + + private static void TestReflectionTargets_ModManager() + { + // Step 3: we access _mods to update Mod.assembly + Assert(typeof(ModManager).GetField("_mods", StaticNonPublic) != null, + "Reflection: ModManager._mods exists"); + } + + private static void TestReflectionTargets_ReflectionHelper() + { + // Step 4: we null _modTypes to force cache rebuild + Assert(typeof(ReflectionHelper).GetField("_modTypes", StaticNonPublic) != null, + "Reflection: ReflectionHelper._modTypes exists"); + } + + private static void TestReflectionTargets_ModHelper() + { + // Step 8: we access _moddedContentForPools to unfreeze and clean pools + Assert(typeof(ModHelper).GetField("_moddedContentForPools", StaticNonPublic) != null, + "Reflection: ModHelper._moddedContentForPools exists"); + } + + private static void TestReflectionTargets_SerializationCache() + { + // Step 5: we register new entity IDs in ModelIdSerializationCache + var cacheType = typeof(ModelId).Assembly.GetType("MegaCrit.Sts2.Core.Multiplayer.Serialization.ModelIdSerializationCache"); + Assert(cacheType != null, "Reflection: ModelIdSerializationCache type exists"); + + if (cacheType != null) + { + Assert(cacheType.GetField("_categoryNameToNetIdMap", StaticNonPublic) != null, + "Reflection: SerializationCache._categoryNameToNetIdMap exists"); + Assert(cacheType.GetField("_netIdToCategoryNameMap", StaticNonPublic) != null, + "Reflection: SerializationCache._netIdToCategoryNameMap exists"); + Assert(cacheType.GetField("_entryNameToNetIdMap", StaticNonPublic) != null, + "Reflection: SerializationCache._entryNameToNetIdMap exists"); + Assert(cacheType.GetField("_netIdToEntryNameMap", StaticNonPublic) != null, + "Reflection: SerializationCache._netIdToEntryNameMap exists"); + Assert(cacheType.GetProperty("CategoryIdBitSize", StaticPublic) != null, + "Reflection: SerializationCache.CategoryIdBitSize exists"); + Assert(cacheType.GetProperty("EntryIdBitSize", StaticPublic) != null, + "Reflection: SerializationCache.EntryIdBitSize exists"); + } + } + + private static void TestReflectionTargets_LocTable() + { + // Step 9 (via ModelLocPatch): we access LocTable._translations + Assert(typeof(LocTable).GetField("_translations", InstanceNonPublic) != null, + "Reflection: LocTable._translations exists"); + } + + private static void TestReflectionTargets_PoolModels() + { + // Step 8: we null lazy caches on pool model instances + Assert(typeof(CardPoolModel).GetField("_allCards", InstanceNonPublic) != null, + "Reflection: CardPoolModel._allCards exists"); + Assert(typeof(CardPoolModel).GetField("_allCardIds", InstanceNonPublic) != null, + "Reflection: CardPoolModel._allCardIds exists"); + Assert(typeof(RelicPoolModel).GetField("_relics", InstanceNonPublic) != null, + "Reflection: RelicPoolModel._relics exists"); + Assert(typeof(RelicPoolModel).GetField("_allRelicIds", InstanceNonPublic) != null, + "Reflection: RelicPoolModel._allRelicIds exists"); + Assert(typeof(PotionPoolModel).GetField("_allPotions", InstanceNonPublic) != null, + "Reflection: PotionPoolModel._allPotions exists"); + Assert(typeof(PotionPoolModel).GetField("_allPotionIds", InstanceNonPublic) != null, + "Reflection: PotionPoolModel._allPotionIds exists"); + } + + // ═══════════════════════════════════════════════════════════════════ + // Phase 2: Integration tests — full pipeline, run on-demand + // ═══════════════════════════════════════════════════════════════════ + // These require the game to be fully loaded (ModelDb populated, etc.) + // and a test mod DLL to be available. Run via: hotreload_test console command. + + /// + /// Run the full suite of integration tests. Requires the game to be fully loaded. + /// Returns a structured result for the console command. + /// + public static (int passed, int failed, List failures) RunIntegrationTests() + { + _passed = 0; + _failed = 0; + var failures = new List(); + + // Override Assert to capture failure messages + void AssertIntegration(bool condition, string testName) + { + if (condition) + _passed++; + else + { + _failed++; + failures.Add(testName); + BaseLibMain.Logger.Error($"[HotReload] FAIL: {testName}"); + } + } + + // ── Test: ModelDb._contentById is populated and accessible ── + { + var field = typeof(ModelDb).GetField("_contentById", StaticNonPublic); + var dict = field?.GetValue(null) as Dictionary; + AssertIntegration(dict != null, "Integration: ModelDb._contentById accessible"); + AssertIntegration(dict != null && dict.Count > 0, $"Integration: ModelDb has entities ({dict?.Count ?? 0})"); + } + + // ── Test: SerializationCacheSnapshot round-trip ── + // Capture the current state, restore it, and verify nothing changed + { + var cacheType = typeof(ModelId).Assembly.GetType("MegaCrit.Sts2.Core.Multiplayer.Serialization.ModelIdSerializationCache"); + if (cacheType != null) + { + var snapshot = SerializationCacheSnapshot.Capture(cacheType); + AssertIntegration(snapshot != null, "Integration: SerializationCacheSnapshot captured"); + + if (snapshot != null) + { + // Record pre-restore state + var entryListField = cacheType.GetField("_netIdToEntryNameMap", StaticNonPublic); + var entryListBefore = entryListField?.GetValue(null) as List; + int countBefore = entryListBefore?.Count ?? 0; + + // Restore should be a no-op (same data) + snapshot.Restore(); + + var entryListAfter = entryListField?.GetValue(null) as List; + int countAfter = entryListAfter?.Count ?? 0; + + AssertIntegration(countBefore == countAfter, + $"Integration: SerializationCache round-trip preserves count ({countBefore} → {countAfter})"); + } + } + else + { + AssertIntegration(false, "Integration: SerializationCache type not found"); + } + } + + // ── Test: Pool unfreezing and refreezing doesn't break pool access ── + { + var poolsField = typeof(ModHelper).GetField("_moddedContentForPools", StaticNonPublic); + var pools = poolsField?.GetValue(null) as IDictionary; + AssertIntegration(pools != null, "Integration: ModHelper._moddedContentForPools accessible"); + + if (pools != null) + { + // Count entries — should be > 0 if any mods registered pool content + AssertIntegration(pools.Count >= 0, + $"Integration: _moddedContentForPools has {pools.Count} entries"); + + // Verify isFrozen and modelsToAdd fields exist on the content entries + bool foundStructure = false; + foreach (var key in pools.Keys) + { + var content = pools[key]; + if (content == null) continue; + var contentType = content.GetType(); + var frozenField = contentType.GetField("isFrozen"); + var modelsField = contentType.GetField("modelsToAdd"); + foundStructure = frozenField != null && modelsField != null; + break; // just check the first one + } + if (pools.Count > 0) + { + AssertIntegration(foundStructure, + "Integration: pool content entries have isFrozen and modelsToAdd fields"); + } + } + } + + // ── Test: ModManager._loadedMods is populated and has assembly field ── + { + var loadedModsField = typeof(ModManager).GetField("_mods", StaticNonPublic); + var loadedMods = loadedModsField?.GetValue(null) as IList; + AssertIntegration(loadedMods != null, "Integration: ModManager._loadedMods accessible"); + AssertIntegration(loadedMods != null && loadedMods.Count > 0, + $"Integration: ModManager has {loadedMods?.Count ?? 0} loaded mods"); + + if (loadedMods is { Count: > 0 }) + { + var firstMod = loadedMods[0]!; + var asmField = firstMod.GetType().GetField("assembly", BindingFlags.Public | BindingFlags.Instance); + AssertIntegration(asmField != null, "Integration: Mod.assembly field exists"); + if (asmField != null) + { + var asm = asmField.GetValue(firstMod) as Assembly; + AssertIntegration(asm != null, "Integration: first mod has a loaded assembly"); + } + } + } + + // ── Test: ReflectionHelper._modTypes invalidation and rebuild ── + { + var modTypesField = typeof(ReflectionHelper).GetField("_modTypes", StaticNonPublic); + var originalTypes = modTypesField?.GetValue(null); + + // Null it (this is what step 4 does) + modTypesField?.SetValue(null, null); + AssertIntegration(modTypesField?.GetValue(null) == null, + "Integration: ReflectionHelper._modTypes can be nulled"); + + // Access ModTypes to trigger rebuild + var rebuiltTypes = ReflectionHelper.ModTypes?.ToList(); + AssertIntegration(rebuiltTypes != null && rebuiltTypes.Count > 0, + $"Integration: ReflectionHelper.ModTypes rebuilds after null ({rebuiltTypes?.Count ?? 0} types)"); + } + + // ── Test: Live instance refresh doesn't crash on empty scene ── + // (This tests that the BFS walk handles the current scene tree gracefully) + { + try + { + int refreshed = LiveInstanceRefresher.RefreshSceneTree(); + // We don't know how many nodes exist, but it shouldn't throw + AssertIntegration(true, $"Integration: RefreshSceneTree completed ({refreshed} refreshed)"); + } + catch (Exception ex) + { + AssertIntegration(false, $"Integration: RefreshSceneTree threw: {ex.Message}"); + } + } + + // ── Test: BuildModelId produces valid IDs for known types ── + { + try + { + var (category, entry) = TypeSignatureHasher.GetCategoryAndEntry(typeof(CardModel)); + AssertIntegration(!string.IsNullOrEmpty(category), + $"Integration: GetCategoryAndEntry produces category for CardModel: '{category}'"); + AssertIntegration(!string.IsNullOrEmpty(entry), + $"Integration: GetCategoryAndEntry produces entry for CardModel: '{entry}'"); + } + catch (Exception ex) + { + AssertIntegration(false, $"Integration: GetCategoryAndEntry threw: {ex.Message}"); + } + } + + // ── Test: DefaultAlcResolving finds loaded assemblies ── + { + // Try resolving BaseLib itself — it should find the loaded assembly + var baseLibName = typeof(BaseLibMain).Assembly.GetName(); + var resolved = TypeSignatureHasher.DefaultAlcResolving( + AssemblyLoadContext.Default, + new AssemblyName(baseLibName.Name!)); + AssertIntegration(resolved != null, + "Integration: DefaultAlcResolving resolves BaseLib by name"); + + // Try resolving a nonexistent assembly — should return null + var notFound = TypeSignatureHasher.DefaultAlcResolving( + AssemblyLoadContext.Default, + new AssemblyName("NonExistentMod_Definitely_Not_Loaded")); + AssertIntegration(notFound == null, + "Integration: DefaultAlcResolving returns null for unknown assembly"); + } + + // ── Test: GetAssembliesForMod finds the right assemblies ── + { + // BaseLib should find itself + string baseLibKey = AssemblyStamper.NormalizeModKey(typeof(BaseLibMain).Assembly.GetName().Name); + var found = TypeSignatureHasher.GetAssembliesForMod(baseLibKey).ToList(); + AssertIntegration(found.Count > 0, + $"Integration: GetAssembliesForMod finds BaseLib (key='{baseLibKey}', found={found.Count})"); + + // Exclude parameter should work + var foundExcluding = TypeSignatureHasher.GetAssembliesForMod(baseLibKey, typeof(BaseLibMain).Assembly).ToList(); + AssertIntegration(foundExcluding.Count == found.Count - 1, + "Integration: GetAssembliesForMod exclude parameter works"); + } + + var result = (_passed, _failed, failures); + _passed = 0; + _failed = 0; + return result; + } +} diff --git a/HotReload/HotReloadSession.cs b/HotReload/HotReloadSession.cs new file mode 100644 index 0000000..20eca93 --- /dev/null +++ b/HotReload/HotReloadSession.cs @@ -0,0 +1,34 @@ +using System.Reflection; +using System.Runtime.Loader; +using HarmonyLib; + +namespace BaseLib.HotReload; + +/// +/// Tracks per-mod hot reload state across successive reloads. +/// +/// Each mod gets one session that persists for the entire game session. +/// When a mod is reloaded, we need to know: +/// - Which ALC was used (so we can unload it if it was collectible) +/// - Which Harmony instance was used (so we can unpatch the old patches) +/// - Which assembly was loaded (so we can identify stale types) +/// +internal sealed class HotReloadSession +{ + /// The canonical mod name with _hr suffix stripped. + public string ModKey { get; } + + /// The ALC used for the most recent reload (null for default ALC). + public AssemblyLoadContext? LoadContext { get; set; } + + /// The assembly from the most recent reload. + public Assembly? LastLoadedAssembly { get; set; } + + /// The Harmony instance from the most recent reload (unique ID per reload). + public Harmony? HotReloadHarmony { get; set; } + + public HotReloadSession(string modKey) + { + ModKey = modKey; + } +} diff --git a/HotReload/LiveInstanceRefresher.cs b/HotReload/LiveInstanceRefresher.cs new file mode 100644 index 0000000..87ac1f3 --- /dev/null +++ b/HotReload/LiveInstanceRefresher.cs @@ -0,0 +1,427 @@ +using System.Collections; +using System.Reflection; +using Godot; +using MegaCrit.Sts2.Core.Models; +using MegaCrit.Sts2.Core.Nodes.Cards; +using MegaCrit.Sts2.Core.Nodes.Combat; +using MegaCrit.Sts2.Core.Nodes.Potions; +using MegaCrit.Sts2.Core.Nodes.Relics; + +namespace BaseLib.HotReload; + +/// +/// Walks the Godot scene tree and the current run state to replace old model instances +/// with fresh ones from ModelDb. This makes hot-reloaded changes visible immediately +/// without needing a new encounter or game restart. +/// +internal static class LiveInstanceRefresher +{ + private const BindingFlags InstanceFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; + + // These are the mutable runtime state fields we copy from old → new instances. + // Without this, a hot-reloaded card would lose its upgrade status, a power would + // lose its stacks, etc. + private static readonly string[] CardStateFields = + [ + "CostForTurn", "CurrentCost", "TemporaryCost", "IsTemporaryCostModified", + "FreeToPlay", "Retain", "Ethereal", "Exhaust", "Exhausts", + "WasDiscarded", "WasDrawnThisTurn", "PlayedThisTurn", "Misc", "Counter", "TurnsInHand", + ]; + + private static readonly string[] RelicStateFields = + [ + "Counter", "Charges", "UsesRemaining", "Cooldown", + "TriggeredThisTurn", "TriggeredThisCombat", "PulseActive", "IsDisabled", "Grayscale", + ]; + + private static readonly string[] PowerStateFields = + [ + "Stacks", "Amount", "Counter", "TurnsRemaining", + "TriggeredThisTurn", "TriggeredThisCombat", "PulseActive", "JustApplied", + ]; + + private static readonly string[] PotionStateFields = + [ + "Charges", "UsesRemaining", "Counter", "TriggeredThisCombat", + ]; + + // ─── Scene Tree Refresh ───────────────────────────────────────────── + + /// + /// Walk the entire Godot scene tree and re-set Model properties on NCard, NRelic, + /// NPower, NPotion, and NCreature nodes to fresh instances from ModelDb. + /// Returns total number of nodes refreshed. + /// + public static int RefreshSceneTree() + { + var root = ((SceneTree)Engine.GetMainLoop()).Root; + int total = 0; + + total += WalkAndRefresh(root, RefreshCard); + total += WalkAndRefresh(root, RefreshRelic); + total += WalkAndRefresh(root, RefreshPower); + total += WalkAndRefresh(root, RefreshPotion); + total += WalkAndRefresh(root, RefreshCreature); + + return total; + } + + /// + /// BFS walk the scene tree, calling refreshFunc on each node of type T. + /// Returns how many nodes were actually refreshed (model swapped). + /// + private static int WalkAndRefresh(Node root, Func refreshFunc) where T : Node + { + int count = 0; + var queue = new Queue(); + queue.Enqueue(root); + + while (queue.Count > 0) + { + var node = queue.Dequeue(); + if (node is T typed) + { + try + { + if (refreshFunc(typed)) + count++; + } + catch { /* best effort — don't crash the whole refresh for one node */ } + } + foreach (var child in node.GetChildren()) + queue.Enqueue(child); + } + + return count; + } + + // Each Refresh* method below does the same thing: check if the node's current model + // was loaded from an old assembly, and if so, replace it with the fresh one from ModelDb. + // They can't be a single generic because ModelDb.GetByIdOrNull requires the specific + // model type (CardModel, RelicModel, etc.) and the node types don't share a common + // "get model" interface. NCreature is extra special — its Monster property is get-only + // so we have to poke the compiler-generated backing field directly. + + private static bool RefreshCard(NCard node) + { + var modelProp = node.GetType().GetProperty("Model"); + if (modelProp?.GetValue(node) is not AbstractModel model) return false; + var fresh = ModelDb.GetByIdOrNull(model.Id); + if (fresh == null || ReferenceEquals(fresh, model)) return false; + if (fresh.GetType().Assembly == model.GetType().Assembly) return false; + modelProp.SetValue(node, fresh); + return true; + } + + private static bool RefreshRelic(NRelic node) + { + var modelProp = node.GetType().GetProperty("Model"); + if (modelProp?.GetValue(node) is not AbstractModel model) return false; + var fresh = ModelDb.GetByIdOrNull(model.Id); + if (fresh == null || ReferenceEquals(fresh, model)) return false; + if (fresh.GetType().Assembly == model.GetType().Assembly) return false; + modelProp.SetValue(node, fresh); + return true; + } + + private static bool RefreshPower(NPower node) + { + var modelProp = node.GetType().GetProperty("Model"); + if (modelProp?.GetValue(node) is not AbstractModel model) return false; + var fresh = ModelDb.GetByIdOrNull(model.Id); + if (fresh == null || ReferenceEquals(fresh, model)) return false; + if (fresh.GetType().Assembly == model.GetType().Assembly) return false; + modelProp.SetValue(node, fresh); + return true; + } + + private static bool RefreshPotion(NPotion node) + { + var modelProp = node.GetType().GetProperty("Model"); + if (modelProp?.GetValue(node) is not AbstractModel model) return false; + var fresh = ModelDb.GetByIdOrNull(model.Id); + if (fresh == null || ReferenceEquals(fresh, model)) return false; + if (fresh.GetType().Assembly == model.GetType().Assembly) return false; + modelProp.SetValue(node, fresh); + return true; + } + + private static bool RefreshCreature(NCreature node) + { + // NCreature.Entity is a Creature, and Creature.Monster is the model. + // Monster is a get-only auto-property so we have to set the compiler-generated + // backing field directly. + var creature = node.Entity; + if (creature == null || !creature.IsMonster) return false; + var model = creature.Monster; + if (model == null) return false; + var fresh = ModelDb.GetByIdOrNull(model.Id); + if (fresh == null || ReferenceEquals(fresh, model)) return false; + if (fresh.GetType().Assembly == model.GetType().Assembly) return false; + + var backingField = creature.GetType().GetField("k__BackingField", InstanceFlags); + if (backingField == null) return false; + backingField.SetValue(creature, fresh); + fresh.Creature = creature; + return true; + } + + // ─── Run State Refresh ────────────────────────────────────────────── + + /// + /// Refresh mutable card/relic/power/potion instances in the current run's state. + /// This covers the master deck, all combat card piles, relics, potions, and active + /// powers on each player. Returns total number of instances refreshed. + /// + public static int RefreshRunInstances(Assembly reloadedAssembly, string modKey) + { + int refreshed = 0; + string assemblyKey = string.IsNullOrWhiteSpace(modKey) + ? AssemblyStamper.NormalizeModKey(reloadedAssembly.GetName().Name) + : modKey; + + // Build the set of type names from the new assembly so we know which + // runtime instances belong to the reloaded mod + var reloadedTypeNames = new HashSet( + TypeSignatureHasher.GetLoadableTypes(reloadedAssembly) + .Where(t => !t.IsAbstract && !t.IsInterface) + .Select(t => t.FullName ?? t.Name), + StringComparer.Ordinal); + + try + { + // Find RunManager.CurrentRun via reflection — it's in game code + var runManagerType = AppDomain.CurrentDomain.GetAssemblies() + .SelectMany(a => TypeSignatureHasher.GetLoadableTypes(a)) + .FirstOrDefault(t => t.Name == "RunManager"); + if (runManagerType == null) return 0; + + var currentRun = runManagerType + .GetProperty("CurrentRun", BindingFlags.Public | BindingFlags.Static) + ?.GetValue(null); + if (currentRun == null) return 0; + + // Walk each player's state: deck, relics, potions, combat piles, powers + if (GetPropertyValue(currentRun, "Players") is not IEnumerable players) + return 0; + + foreach (var player in players) + { + refreshed += RefreshModelList( + GetCollectionItems(GetPropertyValue(player, "MasterDeck", "Deck"), "Cards"), + assemblyKey, reloadedTypeNames); + + refreshed += RefreshModelList( + GetCollectionItems(GetPropertyValue(player, "Relics"), "Items"), + assemblyKey, reloadedTypeNames); + + refreshed += RefreshModelList( + GetCollectionItems(GetPropertyValue(player, "Potions"), "Items"), + assemblyKey, reloadedTypeNames); + + // Combat piles: hand, draw, discard, exhaust + var combatState = GetPropertyValue(player, "PlayerCombatState", "CombatState"); + if (combatState == null) continue; + + foreach (var pileName in new[] { "Hand", "DrawPile", "DiscardPile", "ExhaustPile" }) + { + refreshed += RefreshModelList( + GetCollectionItems(GetPropertyValue(combatState, pileName), "Cards"), + assemblyKey, reloadedTypeNames); + } + + // Active powers on the player's combat state + refreshed += RefreshModelList( + GetCollectionItems(GetPropertyValue(combatState, "PlayerPowers", "Powers"), "Items"), + assemblyKey, reloadedTypeNames); + } + } + catch (Exception ex) + { + BaseLibMain.Logger.Error($"[HotReload] Run instance refresh error: {ex}"); + } + + return refreshed; + } + + // ─── Helpers for navigating run state via reflection ───────────────── + + private static object? GetPropertyValue(object? owner, params string[] names) + { + if (owner == null) return null; + const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static; + foreach (var name in names) + { + var prop = owner.GetType().GetProperty(name, flags); + if (prop != null) return prop.GetValue(owner); + } + return null; + } + + private static IList? GetCollectionItems(object? container, params string[] itemPropertyNames) + { + if (container is IList list) return list; + if (container == null) return null; + foreach (var name in itemPropertyNames) + { + if (GetPropertyValue(container, name) is IList propList) + return propList; + } + return null; + } + + /// + /// Walk a list of model instances, replacing any that belong to the reloaded mod + /// with fresh ToMutable() copies from ModelDb. Preserves upgrade status and runtime state. + /// + private static int RefreshModelList(IList? items, string assemblyKey, HashSet reloadedTypeNames) + { + if (items == null) return 0; + int refreshed = 0; + + for (int i = 0; i < items.Count; i++) + { + try + { + if (items[i] is not AbstractModel current) continue; + + // Only touch instances from the reloaded mod's assembly + if (!string.Equals( + AssemblyStamper.NormalizeModKey(current.GetType().Assembly.GetName().Name), + assemblyKey, StringComparison.OrdinalIgnoreCase)) + continue; + + var typeName = current.GetType().FullName ?? current.GetType().Name; + var canonical = GetCanonicalModel(current); + + // If this type name isn't in the new assembly's type list, only + // refresh if the canonical model (from ModelDb) belongs to the + // reloaded mod. This handles renamed/moved types gracefully. + if (!reloadedTypeNames.Contains(typeName)) + { + if (canonical == null + || !string.Equals( + AssemblyStamper.NormalizeModKey(canonical.GetType().Assembly.GetName().Name), + assemblyKey, StringComparison.OrdinalIgnoreCase)) + continue; + } + + // Create a fresh mutable copy from the canonical ModelDb entry + var toMutable = canonical?.GetType().GetMethod("ToMutable"); + if (toMutable?.Invoke(canonical, null) is not AbstractModel mutable) + continue; + + // Preserve upgrade state (cards can be upgraded N times) + if (current is CardModel) + ApplyCardUpgradeState(current, mutable); + + // Copy runtime state fields (counters, stacks, costs, etc.) + var stateFields = current switch + { + CardModel => CardStateFields, + RelicModel => RelicStateFields, + PowerModel => PowerStateFields, + PotionModel => PotionStateFields, + _ => Array.Empty(), + }; + CopyRuntimeState(current, mutable, stateFields); + + items[i] = mutable; + refreshed++; + } + catch { /* best effort — leave existing instance if migration fails */ } + } + + return refreshed; + } + + private static AbstractModel? GetCanonicalModel(AbstractModel current) + { + return current switch + { + CardModel => ModelDb.GetByIdOrNull(current.Id), + RelicModel => ModelDb.GetByIdOrNull(current.Id), + PowerModel => ModelDb.GetByIdOrNull(current.Id), + PotionModel => ModelDb.GetByIdOrNull(current.Id), + _ => null, + }; + } + + /// + /// Re-apply upgrade(s) to the fresh mutable card copy so upgraded cards + /// don't revert to base stats after hot reload. + /// + private static void ApplyCardUpgradeState(object source, object target) + { + int upgrades = 0; + if (TryReadMember(source, "TimesUpgraded", out var val) && val is int tu) upgrades = tu; + else if (TryReadMember(source, "UpgradeCount", out val) && val is int uc) upgrades = uc; + else if (TryReadMember(source, "IsUpgraded", out val) && val is true) upgrades = 1; + + if (upgrades <= 0) return; + + var upgradeMethod = target.GetType().GetMethod("Upgrade", InstanceFlags, null, Type.EmptyTypes, null); + if (upgradeMethod == null) return; + + for (int i = 0; i < upgrades; i++) + { + try { upgradeMethod.Invoke(target, null); } + catch { break; } + } + } + + private static void CopyRuntimeState(object source, object target, string[] memberNames) + { + foreach (var name in memberNames) + { + if (TryReadMember(source, name, out var value)) + TryWriteMember(target, name, value); + } + } + + private static bool TryReadMember(object source, string name, out object? value) + { + var prop = source.GetType().GetProperty(name, InstanceFlags); + if (prop is { CanRead: true }) + { + value = prop.GetValue(source); + return true; + } + var field = source.GetType().GetField(name, InstanceFlags); + if (field != null) + { + value = field.GetValue(source); + return true; + } + value = null; + return false; + } + + private static void TryWriteMember(object target, string name, object? value) + { + var prop = target.GetType().GetProperty(name, InstanceFlags); + if (prop is { CanWrite: true } && CanAssignValue(prop.PropertyType, value)) + { + prop.SetValue(target, value); + return; + } + var field = target.GetType().GetField(name, InstanceFlags); + if (field is { IsInitOnly: false } && CanAssignValue(field.FieldType, value)) + field.SetValue(target, value); + } + + /// + /// Check whether a value can be assigned to a target type without throwing. + /// Handles nullables and null values correctly. + /// + private static bool CanAssignValue(Type targetType, object? value) + { + if (value == null) + return !targetType.IsValueType || Nullable.GetUnderlyingType(targetType) != null; + var valueType = value.GetType(); + if (targetType.IsAssignableFrom(valueType)) + return true; + var underlying = Nullable.GetUnderlyingType(targetType); + return underlying != null && underlying.IsAssignableFrom(valueType); + } +} diff --git a/HotReload/ModFileWatcher.cs b/HotReload/ModFileWatcher.cs new file mode 100644 index 0000000..5918d65 --- /dev/null +++ b/HotReload/ModFileWatcher.cs @@ -0,0 +1,199 @@ +namespace BaseLib.HotReload; + +/// +/// Watches the game's mods directory for new or changed DLL files and triggers +/// hot reload automatically. Only reacts to DLLs matching the hot-reload stamped +/// naming pattern (e.g., MyMod_hr143052789.dll) to avoid triggering on unrelated +/// file changes. +/// +/// Uses .NET FileSystemWatcher as the primary mechanism, with an optional polling +/// fallback for platforms where FSW is unreliable (notably Linux with inotify limits). +/// MSBuild writes files in multiple passes, so we debounce before triggering. +/// +public sealed class ModFileWatcher : IDisposable +{ + private FileSystemWatcher? _watcher; + private Timer? _pollTimer; + private readonly string _modsDirectory; + private readonly int _debounceMs; + private readonly int _pollIntervalMs; + private CancellationTokenSource? _debounceCts; + + // Track the last DLL we triggered on and when, to avoid double-fires + private string? _lastTriggeredPath; + private DateTime _lastTriggeredTime; + + // For polling: track the newest DLL write time we've seen + private DateTime _lastKnownWriteTime = DateTime.MinValue; + + public bool IsWatching { get; private set; } + + /// + /// Fired when a hot-reload-stamped DLL is detected. The string parameter + /// is the absolute path to the DLL. Callers should dispatch to the main + /// thread before calling HotReloadEngine.Reload(). + /// + public event Action? OnModDllChanged; + + /// The game's mods/ directory to watch. + /// How long to wait after a file event before triggering (default 500ms). + /// Polling fallback interval. 0 disables polling (default 2000ms). + public ModFileWatcher(string modsDirectory, int debounceMs = 500, int pollIntervalMs = 2000) + { + _modsDirectory = modsDirectory; + _debounceMs = debounceMs; + _pollIntervalMs = pollIntervalMs; + } + + public void Start() + { + if (IsWatching) return; + + // Primary: FileSystemWatcher (efficient, event-driven) + try + { + _watcher = new FileSystemWatcher(_modsDirectory) + { + Filter = "*.dll", + IncludeSubdirectories = true, + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.CreationTime | NotifyFilters.FileName, + }; + _watcher.Changed += OnFileEvent; + _watcher.Created += OnFileEvent; + _watcher.EnableRaisingEvents = true; + } + catch (Exception ex) + { + // FileSystemWatcher can fail on some platforms (e.g., network drives, certain Linux configs). + // We'll fall back to polling below. + BaseLibMain.Logger.Warn($"[HotReload] FileSystemWatcher failed, using polling only: {ex.Message}"); + } + + // Fallback: periodic polling (reliable everywhere, catches events FSW might miss) + if (_pollIntervalMs > 0) + { + // Snapshot current state so we don't trigger on DLLs that existed before we started + _lastKnownWriteTime = GetNewestStampedDllWriteTime(); + _pollTimer = new Timer(PollForChanges, null, _pollIntervalMs, _pollIntervalMs); + } + + IsWatching = true; + } + + public void Stop() + { + if (_watcher != null) + { + _watcher.EnableRaisingEvents = false; + _watcher.Dispose(); + _watcher = null; + } + _pollTimer?.Dispose(); + _pollTimer = null; + _debounceCts?.Cancel(); + IsWatching = false; + } + + /// + /// Handle a FileSystemWatcher event. We debounce because MSBuild writes the DLL + /// in multiple passes and FSW fires for each one. + /// + private async void OnFileEvent(object sender, FileSystemEventArgs e) + { + // Only trigger for hot-reload-stamped DLLs (e.g., MyMod_hr143052789.dll). + // This avoids reacting to dependency DLLs, manifest copies, etc. + if (!AssemblyStamper.IsHotReloadStamped(e.FullPath)) + return; + + // Deduplicate rapid events for the same file (Changed + Created fire together) + var now = DateTime.UtcNow; + if (_lastTriggeredPath == e.FullPath && (now - _lastTriggeredTime).TotalMilliseconds < _debounceMs * 2) + return; + + // Cancel any pending debounce timer and start a new one + _debounceCts?.Cancel(); + _debounceCts = new CancellationTokenSource(); + var token = _debounceCts.Token; + + try + { + // Wait for the build to finish writing + await Task.Delay(_debounceMs, token); + + _lastTriggeredPath = e.FullPath; + _lastTriggeredTime = DateTime.UtcNow; + _lastKnownWriteTime = File.GetLastWriteTimeUtc(e.FullPath); + OnModDllChanged?.Invoke(e.FullPath); + } + catch (OperationCanceledException) + { + // A newer event came in — this debounce was superseded, which is fine + } + } + + /// + /// Polling fallback: scan the mods directory for stamped DLLs newer than our last + /// known write time. This catches events that FileSystemWatcher might miss + /// (Linux inotify limits, network drives, etc.). + /// + private void PollForChanges(object? state) + { + try + { + var (newestPath, newestTime) = FindNewestStampedDll(); + if (newestPath == null || newestTime <= _lastKnownWriteTime) return; + + // Deduplicate against FSW-triggered events + var now = DateTime.UtcNow; + if (_lastTriggeredPath == newestPath && (now - _lastTriggeredTime).TotalMilliseconds < _pollIntervalMs * 2) + return; + + _lastTriggeredPath = newestPath; + _lastTriggeredTime = now; + _lastKnownWriteTime = newestTime; + OnModDllChanged?.Invoke(newestPath); + } + catch + { + // Polling errors are silently ignored — the next poll will try again + } + } + + private DateTime GetNewestStampedDllWriteTime() + { + var (_, time) = FindNewestStampedDll(); + return time; + } + + private (string? path, DateTime writeTime) FindNewestStampedDll() + { + string? newestPath = null; + var newestTime = DateTime.MinValue; + + try + { + foreach (var dll in Directory.EnumerateFiles(_modsDirectory, "*.dll", SearchOption.AllDirectories)) + { + if (!AssemblyStamper.IsHotReloadStamped(dll)) continue; + var writeTime = File.GetLastWriteTimeUtc(dll); + if (writeTime > newestTime) + { + newestTime = writeTime; + newestPath = dll; + } + } + } + catch + { + // Directory might be inaccessible momentarily + } + + return (newestPath, newestTime); + } + + public void Dispose() + { + Stop(); + _debounceCts?.Dispose(); + } +} diff --git a/HotReload/SerializationCacheSnapshot.cs b/HotReload/SerializationCacheSnapshot.cs new file mode 100644 index 0000000..4b4d43e --- /dev/null +++ b/HotReload/SerializationCacheSnapshot.cs @@ -0,0 +1,84 @@ +using System.Reflection; + +namespace BaseLib.HotReload; + +/// +/// Deep copy of ModelIdSerializationCache state for transactional rollback. +/// +/// The serialization cache maps entity category/entry name strings to integer +/// net IDs for multiplayer serialization. It's built once at boot time, so +/// hot-reloaded entities aren't in it by default. We register new entries +/// during reload, but if something goes wrong we need to undo those additions +/// — otherwise the cache would reference types that don't exist in ModelDb. +/// +internal sealed class SerializationCacheSnapshot +{ + public Type? CacheType { get; init; } + public Dictionary? CategoryMap { get; init; } + public List? CategoryList { get; init; } + public Dictionary? EntryMap { get; init; } + public List? EntryList { get; init; } + public int? CategoryBitSize { get; init; } + public int? EntryBitSize { get; init; } + + private const BindingFlags StaticNonPublic = BindingFlags.NonPublic | BindingFlags.Static; + private const BindingFlags StaticPublic = BindingFlags.Public | BindingFlags.Static; + + /// Deep-copy all 6 fields from the live cache. Returns null if the type is null. + public static SerializationCacheSnapshot? Capture(Type cacheType) + { + return new SerializationCacheSnapshot + { + CacheType = cacheType, + + CategoryMap = cacheType.GetField("_categoryNameToNetIdMap", StaticNonPublic) + ?.GetValue(null) is Dictionary catMap + ? new Dictionary(catMap) + : null, + + CategoryList = cacheType.GetField("_netIdToCategoryNameMap", StaticNonPublic) + ?.GetValue(null) is List catList + ? [.. catList] + : null, + + EntryMap = cacheType.GetField("_entryNameToNetIdMap", StaticNonPublic) + ?.GetValue(null) is Dictionary entMap + ? new Dictionary(entMap) + : null, + + EntryList = cacheType.GetField("_netIdToEntryNameMap", StaticNonPublic) + ?.GetValue(null) is List entList + ? [.. entList] + : null, + + CategoryBitSize = cacheType.GetProperty("CategoryIdBitSize", StaticPublic) + ?.GetValue(null) as int?, + + EntryBitSize = cacheType.GetProperty("EntryIdBitSize", StaticPublic) + ?.GetValue(null) as int?, + }; + } + + /// Overwrite the live cache with this snapshot's data. Used on rollback. + public void Restore() + { + if (CacheType == null) return; + + CacheType.GetField("_categoryNameToNetIdMap", StaticNonPublic) + ?.SetValue(null, CategoryMap != null ? new Dictionary(CategoryMap) : null); + CacheType.GetField("_netIdToCategoryNameMap", StaticNonPublic) + ?.SetValue(null, CategoryList != null ? new List(CategoryList) : null); + CacheType.GetField("_entryNameToNetIdMap", StaticNonPublic) + ?.SetValue(null, EntryMap != null ? new Dictionary(EntryMap) : null); + CacheType.GetField("_netIdToEntryNameMap", StaticNonPublic) + ?.SetValue(null, EntryList != null ? new List(EntryList) : null); + + var catBitProp = CacheType.GetProperty("CategoryIdBitSize", StaticPublic); + if (catBitProp?.SetMethod != null && CategoryBitSize.HasValue) + catBitProp.SetValue(null, CategoryBitSize.Value); + + var entBitProp = CacheType.GetProperty("EntryIdBitSize", StaticPublic); + if (entBitProp?.SetMethod != null && EntryBitSize.HasValue) + entBitProp.SetValue(null, EntryBitSize.Value); + } +} diff --git a/HotReload/TypeSignatureHasher.cs b/HotReload/TypeSignatureHasher.cs new file mode 100644 index 0000000..a1796c2 --- /dev/null +++ b/HotReload/TypeSignatureHasher.cs @@ -0,0 +1,204 @@ +using System.Reflection; +using System.Runtime.Loader; +using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.Models; + +namespace BaseLib.HotReload; + +/// +/// Helpers for type introspection during hot reload: signature hashing, +/// inheritance checks, model ID construction, injection priority. +/// +internal static class TypeSignatureHasher +{ + private const BindingFlags DeclaredMembers = + BindingFlags.Public | BindingFlags.NonPublic | + BindingFlags.Instance | BindingFlags.Static | + BindingFlags.DeclaredOnly; + + /// + /// Compute a hash of a type's member signatures for incremental reload comparison. + /// If the hash matches between old and new assembly, the type is unchanged and can be skipped. + /// + public static int ComputeHash(Type type) + { + unchecked + { + var signatures = new List + { + $"type:{type.FullName}", + $"base:{type.BaseType?.FullName ?? ""}", + }; + + signatures.AddRange(type.GetInterfaces() + .Select(i => $"iface:{i.FullName}") + .OrderBy(s => s, StringComparer.Ordinal)); + + foreach (var method in type.GetMethods(DeclaredMembers) + .OrderBy(m => m.ToString(), StringComparer.Ordinal)) + { + var sig = $"{method.Name}|{method.ReturnType.FullName}|{string.Join(",", method.GetParameters().Select(p => p.ParameterType.FullName))}"; + try + { + var il = method.GetMethodBody()?.GetILAsByteArray(); + if (il is { Length: > 0 }) + sig += $"|il:{Convert.ToHexString(il)}"; + } + catch { /* some methods do not expose IL */ } + signatures.Add($"method:{sig}"); + } + + signatures.AddRange(type.GetFields(DeclaredMembers) + .OrderBy(f => f.Name, StringComparer.Ordinal) + .Select(f => $"field:{f.Name}|{f.FieldType.FullName}")); + + signatures.AddRange(type.GetProperties(DeclaredMembers) + .OrderBy(p => p.Name, StringComparer.Ordinal) + .Select(p => $"prop:{p.Name}|{p.PropertyType.FullName}")); + + foreach (var attr in type.GetCustomAttributesData() + .OrderBy(a => a.AttributeType.FullName, StringComparer.Ordinal)) + { + var ctorArgs = string.Join(",", attr.ConstructorArguments.Select(a => a.Value?.ToString() ?? "null")); + signatures.Add($"attr:{attr.AttributeType.FullName}|{ctorArgs}"); + } + + int hash = (int)2166136261; + foreach (var signature in signatures) + { + foreach (var ch in signature) + { + hash ^= ch; + hash *= 16777619; + } + } + return hash; + } + } + + /// + /// Check if a type IS or inherits from a base type by walking the name chain. + /// Works across AssemblyLoadContexts where IsSubclassOf fails due to type identity. + /// Checks the type itself first, then walks up the inheritance chain. + /// + public static bool InheritsFromByName(Type type, string baseTypeName) + { + var cursor = type; + while (cursor != null) + { + if (cursor.Name == baseTypeName) + return true; + cursor = cursor.BaseType; + } + return false; + } + + /// + /// Safely enumerate types from an assembly, handling ReflectionTypeLoadException. + /// If warnings and prefix are provided, load errors are recorded instead of silently swallowed. + /// + public static IEnumerable GetLoadableTypes(Assembly assembly, List? warnings = null, string? warningPrefix = null) + { + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + if (warnings != null) + { + var prefix = warningPrefix ?? "types"; + warnings.Add($"{prefix}: {ex.Message}"); + foreach (var loaderEx in ex.LoaderExceptions.Where(e => e != null).Take(3)) + warnings.Add($"{prefix}_loader: {loaderEx!.Message}"); + } + return ex.Types.Where(t => t != null).Cast(); + } + } + + /// + /// Returns injection priority for entity types. Lower = injected first. + /// Powers before cards (cards reference powers via PowerVar), + /// monsters before encounters (encounters reference monsters). + /// + public static int GetInjectionPriority(Type type) + { + if (InheritsFromByName(type, nameof(PowerModel))) return 0; + if (InheritsFromByName(type, nameof(RelicModel))) return 1; + if (InheritsFromByName(type, nameof(PotionModel))) return 2; + if (InheritsFromByName(type, nameof(MonsterModel))) return 3; + if (InheritsFromByName(type, nameof(EncounterModel))) return 4; + if (InheritsFromByName(type, nameof(CardModel))) return 5; + if (InheritsFromByName(type, "EventModel")) return 6; + return 9; + } + + /// + /// Get category and entry strings for a type WITHOUT constructing a ModelId. + /// Used to register in the serialization cache before ModelId construction. + /// + public static (string category, string entry) GetCategoryAndEntry(Type type) + { + var cursor = type; + while (cursor.BaseType != null && cursor.BaseType.Name != nameof(AbstractModel)) + cursor = cursor.BaseType; + string category = ModelId.SlugifyCategory(cursor.Name); + string entry = StringHelper.Slugify(type.Name); + return (category, entry); + } + + /// + /// Build a ModelId for a type using name-based base type detection. + /// Must be called AFTER registering the entity in ModelIdSerializationCache. + /// + public static ModelId BuildModelId(Type type) + { + var (category, entry) = GetCategoryAndEntry(type); + return new ModelId(category, entry); + } + + /// + /// Find all loaded assemblies that belong to a given mod. Used to locate old versions + /// of a mod's assembly so we can snapshot their entities and clean up stale patches. + /// The exclude parameter filters out the new assembly being loaded. + /// + public static IEnumerable GetAssembliesForMod(string modKey, Assembly? exclude = null) + { + return AppDomain.CurrentDomain.GetAssemblies() + .Where(a => + !string.IsNullOrEmpty(a.GetName().Name) + && string.Equals(AssemblyStamper.NormalizeModKey(a.GetName().Name), modKey, StringComparison.OrdinalIgnoreCase) + && a != exclude); + } + + /// + /// ALC resolving handler that redirects version-mismatched assembly references + /// to whatever's already loaded. For example, if a mod was built against BaseLib 0.2.1 + /// but the game has BaseLib 0.1.0 loaded, this handler returns the loaded 0.1.0 instead + /// of throwing FileNotFoundException. Matches by name, culture, and public key token. + /// + public static Assembly? DefaultAlcResolving(AssemblyLoadContext context, AssemblyName name) + { + var requestedToken = name.GetPublicKeyToken() ?? []; + return AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(loaded => + { + var candidate = loaded.GetName(); + if (!string.Equals(candidate.Name, name.Name, StringComparison.Ordinal)) + return false; + var candidateToken = candidate.GetPublicKeyToken() ?? []; + bool tokenMatches = requestedToken.Length == 0 || candidateToken.SequenceEqual(requestedToken); + bool cultureMatches = string.Equals(candidate.CultureName ?? "", name.CultureName ?? "", StringComparison.OrdinalIgnoreCase); + return tokenMatches && cultureMatches; + }); + } + + /// + /// How many bits the network serialization needs for this many entries. + /// Called after registering new entity IDs in the serialization cache. + /// + public static int ComputeBitSize(int count) + { + return count <= 1 ? 0 : (int)Math.Ceiling(Math.Log2(count)); + } +} diff --git a/Patches/Content/ContentPatches.cs b/Patches/Content/ContentPatches.cs index 8b2b123..8788b45 100644 --- a/Patches/Content/ContentPatches.cs +++ b/Patches/Content/ContentPatches.cs @@ -14,9 +14,12 @@ public static class CustomContentDictionary { private static readonly Dictionary CustomModelCounts = []; //May log, may just remove. private static readonly Dictionary PoolTypes = []; - + public static readonly List CustomAncients = []; - + + // Track which types were added per-assembly for hot reload cleanup + private static readonly Dictionary> ModelTypesByAssembly = []; + static CustomContentDictionary() { PoolTypes.Add(typeof(CardPoolModel), typeof(CardModel)); @@ -37,8 +40,17 @@ public static void AddModel(Type modelType) int count = CustomModelCounts.GetValueOrDefault(poolAttribute.PoolType, 0); CustomModelCounts[poolAttribute.PoolType] = count + 1; - + ModHelper.AddModelToPool(poolAttribute.PoolType, modelType); + + // Track for hot reload cleanup + var asmName = modelType.Assembly.GetName().Name ?? ""; + if (!ModelTypesByAssembly.TryGetValue(asmName, out var list)) + { + list = []; + ModelTypesByAssembly[asmName] = list; + } + list.Add(modelType); } public static void AddAncient(CustomAncientModel ancient) @@ -47,8 +59,33 @@ public static void AddAncient(CustomAncientModel ancient) CustomModelCounts[typeof(CustomAncientModel)] = count + 1; CustomAncients.Add(ancient); } - - + + /// + /// Remove all tracked content from a specific assembly. Used during hot reload + /// to clean up before re-registration from the new assembly version. + /// + internal static void RemoveByAssembly(Assembly oldAssembly) + { + var asmName = oldAssembly.GetName().Name ?? ""; + + // Remove tracked model types for this assembly + if (ModelTypesByAssembly.Remove(asmName, out var oldTypes)) + { + foreach (var modelType in oldTypes) + { + var poolAttr = modelType.GetCustomAttribute(); + if (poolAttr != null && CustomModelCounts.ContainsKey(poolAttr.PoolType)) + { + CustomModelCounts[poolAttr.PoolType] = Math.Max(0, CustomModelCounts[poolAttr.PoolType] - 1); + } + } + } + + // Remove ancients from old assembly + CustomAncients.RemoveAll(a => a.GetType().Assembly == oldAssembly); + } + + private static bool IsValidPool(Type modelType, Type poolType) { var basePoolType = poolType.BaseType; @@ -135,6 +172,11 @@ public static void Register(CustomCardPoolModel pool) { CustomSharedPools.Add(pool); } + + internal static void RemoveByAssembly(Assembly asm) + { + CustomSharedPools.RemoveAll(p => p.GetType().Assembly == asm); + } } [HarmonyPatch(typeof(ModelDb), "AllSharedRelicPools", MethodType.Getter)] @@ -152,6 +194,11 @@ public static void Register(CustomRelicPoolModel pool) { customSharedPools.Add(pool); } + + internal static void RemoveByAssembly(Assembly asm) + { + customSharedPools.RemoveAll(p => p.GetType().Assembly == asm); + } } [HarmonyPatch(typeof(ModelDb), "AllSharedPotionPools", MethodType.Getter)] @@ -169,6 +216,11 @@ public static void Register(CustomPotionPoolModel pool) { customSharedPools.Add(pool); } + + internal static void RemoveByAssembly(Assembly asm) + { + customSharedPools.RemoveAll(p => p.GetType().Assembly == asm); + } } [HarmonyPatch(typeof(ActModel), nameof(ActModel.GenerateRooms))] diff --git a/Patches/Content/CustomEnums.cs b/Patches/Content/CustomEnums.cs index a0b6633..d404acf 100644 --- a/Patches/Content/CustomEnums.cs +++ b/Patches/Content/CustomEnums.cs @@ -147,11 +147,27 @@ private static bool UseCustomKeywordMap(CardKeyword keyword, ref string? __resul [HarmonyPatch(typeof(ModelDb), nameof(ModelDb.Init))] class GenEnumValues { + // Track which fields have already been generated to avoid duplicates during hot reload + private static readonly HashSet GeneratedFieldKeys = []; + + private static string FieldKey(FieldInfo field) => + $"{field.DeclaringType?.FullName}.{field.Name}"; + [HarmonyPrefix] static void FindAndGenerate() + { + GenerateForTypes(ReflectionHelper.ModTypes); + } + + /// + /// Generate custom enum values for the given types. Skips fields that have already + /// been generated (dedup for hot reload). Called at startup for all mod types, + /// and again during hot reload for new assembly types. + /// + internal static void GenerateForTypes(IEnumerable types) { List customEnumFields = []; - foreach (var t in ReflectionHelper.ModTypes) + foreach (var t in types) { var fields = t.GetFields().Where(field => Attribute.IsDefined(field, typeof(CustomEnumAttribute))); @@ -174,10 +190,14 @@ static void FindAndGenerate() continue; } + // Skip fields already generated in a previous load + if (GeneratedFieldKeys.Contains(FieldKey(field))) + continue; + customEnumFields.Add(field); } } - + customEnumFields.Sort((a, b) => { var comparison = string.Compare(a.Name, b.Name, StringComparison.Ordinal); @@ -190,8 +210,9 @@ static void FindAndGenerate() var key = CustomEnums.GenerateKey(field.FieldType); var t = field.DeclaringType; if (t == null) continue; - + field.SetValue(null, key); + GeneratedFieldKeys.Add(FieldKey(field)); if (field.FieldType == typeof(CardKeyword)) { @@ -208,20 +229,20 @@ static void FindAndGenerate() AutoKeywordText.AdditionalAfterKeywords.Add((CardKeyword) key); break; } - + CustomKeywords.KeywordIDs.Add((int) key, new(keywordId, autoPosition)); continue; } - + //Following code is exclusively for CustomPile if (field.FieldType != typeof(PileType)) continue; - if (!t.IsAssignableTo(typeof(CustomPile))) continue; - + if (!t.IsAssignableTo(typeof(CustomPile))) continue; + var constructor = t.GetConstructor(BindingFlags.Instance | BindingFlags.Public, []) ?? throw new Exception($"CustomPile {t.FullName} with custom PileType does not have an accessible no-parameter constructor"); - + var pileType = (PileType?)field.GetValue(null); if (pileType == null) throw new Exception($"Failed to be set up custom PileType in {t.FullName}"); - + CustomPiles.RegisterCustomPile((PileType) pileType, () => (CustomPile) constructor.Invoke(null)); } } diff --git a/Patches/PostModInitPatch.cs b/Patches/PostModInitPatch.cs index 714accd..23f9739 100644 --- a/Patches/PostModInitPatch.cs +++ b/Patches/PostModInitPatch.cs @@ -10,19 +10,31 @@ namespace BaseLib.Patches; //Simplest patch that occurs after mod initialization, before anything else is done -[HarmonyPatch(typeof(LocManager), nameof(LocManager.Initialize))] +[HarmonyPatch(typeof(LocManager), nameof(LocManager.Initialize))] class PostModInitPatch { + private static Harmony? _postModInitHarmony; + [HarmonyPrefix] private static void ProcessModdedTypes() { - Harmony harmony = new("PostModInit"); + _postModInitHarmony = new Harmony("PostModInit"); + ProcessTypes(ReflectionHelper.ModTypes); + SavedSpireFieldPatch.AddFieldsSorted(); + } + /// + /// Process a set of types for ModInterop, SavedProperty, and SavedSpireField registration. + /// Called at startup for all mod types, and again during hot reload for new assembly types. + /// + internal static void ProcessTypes(IEnumerable types) + { + _postModInitHarmony ??= new Harmony("PostModInit"); ModInterop interop = new(); - - foreach (var type in ReflectionHelper.ModTypes) + + foreach (var type in types) { - interop.ProcessType(harmony, type); + interop.ProcessType(_postModInitHarmony, type); bool hasSavedProperty = false; foreach (var prop in type.GetProperties()) @@ -39,7 +51,7 @@ private static void ProcessModdedTypes() BaseLibMain.Logger.Warn($"Recommended to add a prefix such as \"{prefix}\" to SavedProperty {prop.Name} for compatibility."); } } - + hasSavedProperty = true; } @@ -53,8 +65,6 @@ private static void ProcessModdedTypes() SavedPropertiesTypeCache.InjectTypeIntoCache(type); } } - - SavedSpireFieldPatch.AddFieldsSorted(); } } \ No newline at end of file diff --git a/Patches/Utils/ModelLocPatch.cs b/Patches/Utils/ModelLocPatch.cs index e0ce282..2d78218 100644 --- a/Patches/Utils/ModelLocPatch.cs +++ b/Patches/Utils/ModelLocPatch.cs @@ -36,21 +36,30 @@ class ModelLocPatch [HarmonyPostfix] static void AddModelLoc(Dictionary ____contentById) { - foreach (KeyValuePair content in ____contentById) + InjectLocalization(____contentById); + } + + /// + /// Inject localization entries for all ILocalizationProvider models in the given dictionary. + /// Called at startup via Harmony postfix and during hot reload for re-injection. + /// + internal static void InjectLocalization(IEnumerable> content) + { + foreach (var entry in content) { - if (content.Value is ILocalizationProvider locProvider) + if (entry.Value is ILocalizationProvider locProvider) { var loc = locProvider.Localization; if (loc == null) continue; - + var table = locProvider.LocTable - ?? CategoryToLocTable.GetValueOrDefault(content.Key.Category, null) + ?? CategoryToLocTable.GetValueOrDefault(entry.Key.Category, null) ?? throw new Exception("Override LocTable in your ILocalizationProvider."); var locTable = LocManager.Instance.GetTable(table); - var dict = LocDictionaryField.GetValue(locTable) as Dictionary + var dict = LocDictionaryField.GetValue(locTable) as Dictionary ?? throw new Exception("Failed to get localization dictionary."); - - string key = content.Key.Entry; + + string key = entry.Key.Entry; foreach (var locEntry in loc) { dict[$"{key}.{locEntry.Item1}"] = locEntry.Item2; diff --git a/Patches/Utils/SavedSpireFieldPatch.cs b/Patches/Utils/SavedSpireFieldPatch.cs index d86282f..b328c6d 100644 --- a/Patches/Utils/SavedSpireFieldPatch.cs +++ b/Patches/Utils/SavedSpireFieldPatch.cs @@ -13,6 +13,15 @@ static class SavedSpireFieldPatch public static void Register(SavedSpireField field) where TKey : class => RegisteredFields.Add(field); + /// + /// Remove all saved spire fields whose TargetType belongs to the given assembly. + /// Called during hot reload before re-processing types from the new assembly. + /// + internal static void RemoveByAssembly(Assembly oldAssembly) + { + RegisteredFields.RemoveAll(f => f.TargetType.Assembly == oldAssembly); + } + private static IEnumerable GetFieldsForModel(object model) => RegisteredFields.Where(f => f.TargetType.IsInstanceOfType(model)); diff --git a/Utils/NodeFactories/NodeFactory.cs b/Utils/NodeFactories/NodeFactory.cs index cdadc59..6dc5e6c 100644 --- a/Utils/NodeFactories/NodeFactory.cs +++ b/Utils/NodeFactories/NodeFactory.cs @@ -1,4 +1,5 @@ -using Godot; +using System.Collections.Concurrent; +using Godot; using MegaCrit.Sts2.Core.Assets; using MegaCrit.Sts2.Core.Nodes.GodotExtensions; @@ -16,6 +17,7 @@ namespace BaseLib.Utils.NodeFactories; protected NodeFactory(IEnumerable namedNodes) : base(namedNodes) { _instance = this; + RegisterFactory(typeof(T), this); BaseLibMain.Logger.Info($"Created node factory for {typeof(T).Name}."); } @@ -166,6 +168,13 @@ protected virtual void TransferAndCreateNodes(T target, Node? source) } } + internal override Node CreateAndConvert(Node source) + { + var target = new T(); + ConvertScene(target, source); + return target; + } + /// /// This method should convert the given node into the target type, or call the base method if unsupported. /// The given node should either be freed or incorporated as a child of the generated node. @@ -194,13 +203,404 @@ protected virtual Node ConvertNodeType(Node node, Type targetType) public abstract class NodeFactory { + // Both dictionaries are concurrent — _factories is written during Init on the main thread + // and read from the Instantiate postfix which can fire from any thread during asset loading. + private static readonly ConcurrentDictionary _factories = new(); + private static readonly ConcurrentDictionary _sceneTypes = new(); + + // Prevents recursion when the postfix triggers during a factory conversion. + // ThreadStatic is correct here: Godot node creation is main-thread, but this also + // makes us safe if background asset loading ever calls Instantiate. + [ThreadStatic] + private static bool _isConverting; + public static void Init() { new ControlFactory(); new NCreatureVisualsFactory(); new NEnergyCounterFactory(); + + RunSelfTests(); } - + + /// + /// Register a scene path to be auto-converted to the specified node type on Instantiate. + /// The node type must have a NodeFactory registered for it. + /// + public static void RegisterSceneType(string scenePath) where TNode : Node + { + RegisterSceneType(scenePath, typeof(TNode)); + } + + /// + /// Register a scene path to be auto-converted to the specified node type on Instantiate. + /// Logs a warning if the path was already registered for a different type (silent overwrite). + /// + public static void RegisterSceneType(string scenePath, Type nodeType) + { + if (string.IsNullOrWhiteSpace(scenePath)) + { + BaseLibMain.Logger.Warn($"Ignoring RegisterSceneType({nodeType.Name}) with null/empty path"); + return; + } + + if (_sceneTypes.TryGetValue(scenePath, out var existing) && existing != nodeType) + BaseLibMain.Logger.Warn($"Overwriting scene registration for '{scenePath}': {existing.Name} → {nodeType.Name}"); + + _sceneTypes[scenePath] = nodeType; + BaseLibMain.Logger.Info($"Registered scene '{scenePath}' for auto-conversion to {nodeType.Name}"); + } + + /// + /// Remove a previously registered scene path. Safe to call even if the path was never registered. + /// + public static void UnregisterSceneType(string scenePath) + { + _sceneTypes.TryRemove(scenePath, out _); + } + + /// + /// Remove all scene registrations whose target node type belongs to the given assembly. + /// Called during hot reload cleanup so stale scene → type mappings don't persist. + /// Also clears logged-conversion tracking for those paths so re-registered scenes + /// get fresh log entries. + /// + internal static void RemoveByAssembly(System.Reflection.Assembly asm) + { + foreach (var (path, type) in _sceneTypes) + { + if (type.Assembly == asm) + { + _sceneTypes.TryRemove(path, out _); + _loggedConversions.TryRemove(path, out _); + } + } + } + + /// + /// Check whether a factory is registered for the given node type. + /// + public static bool HasFactory() where TNode : Node => _factories.ContainsKey(typeof(TNode)); + + /// + /// Check whether a scene path is registered for auto-conversion. + /// + public static bool IsRegistered(string scenePath) => !string.IsNullOrEmpty(scenePath) && _sceneTypes.ContainsKey(scenePath); + + internal static void RegisterFactory(Type nodeType, NodeFactory factory) + { + _factories[nodeType] = factory; + } + + // Tracks which paths have already logged a conversion, to avoid log spam. + // ConcurrentDictionary for thread safety — same contract as _factories/_sceneTypes. + private static readonly ConcurrentDictionary _loggedConversions = new(); + + /// + /// Checks if the instantiated node needs auto-conversion based on registered scene types, + /// and performs the conversion if a matching factory exists. + /// Called from the PackedScene.Instantiate postfix. + /// + /// When CreateFromScene also calls Instantiate internally, the postfix handles the conversion + /// and CreateFromScene's "if (n is T t) return t" short-circuits — both paths produce the same result. + /// + internal static bool TryAutoConvert(PackedScene scene, ref Node result) + { + if (_isConverting || result == null) return false; + + var path = scene.ResourcePath; + if (string.IsNullOrEmpty(path)) return false; + if (!_sceneTypes.TryGetValue(path, out var expectedType)) return false; + if (expectedType.IsAssignableFrom(result.GetType())) return false; // already the right type + + if (!_factories.TryGetValue(expectedType, out var factory)) + { + BaseLibMain.Logger.Warn($"Scene '{path}' registered for {expectedType.Name} but no factory exists for that type"); + return false; + } + + _isConverting = true; + try + { + var sourceTypeName = result.GetType().Name; + var converted = factory.CreateAndConvert(result); + + // Only log the first conversion per path to avoid spam (monsters get instantiated a lot) + if (_loggedConversions.TryAdd(path, 0)) + BaseLibMain.Logger.Info($"Auto-converted '{path}' from {sourceTypeName} to {converted.GetType().Name}"); + + result = converted; + return true; + } + catch (Exception e) + { + // CreateAndConvert is destructive — it reparents children from the source node + // and may QueueFree it. If conversion fails midway, the original __result is + // corrupted (children stripped, possibly queued for deletion). We MUST NOT return + // false and let the caller use the mangled node. Re-throw so the failure is visible. + BaseLibMain.Logger.Error($"Auto-conversion failed for '{path}' — the instantiated node is likely corrupt: {e}"); + throw; + } + finally + { + _isConverting = false; + } + } + + /// + /// Create a new instance of the factory's target type and convert the source node into it. + /// Used by auto-conversion to avoid calling Instantiate again. + /// + internal abstract Node CreateAndConvert(Node source); + + //-- Self-tests: run once at init to verify the whole postfix → factory pipeline works -- + + private static int _testsPassed; + private static int _testsFailed; + + private static void RunSelfTests() + { + _testsPassed = 0; + _testsFailed = 0; + + TestCreatureVisualsConversion(); + TestGenericInstantiateChain(); + TestControlConversion(); + TestAlreadyCorrectTypePassthrough(); + TestUnregisteredScenePassthrough(); + TestNullAndEmptyPathValidation(); + TestOverwriteAndUnregister(); + TestQueryApis(); + + if (_testsFailed == 0) + BaseLibMain.Logger.Info($"All {_testsPassed} auto-conversion self-tests passed"); + else + BaseLibMain.Logger.Error($"Auto-conversion self-tests: {_testsPassed} passed, {_testsFailed} FAILED"); + + _testsPassed = 0; + _testsFailed = 0; + } + + private static void Assert(bool condition, string testName) + { + if (condition) + _testsPassed++; + else + { + _testsFailed++; + BaseLibMain.Logger.Error($"FAIL: {testName}"); + } + } + + /// + /// Pack a Node2D scene with creature-like children, register it, Instantiate, expect NCreatureVisuals. + /// + private static void TestCreatureVisualsConversion() + { + const string path = "res://baselib_test/creature.tscn"; + try + { + var root = new Node2D { Name = "TestCreature" }; + AddOwnedChild(root, new Sprite2D { Name = "Visuals", UniqueNameInOwner = true }); + AddOwnedChild(root, new Control { Name = "Bounds", Size = new Vector2(200, 240), Position = new Vector2(-100, -240) }); + AddOwnedChild(root, new Marker2D { Name = "IntentPos" }); + AddOwnedChild(root, new Marker2D { Name = "CenterPos", UniqueNameInOwner = true }); + + var scene = new PackedScene(); + scene.Pack(root); + root.QueueFree(); + scene.ResourcePath = path; + + _sceneTypes[path] = typeof(MegaCrit.Sts2.Core.Nodes.Combat.NCreatureVisuals); + + var result = scene.Instantiate(PackedScene.GenEditState.Disabled); + Assert(result is MegaCrit.Sts2.Core.Nodes.Combat.NCreatureVisuals, "NCreatureVisuals conversion"); + result.QueueFree(); + } + catch (Exception e) { Assert(false, $"NCreatureVisuals conversion (threw: {e.Message})"); } + finally { _sceneTypes.TryRemove(path, out _); _loggedConversions.TryRemove(path, out _); } + } + + /// + /// Pack a Node2D, register for Control, Instantiate, expect Control. + /// + private static void TestControlConversion() + { + const string path = "res://baselib_test/control.tscn"; + try + { + var root = new Node2D { Name = "TestControl" }; + + var scene = new PackedScene(); + scene.Pack(root); + root.QueueFree(); + scene.ResourcePath = path; + + _sceneTypes[path] = typeof(Control); + + var result = scene.Instantiate(PackedScene.GenEditState.Disabled); + Assert(result is Control, "Control conversion"); + result.QueueFree(); + } + catch (Exception e) { Assert(false, $"Control conversion (threw: {e.Message})"); } + finally { _sceneTypes.TryRemove(path, out _); _loggedConversions.TryRemove(path, out _); } + } + + /// + /// Register a scene whose root is already the expected type — should pass through with no conversion. + /// + private static void TestAlreadyCorrectTypePassthrough() + { + const string path = "res://baselib_test/passthrough.tscn"; + try + { + var root = new Control { Name = "AlreadyControl" }; + + var scene = new PackedScene(); + scene.Pack(root); + root.QueueFree(); + scene.ResourcePath = path; + + _sceneTypes[path] = typeof(Control); + + var result = scene.Instantiate(PackedScene.GenEditState.Disabled); + // Should still be Control (no conversion needed), and specifically should NOT + // have gone through CreateAndConvert (it would still be Control either way, + // but IsAssignableFrom should short-circuit). + Assert(result is Control, "Already-correct-type passthrough"); + result.QueueFree(); + } + catch (Exception e) { Assert(false, $"Already-correct-type passthrough (threw: {e.Message})"); } + finally { _sceneTypes.TryRemove(path, out _); } + } + + /// + /// Instantiate a scene that is NOT registered — should return the raw node unchanged. + /// + private static void TestUnregisteredScenePassthrough() + { + const string path = "res://baselib_test/unregistered.tscn"; + try + { + var root = new Node2D { Name = "Unregistered" }; + + var scene = new PackedScene(); + scene.Pack(root); + root.QueueFree(); + scene.ResourcePath = path; + // Deliberately NOT registering this path + + var result = scene.Instantiate(PackedScene.GenEditState.Disabled); + Assert(result is Node2D, "Unregistered scene passthrough"); + Assert(result.GetType() == typeof(Node2D), "Unregistered scene is exactly Node2D"); + result.QueueFree(); + } + catch (Exception e) { Assert(false, $"Unregistered scene passthrough (threw: {e.Message})"); } + } + + /// + /// THE critical test: call the generic Instantiate<NCreatureVisuals>() which is what game + /// code actually uses. Verifies the postfix on the non-generic Instantiate fires and converts + /// the node BEFORE the (T)(object) cast in the generic method. + /// If this test passes, the entire approach works end-to-end. + /// + private static void TestGenericInstantiateChain() + { + const string path = "res://baselib_test/generic_chain.tscn"; + try + { + var root = new Node2D { Name = "GenericChainTest" }; + AddOwnedChild(root, new Sprite2D { Name = "Visuals", UniqueNameInOwner = true }); + AddOwnedChild(root, new Control { Name = "Bounds", Size = new Vector2(200, 240), Position = new Vector2(-100, -240) }); + AddOwnedChild(root, new Marker2D { Name = "IntentPos" }); + AddOwnedChild(root, new Marker2D { Name = "CenterPos", UniqueNameInOwner = true }); + + var scene = new PackedScene(); + scene.Pack(root); + root.QueueFree(); + scene.ResourcePath = path; + + _sceneTypes[path] = typeof(MegaCrit.Sts2.Core.Nodes.Combat.NCreatureVisuals); + + // This is the real deal — Instantiate calls Instantiate() then casts to T. + // Our postfix on Instantiate() must convert BEFORE the cast, or this throws InvalidCastException. + var result = scene.Instantiate(PackedScene.GenEditState.Disabled); + Assert(result is MegaCrit.Sts2.Core.Nodes.Combat.NCreatureVisuals, "Generic Instantiate chain"); + result.QueueFree(); + } + catch (InvalidCastException) + { + Assert(false, "Generic Instantiate chain (InvalidCastException — postfix didn't fire before cast!)"); + } + catch (Exception e) + { + Assert(false, $"Generic Instantiate chain (threw: {e.Message})"); + } + finally { _sceneTypes.TryRemove(path, out _); _loggedConversions.TryRemove(path, out _); } + } + + /// + /// Verify that null/empty paths are rejected by RegisterSceneType. + /// + private static void TestNullAndEmptyPathValidation() + { + var countBefore = _sceneTypes.Count; + RegisterSceneType(null!); + RegisterSceneType(""); + RegisterSceneType(" "); + Assert(_sceneTypes.Count == countBefore, "Null/empty/whitespace paths rejected"); + } + + /// + /// Verify overwrite and unregister work correctly. + /// + private static void TestOverwriteAndUnregister() + { + const string path = "res://baselib_test/overwrite.tscn"; + try + { + // Register for Control, then overwrite to NCreatureVisuals + _sceneTypes[path] = typeof(Control); + RegisterSceneType(path); + Assert(_sceneTypes.TryGetValue(path, out var t) && t == typeof(MegaCrit.Sts2.Core.Nodes.Combat.NCreatureVisuals), + "Overwrite registration"); + + // Unregister + UnregisterSceneType(path); + Assert(!_sceneTypes.ContainsKey(path), "Unregister removes path"); + + // Unregister again (should not throw) + UnregisterSceneType(path); + Assert(true, "Double unregister is safe"); + } + catch (Exception e) { Assert(false, $"Overwrite/unregister (threw: {e.Message})"); } + finally { _sceneTypes.TryRemove(path, out _); } + } + + /// + /// Verify the public query APIs work. + /// + private static void TestQueryApis() + { + Assert(HasFactory(), "HasFactory"); + Assert(HasFactory(), "HasFactory"); + Assert(!HasFactory(), "!HasFactory (no factory registered)"); + + const string path = "res://baselib_test/query_test.tscn"; + _sceneTypes[path] = typeof(Control); + Assert(IsRegistered(path), "IsRegistered for known path"); + Assert(!IsRegistered("res://nonexistent/path.tscn"), "!IsRegistered for unknown path"); + Assert(!IsRegistered(""), "!IsRegistered for empty path"); + Assert(!IsRegistered(null!), "!IsRegistered for null path"); + _sceneTypes.TryRemove(path, out _); + } + + private static void AddOwnedChild(Node parent, Node child) + { + parent.AddChild(child); + child.Owner = parent; + } + protected interface INodeInfo { string Path { get; } diff --git a/build/BaseLib.props b/build/BaseLib.props index 346d7bf..ce121b0 100644 --- a/build/BaseLib.props +++ b/build/BaseLib.props @@ -4,4 +4,64 @@ false - \ No newline at end of file + + + + + <_OriginalAssemblyName Condition="'$(_OriginalAssemblyName)' == ''">$(AssemblyName) + $(_OriginalAssemblyName)_hr$([System.DateTime]::UtcNow.ToString("HHmmssfff")) + + + + + + <_ModFolderName Condition="'$(_OriginalAssemblyName)' != ''">$(_OriginalAssemblyName) + <_ModFolderName Condition="'$(_OriginalAssemblyName)' == ''">$(MSBuildProjectName) + <_ModsPath>$(Sts2Path)/mods/$(_ModFolderName)/ + + + + + + + + + + + + + + diff --git a/docs/hot_reload.md b/docs/hot_reload.md new file mode 100644 index 0000000..23fef26 --- /dev/null +++ b/docs/hot_reload.md @@ -0,0 +1,304 @@ +# Hot Reload + +Change your mod's code while the game is running and see the results instantly — no +restart needed. Edit a card's damage, tweak a relic's effect, fix a bug in a power, +and it takes effect in seconds. + +## Do I Need to Do Anything Special? + +**If your mod already uses the BaseLib NuGet package: almost nothing.** + +BaseLib automatically handles assembly name stamping and deployment. The only thing +you need is `Sts2Path` set in your `.csproj` so the build knows where the game is +installed. Most mod templates already have this. + +If you're starting from the [Mod Template](https://github.com/Alchyr/ModTemplate-StS2), +everything is pre-configured and hot reload works out of the box. + +## Quick Start + +### 1. Make sure your csproj has the game path + +Your `.csproj` should have something like this (adjust the path for your system): + +```xml + + E:\SteamLibrary\steamapps\common\Slay the Spire 2 + +``` + +Most templates detect the Steam path automatically. If yours doesn't, add it manually. + +### 2. Build your mod + +Build in Debug mode like normal: + +``` +dotnet build -c Debug +``` + +You'll see output like: + +``` +MyMod -> bin\Debug\net9.0\MyMod_hr143052789.dll +[BaseLib] Deployed to .../mods/MyMod/ +``` + +The `_hr143052789` suffix is the hot reload stamp — a unique name per build so the +game can load it alongside the previous version. This happens automatically; you +don't need to do anything. + +### 3. Reload in-game + +Open the dev console (`~` key) and type: + +``` +hotreload MyMod +``` + +That's it. Your changes are now live in the game. + +## What Happens During a Hot Reload + +When you run `hotreload MyMod`, BaseLib: + +1. Finds the newest DLL in `mods/MyMod/` +2. Loads it into the game's runtime +3. Detects which entities changed (by comparing code signatures) +4. Removes old versions of changed entities from the game database +5. Creates fresh instances of changed entities (unchanged ones are skipped) +6. Re-registers them in card/relic/potion pools +7. Reloads localization text +8. Updates any cards, relics, or powers visible in the current combat + +All of this typically takes under 100ms. + +## Console Commands + +| Command | Description | +|---------|-------------| +| `hotreload MyMod` | Reload a mod by its folder name in `mods/` | +| `hotreload path/to/MyMod.dll` | Reload a specific DLL by path | +| `hotreload MyMod 3` | Reload with tier 3 (includes PCK resource remount) | +| `hotreload_list` | Show all mods available for reload with timestamps | +| `hotreload_status` | Show the last reload result and file watcher state | +| `hotreload_test` | Run integration tests against the live game state | + +### Tier Auto-Detection + +If you don't specify a tier, BaseLib picks one automatically: + +- **Tier 2** (default) — Reloads entities + Harmony patches + localization +- **Tier 3** (auto if `.pck` file exists) — Everything in tier 2 + Godot resources (scenes, textures, audio) + +You can force a specific tier by passing it as the second argument: +`hotreload MyMod 1` for patch-only reload (fastest, no entity refresh). + +## What Can Be Hot-Reloaded + +| Change | Supported | Notes | +|--------|-----------|-------| +| Card stats (damage, cost, block) | Yes | Change the value, rebuild, reload | +| Card effects (OnPlay logic) | Yes | New method body takes effect | +| Relic effects | Yes | Same as cards | +| Power effects | Yes | Same as cards | +| Potion effects | Yes | Same as cards | +| Localization text | Yes | Updated via `ILocalizationProvider` | +| Harmony patches | Yes | Old patches removed, new ones applied | +| New entity types | Yes | Added to game database and pools | +| Removed entity types | Yes | Removed from database (orphaned instances warned) | +| Custom enums / keywords | Yes | Re-generated on reload | +| SavedSpireField registrations | Yes | Re-processed on reload | +| Custom character visuals | Partial | Paths re-registered; character selection UI needs restart | +| Custom orb properties | Yes | Random pool cache rebuilt | +| Scene registrations (NodeFactory) | Yes | Old registrations cleaned up | +| Godot scenes/textures (PCK) | Yes | Requires tier 3 reload | +| BaseLib dependency version changes | No | Requires game restart | +| Adding new NuGet packages | No | Requires game restart | + +## Incremental Reload + +BaseLib compares the code signature of each entity type between the old and new +assembly. If a type hasn't changed (same methods, fields, properties, IL bytecode), +it's skipped entirely. This means: + +- Rebuilding without changes → 0 entities injected (instant) +- Changing one card out of 50 → only that card is re-injected +- Changing a shared utility class → all entities that use it get new IL → re-injected + +## File Watcher (Automatic Reload) + +Instead of typing `hotreload` every time, you can enable the file watcher to +automatically reload when you build: + +1. Open BaseLib's settings in the mod menu +2. Enable **"File Watcher"** +3. Every time you `dotnet build`, the new DLL is detected and reloaded automatically + +The watcher: +- Monitors the `mods/` directory for new hot-reload-stamped DLLs +- Debounces 500ms after the last file write (MSBuild writes incrementally) +- Works on Windows natively; includes a polling fallback for Linux +- Only triggers for `_hr`-stamped DLLs (won't react to manifest copies etc.) + +## Troubleshooting + +### "Hot reload already in progress" + +The previous reload hasn't finished yet. Wait a few seconds and try again. + +### "dll_not_found" + +The DLL path doesn't exist. Check: +- Did the build succeed? (`dotnet build` should show `Build succeeded`) +- Is `Sts2Path` correct in your `.csproj`? +- Run `hotreload_list` to see what's actually in the mods folder + +### "Entity staging failed" + +An entity couldn't be created from the new assembly. Common causes: +- **MissingMethodException** — Your mod references a method that doesn't exist in + the loaded version of a dependency. Rebuild against the same version. +- **DuplicateModelException** — Shouldn't happen (BaseLib removes old entities first), + but if it does, restart the game and try again. +- **TypeLoadException** — A base class changed incompatibly. Restart the game. + +Check the game log for the specific error: `[BaseLib] [HotReload] Failed to stage ...` + +### "assembly_load" error + +The DLL couldn't be loaded. Common causes: +- The DLL is corrupted or truncated (build was interrupted) +- A dependency DLL is missing from the mod folder +- The assembly references a game version that doesn't match + +### Build says "file is locked" + +The game holds a lock on the original mod DLL. This is normal — the hot reload +stamping gives each build a unique filename specifically to avoid this. If the +**stamped** DLL is also locked, it means the game loaded it during a previous +hot reload. Just build again (the new timestamp produces a new filename). + +### Changes don't seem to take effect + +- Make sure you're building in **Debug** mode (Release builds aren't stamped) +- Check that `hotreload` output says the entities were "injected", not "unchanged" +- If entities show as "unchanged", your code changes might be in a file that + doesn't affect entity type signatures (e.g., a helper method in a non-entity class). + In that case, the changed code IS loaded — it just didn't trigger re-injection + because the entity types themselves didn't change. + +### Memory usage grows over time + +Each hot reload loads a new assembly into the game's runtime. Old assemblies can't be +fully unloaded (a .NET limitation for non-collectible ALCs). Each reload adds ~0.5-2 MB. +Restart the game periodically during long development sessions. + +## Opting Out + +If hot reload stamping causes issues with your build pipeline, disable it: + +```xml + + false + +``` + +To disable the auto-copy to mods folder: + +```xml + + true + +``` + +## For Advanced Users + +### Public API + +You can trigger hot reload programmatically from your own code: + +```csharp +using BaseLib.HotReload; + +// Reload by mod folder name (auto-detects tier and latest DLL) +var result = HotReloadEngine.ReloadByModId("MyMod"); +if (result.Success) + Console.WriteLine($"Reloaded {result.EntitiesInjected} entities in {result.TotalMs}ms"); +else + Console.WriteLine($"Failed: {result.Errors[0]}"); + +// Reload a specific DLL with explicit tier +var result2 = HotReloadEngine.Reload("path/to/MyMod_hr143052789.dll", tier: 2); + +// Subscribe to reload events +HotReloadEngine.OnReloadComplete += result => +{ + // Called after every reload (success or failure) + // Use this to refresh your mod's own caches +}; + +// Check reload history +foreach (var past in HotReloadEngine.ReloadHistory) + Console.WriteLine(past.Summary); +``` + +### HotReloadResult Fields + +| Field | Type | Description | +|-------|------|-------------| +| `Success` | bool | Whether the reload completed without errors | +| `Tier` | int | The reload tier used (1, 2, or 3) | +| `AssemblyName` | string | Full name of the loaded assembly | +| `EntitiesInjected` | int | How many entities were created fresh | +| `EntitiesSkipped` | int | How many entities were unchanged (same hash) | +| `EntitiesRemoved` | int | How many old entities were removed from ModelDb | +| `PatchCount` | int | How many Harmony patches were applied | +| `PoolsUnfrozen` | int | How many game pools were unfrozen for re-registration | +| `LocalizationReloaded` | bool | Whether localization tables were refreshed | +| `PckReloaded` | bool | Whether the PCK resource pack was remounted | +| `LiveInstancesRefreshed` | int | How many in-scene nodes were updated | +| `TotalMs` | long | Total time in milliseconds | +| `StepTimings` | dict | Time per pipeline step (for profiling) | +| `ChangedEntities` | list | Per-entity details (name, action, id) | +| `Actions` | list | What the pipeline did (for debugging) | +| `Errors` | list | What went wrong (empty on success) | +| `Warnings` | list | Non-fatal issues (e.g., memory accumulation) | + +### How Assembly Stamping Works + +When you build in Debug mode, BaseLib's NuGet package injects a PropertyGroup that +changes your assembly name to include a timestamp: + +``` +MyMod → MyMod_hr143052789 +``` + +This produces a file like `MyMod_hr143052789.dll`. Each build gets a unique name, +which solves two problems: +1. The game's runtime rejects loading an assembly with a name it already has +2. The game holds a file lock on the previously loaded DLL + +The timestamp format is `HHmmssfff` (hours, minutes, seconds, milliseconds in UTC). +Release builds are NOT stamped — only Debug. + +### Pipeline Steps (Technical) + +1. **Load assembly** into the game's ALC (Godot's `IsolatedComponentLoadContext`) +2. **Stage Harmony patches** with a unique instance per reload +3. **Update `Mod.assembly`** reference in the game's `ModManager` +4. **Invalidate `ReflectionHelper._modTypes`** cache +5. **Register entity IDs** in the network serialization cache +6. **Remove old entities** from `ModelDb._contentById` (prevents DuplicateModelException) +7. **Create new entities** via `Activator.CreateInstance()` (BaseLib constructors auto-register in pools) +8. **Clear ModelDb caches** (14 lazy-computed collection fields) +9. **Unfreeze pools** and clean old entries from `ModHelper._moddedContentForPools` +10. **Refresh BaseLib subsystems** (SavedSpireField, ModInterop, custom enums, NodeFactory, characters, orbs) +11. **Reload localization** (file-based + ILocalizationProvider re-injection) +12. **Remount PCK** (tier 3 only) +13. **Verify entities** in ModelDb + ToMutable() sanity check on cards +14. **Refresh live instances** (scene tree nodes + run state: deck, piles, relics, powers) +15. **Commit session** (swap Harmony instances, clean stale patches, unload old ALC) + +Rollback: if entity creation fails, the pipeline restores ModelDb, Mod.assembly +references, serialization cache, and Harmony patches to their pre-reload state. From e26a057865a69d8bff71df94a8fb5ff51586578b Mon Sep 17 00:00:00 2001 From: elliotttate Date: Mon, 30 Mar 2026 23:02:01 -0400 Subject: [PATCH 2/4] Harden hot reload pipeline and expand test coverage - Fix CTS leak in ModFileWatcher (old CancellationTokenSource was never disposed) - Add logging to LiveInstanceRefresher catch blocks (was silently swallowing errors) - Expand rollback to clear ModelDb caches and pool instance caches on entity failure - Skip types without parameterless constructors instead of crashing - Guard against null from Activator.CreateInstance - Protect file watcher against file deletion between debounce and read - Warn when old assembly count exceeds 10 (memory accumulation) - Log stale patch removal failures instead of silently ignoring - Add 11 new integration tests: bad path/mod ID error handling, InheritsFromByName vs IsSubclassOf consistency, hash stability, assembly count health check, RemoveByAssembly safety with unknown assemblies, edge case mod names, and more --- HotReload/HotReloadPipeline.cs | 59 ++++++++++++++-- HotReload/HotReloadSelfTests.cs | 107 +++++++++++++++++++++++++++++ HotReload/LiveInstanceRefresher.cs | 14 +++- HotReload/ModFileWatcher.cs | 12 +++- 4 files changed, 181 insertions(+), 11 deletions(-) diff --git a/HotReload/HotReloadPipeline.cs b/HotReload/HotReloadPipeline.cs index 6ef5141..eabaaba 100644 --- a/HotReload/HotReloadPipeline.cs +++ b/HotReload/HotReloadPipeline.cs @@ -515,9 +515,18 @@ void CleanupStaged() continue; } + // Verify the type has a parameterless constructor before we try to create it. + // Types without one (abstract helpers, static classes) should be skipped, not crash. + if (newType.GetConstructor(Type.EmptyTypes) == null) + { + warnings.Add($"skip_{newType.Name}: no parameterless constructor"); + continue; + } + // Create a fresh instance — this triggers BaseLib constructor auto-registration // (CustomContentDictionary.AddModel → ModHelper.AddModelToPool) - var instance = Activator.CreateInstance(newType); + var instance = Activator.CreateInstance(newType) + ?? throw new InvalidOperationException($"Activator.CreateInstance returned null for {newType.FullName}"); if (instance is not AbstractModel model) throw new InvalidOperationException($"{newType.FullName} is not assignable to AbstractModel at runtime"); @@ -548,10 +557,13 @@ void CleanupStaged() } catch (Exception ex) { - // ── ROLLBACK: restore ModelDb to pre-reload state ──────── + // ── ROLLBACK: undo everything we changed ──────────────── + // Entity staging failed — we need to put ModelDb, pools, mod references, + // and BaseLib's internal tracking back to how they were before we started. errors.Add($"entity_reload: {ex.Message}"); BaseLibMain.Logger.Error($"[HotReload] Entity reload error, rolling back: {ex}"); + // 1. Restore ModelDb._contentById from our snapshot try { var contentByIdField = typeof(ModelDb).GetField("_contentById", StaticNonPublic); @@ -560,18 +572,40 @@ void CleanupStaged() } catch (Exception rbEx) { errors.Add($"rollback_entities: {rbEx.Message}"); } - // Restore Mod.assembly references to previous values + // 2. Restore Mod.assembly references to previous values foreach (var (modRef, field, prev) in previousModAssemblyRefs) { try { field.SetValue(modRef, prev); } catch (Exception rbEx) { warnings.Add($"rollback_mod_ref: {rbEx.Message}"); } } - // Re-invalidate type cache so it rebuilds without new assembly + // 3. Re-invalidate type cache so it rebuilds without the new assembly's types try { typeof(ReflectionHelper).GetField("_modTypes", StaticNonPublic)?.SetValue(null, null); } catch { /* best effort */ } + // 4. Restore serialization cache (undo step 5 entity ID registrations) serializationSnapshot?.Restore(); + + // 5. Undo the pool cleanup we did before entity creation — null ModelDb + // caches so pools re-enumerate from the restored _contentById on next access. + // This effectively rebuilds pool state from the restored entities. + try + { + string[] cacheFields = + [ + "_allCards", "_allCardPools", "_allCharacterCardPools", + "_allSharedEvents", "_allEvents", "_allEncounters", "_allPotions", + "_allPotionPools", "_allCharacterPotionPools", "_allSharedPotionPools", + "_allPowers", "_allRelics", "_allCharacterRelicPools", "_achievements" + ]; + foreach (var fieldName in cacheFields) + typeof(ModelDb).GetField(fieldName, StaticNonPublic)?.SetValue(null, null); + NullPoolInstanceCaches(typeof(CardPoolModel), "_allCards", "_allCardIds"); + NullPoolInstanceCaches(typeof(RelicPoolModel), "_relics", "_allRelicIds"); + NullPoolInstanceCaches(typeof(PotionPoolModel), "_allPotions", "_allPotionIds"); + } + catch (Exception rbEx) { warnings.Add($"rollback_pool_caches: {rbEx.Message}"); } + CleanupStaged(); return Finish(); } @@ -716,7 +750,15 @@ void CleanupStaged() } if (!alcCollectible) - warnings.Add("Old assembly loaded into default ALC (non-collectible); memory will accumulate"); + { + // Count how many old versions of this mod are sitting in the AppDomain. + // Each one is ~0.5-2 MB that can't be reclaimed until game restart. + var oldVersionCount = TypeSignatureHasher.GetAssembliesForMod(modKey, assembly).Count(); + if (oldVersionCount >= 10) + warnings.Add($"Memory warning: {oldVersionCount} old assemblies for {modKey} in memory. Consider restarting the game."); + else + warnings.Add("Old assembly loaded into default ALC (non-collectible); memory will accumulate"); + } stepTimings["step10_pck"] = sw.ElapsedMilliseconds - lastLap; lastLap = sw.ElapsedMilliseconds; @@ -878,7 +920,12 @@ private static void RemoveStalePatchesForMod(string modKey, Assembly? currentAss new Harmony(patch.owner).Unpatch(method, patch.PatchMethod); staleRemoved++; } - catch { /* best effort */ } + catch (Exception ex) + { + // Stale patch couldn't be removed — it'll stay active alongside the new one. + // This is usually harmless (old patch references dead types) but log it. + BaseLibMain.Logger.Warn($"[HotReload] Couldn't remove stale patch from {patchAsm.GetName().Name}: {ex.Message}"); + } } } if (staleRemoved > 0) actions.Add($"stale_patches_removed:{staleRemoved}"); diff --git a/HotReload/HotReloadSelfTests.cs b/HotReload/HotReloadSelfTests.cs index 4288e72..1e11f9b 100644 --- a/HotReload/HotReloadSelfTests.cs +++ b/HotReload/HotReloadSelfTests.cs @@ -1,6 +1,7 @@ using System.Collections; using System.Reflection; using System.Runtime.Loader; +using BaseLib.Patches.Content; using MegaCrit.Sts2.Core.Helpers; using MegaCrit.Sts2.Core.Localization; using MegaCrit.Sts2.Core.Modding; @@ -553,6 +554,112 @@ void AssertIntegration(bool condition, string testName) "Integration: GetAssembliesForMod exclude parameter works"); } + // ── Test: Reload with nonexistent DLL returns clean error ── + { + var badResult = HotReloadEngine.Reload("Z:/definitely/not/a/real/path.dll"); + AssertIntegration(!badResult.Success, "Integration: reload with bad path fails cleanly"); + AssertIntegration(badResult.Errors.Count > 0 && badResult.Errors[0].Contains("dll_not_found"), + "Integration: bad path gives dll_not_found error"); + } + + // ── Test: Reload with nonexistent mod ID returns clean error ── + { + var badResult = HotReloadEngine.ReloadByModId("ThisModDefinitelyDoesNotExist_12345"); + AssertIntegration(!badResult.Success, "Integration: reload with bad mod ID fails cleanly"); + AssertIntegration(badResult.Errors.Count > 0, + $"Integration: bad mod ID gives error: {(badResult.Errors.Count > 0 ? badResult.Errors[0] : "none")}"); + } + + // ── Test: InheritsFromByName works for actual game types ── + // This is what the pipeline uses to find AbstractModel subtypes + { + var contentByIdField = typeof(ModelDb).GetField("_contentById", StaticNonPublic); + if (contentByIdField?.GetValue(null) is Dictionary contentById && contentById.Count > 0) + { + // Pick any entity from ModelDb and verify InheritsFromByName agrees with IsSubclassOf + var firstEntity = contentById.Values.First(); + var entityType = firstEntity.GetType(); + bool byName = TypeSignatureHasher.InheritsFromByName(entityType, nameof(AbstractModel)); + bool byClr = entityType.IsSubclassOf(typeof(AbstractModel)) || entityType == typeof(AbstractModel); + AssertIntegration(byName == byClr, + $"Integration: InheritsFromByName matches IsSubclassOf for {entityType.Name} (byName={byName}, byCLR={byClr})"); + } + } + + // ── Test: TypeSignatureHash is stable across calls ── + // If this fails, incremental reload will spuriously re-inject unchanged entities + { + var contentByIdField = typeof(ModelDb).GetField("_contentById", StaticNonPublic); + if (contentByIdField?.GetValue(null) is Dictionary contentById && contentById.Count > 0) + { + var someType = contentById.Values.First().GetType(); + int hash1 = TypeSignatureHasher.ComputeHash(someType); + int hash2 = TypeSignatureHasher.ComputeHash(someType); + AssertIntegration(hash1 == hash2, + $"Integration: hash stable for live type {someType.Name} ({hash1} == {hash2})"); + } + } + + // ── Test: Assembly accumulation count is reasonable ── + { + int totalAssemblies = AppDomain.CurrentDomain.GetAssemblies().Length; + AssertIntegration(totalAssemblies < 200, + $"Integration: {totalAssemblies} loaded assemblies (under 200 = healthy)"); + } + + // ── Test: CustomContentDictionary.RemoveByAssembly doesn't crash on unknown assembly ── + { + try + { + // Pass an assembly that has no registered content — should be a no-op + CustomContentDictionary.RemoveByAssembly(typeof(HotReloadSelfTests).Assembly); + AssertIntegration(true, "Integration: RemoveByAssembly handles unknown assembly"); + } + catch (Exception ex) + { + AssertIntegration(false, $"Integration: RemoveByAssembly crashed on unknown assembly: {ex.Message}"); + } + } + + // ── Test: AssemblyStamper handles edge cases that could appear in the wild ── + { + // Mod names with dots (e.g., "My.Cool.Mod_hr123456789") + AssertIntegration(AssemblyStamper.NormalizeModKey("My.Cool.Mod_hr123456789") == "My.Cool.Mod", + "Integration: NormalizeModKey handles dots in mod name"); + + // Mod names with hyphens + AssertIntegration(AssemblyStamper.NormalizeModKey("my-mod_hr123456789") == "my-mod", + "Integration: NormalizeModKey handles hyphens"); + + // Path with spaces + AssertIntegration(AssemblyStamper.NormalizeModKey("E:/my mods/Cool Mod/Cool Mod_hr123456789.dll") == "Cool Mod", + "Integration: NormalizeModKey handles spaces in path"); + } + + // ── Test: Concurrent reload attempt returns clean error ── + // The lock in HotReloadEngine prevents concurrent reloads. We can't easily + // test true concurrency here, but we can verify the error message format. + { + // This test is more of a "does the code path exist" check — + // true concurrency testing would need threads. + AssertIntegration(HotReloadEngine.CurrentProgress == "", + "Integration: CurrentProgress is empty when idle"); + } + + // ── Test: HotReloadResult.Summary format ── + { + var lastResult = HotReloadEngine.ReloadHistory.Count > 0 + ? HotReloadEngine.ReloadHistory[^1] + : null; + if (lastResult != null) + { + AssertIntegration(!string.IsNullOrEmpty(lastResult.Summary), + "Integration: last reload result has a summary"); + AssertIntegration(!string.IsNullOrEmpty(lastResult.Timestamp), + "Integration: last reload result has a timestamp"); + } + } + var result = (_passed, _failed, failures); _passed = 0; _failed = 0; diff --git a/HotReload/LiveInstanceRefresher.cs b/HotReload/LiveInstanceRefresher.cs index 87ac1f3..cc9f92c 100644 --- a/HotReload/LiveInstanceRefresher.cs +++ b/HotReload/LiveInstanceRefresher.cs @@ -86,7 +86,12 @@ private static int WalkAndRefresh(Node root, Func refreshFunc) where if (refreshFunc(typed)) count++; } - catch { /* best effort — don't crash the whole refresh for one node */ } + catch (Exception ex) + { + // Don't crash the whole refresh for one broken node, but log it + // so the modder knows something went wrong + BaseLibMain.Logger.Warn($"[HotReload] Failed to refresh {typed.GetType().Name} node: {ex.Message}"); + } } foreach (var child in node.GetChildren()) queue.Enqueue(child); @@ -329,7 +334,12 @@ private static int RefreshModelList(IList? items, string assemblyKey, HashSet Date: Tue, 31 Mar 2026 16:10:15 -0400 Subject: [PATCH 3/4] Fix auto-conversion self-tests running before Harmony patches RunSelfTests() was called from NodeFactory.Init() which runs before harmony.PatchAll(). The 3 tests that call scene.Instantiate() need the SceneConversionPatch postfix to be in place. Move the call to BaseLibMain after PatchAll() so all 17 tests can pass. --- BaseLibMain.cs | 1 + Utils/NodeFactories/NodeFactory.cs | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/BaseLibMain.cs b/BaseLibMain.cs index d759da4..2bc54df 100644 --- a/BaseLibMain.cs +++ b/BaseLibMain.cs @@ -40,6 +40,7 @@ public static void Initialize() harmony.PatchAll(); + NodeFactory.RunSelfTests(); HotReloadEngine.Init(); } diff --git a/Utils/NodeFactories/NodeFactory.cs b/Utils/NodeFactories/NodeFactory.cs index 547403f..0d8a37c 100644 --- a/Utils/NodeFactories/NodeFactory.cs +++ b/Utils/NodeFactories/NodeFactory.cs @@ -217,8 +217,6 @@ public static void Init() new NCreatureVisualsFactory(); new NMerchantCharacterFactory(); new NEnergyCounterFactory(); - - RunSelfTests(); } /// @@ -358,7 +356,7 @@ internal static bool TryAutoConvert(PackedScene scene, ref Node? result) private static int _testsPassed; private static int _testsFailed; - private static void RunSelfTests() + internal static void RunSelfTests() { _testsPassed = 0; _testsFailed = 0; From 78d77f331137d6e3354bd3684675ae38e227225b Mon Sep 17 00:00:00 2001 From: elliotttate Date: Tue, 31 Mar 2026 16:13:44 -0400 Subject: [PATCH 4/4] Fix ModelLocPatch namespace after upstream moved it to Patches.Localization Upstream renamed Patches/Utils/ModelLocPatch.cs to Patches/Localization/ModelLocPatch.cs, changing its namespace. Add the new using to HotReloadPipeline.cs so it compiles. --- HotReload/HotReloadPipeline.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HotReload/HotReloadPipeline.cs b/HotReload/HotReloadPipeline.cs index eabaaba..66124dc 100644 --- a/HotReload/HotReloadPipeline.cs +++ b/HotReload/HotReloadPipeline.cs @@ -5,8 +5,8 @@ using BaseLib.Abstracts; using BaseLib.Patches; using BaseLib.Patches.Content; +using BaseLib.Patches.Localization; using BaseLib.Patches.Utils; -// NodeFactory scene cleanup will be added when the auto-conversion branch merges using Godot; using HarmonyLib; using MegaCrit.Sts2.Core.Helpers;