diff --git a/Abstracts/CustomCharacterModel.cs b/Abstracts/CustomCharacterModel.cs
index 42411e5..984e1d9 100644
--- a/Abstracts/CustomCharacterModel.cs
+++ b/Abstracts/CustomCharacterModel.cs
@@ -273,6 +273,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 176a199..0c1141d 100644
--- a/BaseLib.csproj
+++ b/BaseLib.csproj
@@ -97,6 +97,12 @@
+
+
+
+
+
+
@@ -112,10 +118,18 @@
-
+
+
+
+
+
diff --git a/BaseLibMain.cs b/BaseLibMain.cs
index 618c3b0..2bc54df 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;
@@ -38,6 +39,9 @@ public static void Initialize()
TheBigPatchToCardPileCmdAdd.Patch(harmony);
harmony.PatchAll();
+
+ NodeFactory.RunSelfTests();
+ 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 7fccc5f..bf94028 100644
--- a/Config/BaseLibConfig.cs
+++ b/Config/BaseLibConfig.cs
@@ -22,4 +22,7 @@ internal class BaseLibConfig : SimpleModConfig
[ConfigHideInUI] public static int LogLastSizeY { get; set; } = 0;
[ConfigHideInUI] public static int LogLastPosX { get; set; } = 0;
[ConfigHideInUI] public static int LogLastPosY { get; set; } = 0;
+
+ [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..66124dc
--- /dev/null
+++ b/HotReload/HotReloadPipeline.cs
@@ -0,0 +1,1051 @@
+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.Localization;
+using BaseLib.Patches.Utils;
+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;
+ }
+
+ // 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)
+ ?? 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");
+
+ 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: 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);
+ if (contentByIdField?.GetValue(null) is Dictionary rollbackDict)
+ RestoreEntitySnapshot(rollbackDict, entitySnapshot, modKey);
+ }
+ catch (Exception rbEx) { errors.Add($"rollback_entities: {rbEx.Message}"); }
+
+ // 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}"); }
+ }
+
+ // 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();
+ }
+ 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)
+ {
+ // 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;
+
+ // ═════════════════════════════════════════════════════════════
+ // 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 (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}");
+ }
+
+ ///
+ /// 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
protected abstract Node CreateFromNode(Node source);
+ //-- Self-tests: run once at init to verify the whole postfix → factory pipeline works --
+
+ private static int _testsPassed;
+ private static int _testsFailed;
+
+ internal 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 CreateFromNode again (it would still be Control either way,
+ // but IsInstanceOfType 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;
+ }
+
///
/// Information about an element (node) contained in a scene, used to determine if conversion is possible/how to convert.
///
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.