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().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..1e11f9b --- /dev/null +++ b/HotReload/HotReloadSelfTests.cs @@ -0,0 +1,668 @@ +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; +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"); + } + + // ── 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; + 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..cc9f92c --- /dev/null +++ b/HotReload/LiveInstanceRefresher.cs @@ -0,0 +1,437 @@ +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 (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); + } + + 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 (Exception ex) + { + // Leave the existing runtime instance in place — better than crashing mid-combat. + // But log it so the modder can investigate. + BaseLibMain.Logger.Warn($"[HotReload] Failed to refresh model instance: {ex.Message}"); + } + } + + 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..4087762 --- /dev/null +++ b/HotReload/ModFileWatcher.cs @@ -0,0 +1,205 @@ +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 and dispose the previous debounce timer, then start a new one. + // Without the dispose, each file event leaks a CTS — adds up fast during + // rapid rebuild cycles. + var oldCts = _debounceCts; + _debounceCts = new CancellationTokenSource(); + var token = _debounceCts.Token; + try { oldCts?.Cancel(); oldCts?.Dispose(); } catch { /* already disposed */ } + + try + { + // Wait for the build to finish writing + await Task.Delay(_debounceMs, token); + + // The file might have been deleted or renamed between debounce and now + if (!File.Exists(e.FullPath)) return; + + _lastTriggeredPath = e.FullPath; + _lastTriggeredTime = DateTime.UtcNow; + try { _lastKnownWriteTime = File.GetLastWriteTimeUtc(e.FullPath); } catch { } + 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 37ee917..15e6d52 100644 --- a/Patches/Content/ContentPatches.cs +++ b/Patches/Content/ContentPatches.cs @@ -13,9 +13,13 @@ namespace BaseLib.Patches.Content; public static class CustomContentDictionary { public static readonly HashSet RegisteredTypes = []; + private static readonly Dictionary CustomModelCounts = []; 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)); @@ -39,8 +43,20 @@ public static void AddModel(Type modelType) { throw new Exception($"Model {modelType.FullName} is assigned to incorrect type of pool {poolAttribute.PoolType.FullName}."); } - + + 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) @@ -49,8 +65,33 @@ public static void AddAncient(CustomAncientModel ancient) 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; @@ -139,6 +180,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)] @@ -158,6 +204,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)] @@ -177,6 +228,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 0a5d3c0..8d78a1a 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/Localization/ModelLocPatch.cs b/Patches/Localization/ModelLocPatch.cs index 439cb2d..a0e69d4 100644 --- a/Patches/Localization/ModelLocPatch.cs +++ b/Patches/Localization/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/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/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 843e702..0d8a37c 100644 --- a/Utils/NodeFactories/NodeFactory.cs +++ b/Utils/NodeFactories/NodeFactory.cs @@ -255,6 +255,24 @@ 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. /// @@ -300,7 +318,7 @@ internal static bool TryAutoConvert(PackedScene scene, ref Node? result) _convertingNodes ??= []; var converting = result; _convertingNodes.Add(converting); - + try { var sourceTypeName = result.GetType().Name; @@ -333,6 +351,254 @@ internal static bool TryAutoConvert(PackedScene scene, ref Node? result) /// 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.