From 89eaebdd94f6f4385c8cb85dfe3dd9a2f55599c5 Mon Sep 17 00:00:00 2001 From: JZ-Zhou-UofC Date: Mon, 22 Jun 2026 22:54:32 -0600 Subject: [PATCH 1/8] snapshot --- content/ModTemplate/ModTemplate.csproj | 4 +- content/ModTemplate/ModTemplate.json | 4 +- .../ModTemplate/ModTemplateCode/MainFile.cs | 13 +- .../Nodes/SnapshotInputNode.cs | 40 +++++ .../Patches/RunLifecyclePatch.cs | 48 ++++++ .../ModTemplateCode/Patches/SnapshotPatch.cs | 155 ++++++++++++++++++ .../ModTemplateCode/Snapshots/SnapshotData.cs | 21 +++ .../Snapshots/SnapshotManager.cs | 66 ++++++++ 8 files changed, 339 insertions(+), 12 deletions(-) create mode 100644 content/ModTemplate/ModTemplateCode/Nodes/SnapshotInputNode.cs create mode 100644 content/ModTemplate/ModTemplateCode/Patches/RunLifecyclePatch.cs create mode 100644 content/ModTemplate/ModTemplateCode/Patches/SnapshotPatch.cs create mode 100644 content/ModTemplate/ModTemplateCode/Snapshots/SnapshotData.cs create mode 100644 content/ModTemplate/ModTemplateCode/Snapshots/SnapshotManager.cs diff --git a/content/ModTemplate/ModTemplate.csproj b/content/ModTemplate/ModTemplate.csproj index f801eb4..c88fcbc 100644 --- a/content/ModTemplate/ModTemplate.csproj +++ b/content/ModTemplate/ModTemplate.csproj @@ -3,7 +3,7 @@ net9.0 true - {NullableChecks} + enable true $(MSBuildProjectDirectory)\ @@ -28,7 +28,7 @@ - + diff --git a/content/ModTemplate/ModTemplate.json b/content/ModTemplate/ModTemplate.json index e670dfc..cbe1ec0 100644 --- a/content/ModTemplate/ModTemplate.json +++ b/content/ModTemplate/ModTemplate.json @@ -1,4 +1,4 @@ -{ +{ "id": "ModTemplate", "name": "ModTemplate", "author": "{ModAuthor}", @@ -8,7 +8,7 @@ "has_pck": true, "has_dll": true, "dependencies": [ - {"id": "BaseLib", "min_version": "3.3.0"} + {"id": "BaseLib", "min_version": "3.3.1"} ], "affects_gameplay": true } diff --git a/content/ModTemplate/ModTemplateCode/MainFile.cs b/content/ModTemplate/ModTemplateCode/MainFile.cs index 571445c..0c4ae6a 100644 --- a/content/ModTemplate/ModTemplateCode/MainFile.cs +++ b/content/ModTemplate/ModTemplateCode/MainFile.cs @@ -4,21 +4,18 @@ namespace ModTemplate.ModTemplateCode; -//You're recommended but not required to keep all your code in this package and all your assets in the ModTemplate folder. [ModInitializer(nameof(Initialize))] public partial class MainFile : Node { - public const string ModId = "ModTemplate"; //At the moment, this is used only for the Logger and harmony names. + public const string ModId = "ModTemplate"; - public static MegaCrit.Sts2.Core.Logging.Logger Logger { get; } = new(ModId, MegaCrit.Sts2.Core.Logging.LogType.Generic); + public static MegaCrit.Sts2.Core.Logging.Logger Logger { get; } = + new(ModId, MegaCrit.Sts2.Core.Logging.LogType.Generic); public static void Initialize() { - //If you want to use scripts defined in your mod for Godot scenes, uncomment the following line. - //Godot.Bridge.ScriptManagerBridge.LookupScriptsInAssembly(Assembly.GetExecutingAssembly()); - Harmony harmony = new(ModId); - - harmony.PatchAll(); + try { harmony.PatchAll(); } + catch (Exception ex) { Logger.Info($"[Snapshot] PatchAll error: {ex.Message}"); } } } diff --git a/content/ModTemplate/ModTemplateCode/Nodes/SnapshotInputNode.cs b/content/ModTemplate/ModTemplateCode/Nodes/SnapshotInputNode.cs new file mode 100644 index 0000000..ce6e911 --- /dev/null +++ b/content/ModTemplate/ModTemplateCode/Nodes/SnapshotInputNode.cs @@ -0,0 +1,40 @@ +using Godot; +using ModTemplate.ModTemplateCode.Patches; + +namespace ModTemplate.ModTemplateCode.Nodes; + +// Persistent node added to the scene root. Polls F5 (save) and F9 (load latest). +// Requires a brief hold to avoid accidental triggers. +public partial class SnapshotInputNode : Node +{ + private const double HoldSeconds = 0.4; + + private double _saveHeld; + private double _loadHeld; + + public override void _Process(double delta) + { + Track(ref _saveHeld, delta, Key.F5, SnapshotPatch.SaveCurrent); + Track(ref _loadHeld, delta, Key.F9, SnapshotPatch.RestoreLatest); + } + + private static void Track(ref double held, double delta, Key key, Action action) + { + if (Input.IsKeyPressed(key)) + { + if (held >= 0) + { + held += delta; + if (held >= HoldSeconds) + { + held = double.MinValue; // suppress until key is released + action(); + } + } + } + else + { + held = 0; + } + } +} diff --git a/content/ModTemplate/ModTemplateCode/Patches/RunLifecyclePatch.cs b/content/ModTemplate/ModTemplateCode/Patches/RunLifecyclePatch.cs new file mode 100644 index 0000000..98b990e --- /dev/null +++ b/content/ModTemplate/ModTemplateCode/Patches/RunLifecyclePatch.cs @@ -0,0 +1,48 @@ +using HarmonyLib; +using MegaCrit.Sts2.Core.Nodes; +using ModTemplate.ModTemplateCode.Nodes; +using ModTemplate.ModTemplateCode.Snapshots; + +namespace ModTemplate.ModTemplateCode.Patches; + +// Run starts when a new singleplayer run is initiated. +[HarmonyPatch(typeof(NGame), nameof(NGame.StartNewSingleplayerRun))] +static class RunStartPatch +{ + [HarmonyPostfix] + static void Postfix() + { + try { SnapshotManager.OnRunStart(); } + catch (Exception ex) { MainFile.Logger.Info($"[Snapshot] RunStart error: {ex.Message}"); } + } +} + +// Run ends when the player returns to the main menu (covers both death and victory). +// LaunchMainMenu is internal in sts2.dll so we resolve it by name at runtime. +[HarmonyPatch("MegaCrit.Sts2.Core.Nodes.NGame", "LaunchMainMenu")] +static class RunEndPatch +{ + [HarmonyPrefix] + static void Prefix() + { + try { SnapshotManager.OnRunEnd(); } + catch (Exception ex) { MainFile.Logger.Info($"[Snapshot] RunEnd error: {ex.Message}"); } + } +} + +// Add our input-listening node as a child of NGame so it persists for the whole session. +[HarmonyPatch(typeof(NGame), "_Ready")] +static class NGameReadyPatch +{ + [HarmonyPostfix] + static void Postfix(NGame __instance) + { + try + { + if (!__instance.HasNode("SnapshotInputNode")) + __instance.AddChild(new SnapshotInputNode { Name = "SnapshotInputNode" }); + MainFile.Logger.Info("[Snapshot] Input node added to NGame (F5 = save, F9 = restore)."); + } + catch (Exception ex) { MainFile.Logger.Info($"[Snapshot] NGameReady error: {ex.Message}"); } + } +} diff --git a/content/ModTemplate/ModTemplateCode/Patches/SnapshotPatch.cs b/content/ModTemplate/ModTemplateCode/Patches/SnapshotPatch.cs new file mode 100644 index 0000000..cde89b4 --- /dev/null +++ b/content/ModTemplate/ModTemplateCode/Patches/SnapshotPatch.cs @@ -0,0 +1,155 @@ +using HarmonyLib; +using MegaCrit.Sts2.Core.Nodes; +using MegaCrit.Sts2.Core.Runs; +using MegaCrit.Sts2.Core.Entities.Players; +using ModTemplate.ModTemplateCode.Snapshots; + +namespace ModTemplate.ModTemplateCode.Patches; + +internal static class SnapshotPatch +{ + // Cached at NRun._Ready so we can read state on F5 without scene traversal. + private static RunState? _runState; + + // ── Cache RunState when a run scene loads ────────────────────────────────── + + [HarmonyPatch(typeof(NRun), "_Ready")] + static class NRunReadyPatch + { + [HarmonyPostfix] + static void Postfix(NRun __instance) + { + try + { + _runState = null; + // RunState is stored in a private field on NRun. + // We iterate all fields to find whichever one holds a RunState — + // this is resilient to field renames across game updates. + var t = Traverse.Create(__instance); + foreach (var fieldName in t.Fields()) + { + if (t.Field(fieldName).GetValue() is RunState rs) + { + _runState = rs; + MainFile.Logger.Info("[Snapshot] RunState cached from NRun."); + break; + } + } + if (_runState is null) + MainFile.Logger.Info("[Snapshot] Warning: RunState not found in NRun fields."); + } + catch (Exception ex) { MainFile.Logger.Info($"[Snapshot] NRunReady error: {ex.Message}"); } + } + } + + // ── Public entry points called from SnapshotInputNode ───────────────────── + + public static void SaveCurrent() + { + var snapshot = Capture(); + if (snapshot is null) + { + MainFile.Logger.Info("[Snapshot] Cannot save: run is not active or RunState is not cached."); + return; + } + SnapshotManager.Save(snapshot); + } + + public static void RestoreLatest() + { + var snapshot = SnapshotManager.LoadLatest(); + if (snapshot is null) + { + MainFile.Logger.Info("[Snapshot] No snapshot to restore."); + return; + } + Restore(snapshot); + } + + // ── Capture ──────────────────────────────────────────────────────────────── + + private static RunSnapshot? Capture() + { + if (_runState is null) return null; + var player = GetSoloPlayer(); + if (player is null) return null; + + var pt = Traverse.Create(player); + var snapshot = new RunSnapshot + { + Floor = _runState.TotalFloor, + // TODO: update private field names below after decompiling sts2.dll. + // Common patterns tried first; adjust if Traverse returns defaults (0). + CurrentHp = pt.Field("_currentHp").GetValue(), + MaxHp = pt.Field("_maxHp").GetValue(), + Gold = pt.Field("_gold").GetValue(), + }; + + // Deck — cards are stored in a private CardPile field on the player. + // TODO: update "_masterDeck" and "_group" to match actual field names. + var deckItems = pt.Field("_masterDeck").Field("_group").GetValue() + ?? pt.Field("masterDeck").Field("group").GetValue(); + if (deckItems is not null) + { + foreach (var card in deckItems) + { + var ct = Traverse.Create(card); + snapshot.Deck.Add(new CardData + { + ModelId = ct.Property("ModelId").GetValue() ?? "", + UpgradeCount = ct.Field("_timesUpgraded").GetValue(), + }); + } + } + + // Relics — stored in a private list on the player. + // TODO: update "_relics" to match the actual field name. + var relicList = pt.Field("_relics").GetValue() + ?? pt.Field("relics").GetValue(); + if (relicList is not null) + { + foreach (var relic in relicList) + { + var id = Traverse.Create(relic).Property("ModelId").GetValue() ?? ""; + if (id.Length > 0) snapshot.RelicIds.Add(id); + } + } + + return snapshot; + } + + // ── Restore ──────────────────────────────────────────────────────────────── + + private static void Restore(RunSnapshot snapshot) + { + if (_runState is null) + { + MainFile.Logger.Info("[Snapshot] Cannot restore: run is not active."); + return; + } + var player = GetSoloPlayer(); + if (player is null) return; + + // Player does not publicly expose Creature inheritance without publicization, + // so we invoke the game's command methods via reflection. + AccessTools.Method("MegaCrit.Sts2.Core.Commands.CreatureCmd:SetMaxAndCurrentHp") + ?.Invoke(null, [player, snapshot.CurrentHp]); + AccessTools.Method("MegaCrit.Sts2.Core.Commands.PlayerCmd:SetGold") + ?.Invoke(null, [snapshot.Gold, player]); + + // TODO: restore deck and relics. + // Cards: iterate deck, call RunState.RemoveCard() on each, then RunState.AddCard() + // using ModelDb to look up CardModel by snapshot.Deck[i].ModelId. + // Relics: similar pattern via RunState or PlayerCmd. + + MainFile.Logger.Info( + $"[Snapshot] Restored HP {snapshot.CurrentHp}/{snapshot.MaxHp}, " + + $"Gold {snapshot.Gold} (Floor {snapshot.Floor}). " + + "Deck/relic restore pending — see SnapshotPatch.Restore()."); + } + + // ── Helpers ──────────────────────────────────────────────────────────────── + + private static Player? GetSoloPlayer() + => _runState?.Players.FirstOrDefault(); +} diff --git a/content/ModTemplate/ModTemplateCode/Snapshots/SnapshotData.cs b/content/ModTemplate/ModTemplateCode/Snapshots/SnapshotData.cs new file mode 100644 index 0000000..0510321 --- /dev/null +++ b/content/ModTemplate/ModTemplateCode/Snapshots/SnapshotData.cs @@ -0,0 +1,21 @@ +namespace ModTemplate.ModTemplateCode.Snapshots; + +public class CardData +{ + public string ModelId { get; set; } = ""; + public int UpgradeCount { get; set; } +} + +public class RunSnapshot +{ + public string SnapshotId { get; set; } = Guid.NewGuid().ToString("N")[..8]; + public string RunId { get; set; } = ""; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public int Floor { get; set; } + public decimal CurrentHp { get; set; } // game uses decimal + public decimal MaxHp { get; set; } + public decimal Gold { get; set; } + public List Deck { get; set; } = []; + public List RelicIds { get; set; } = []; + public List PotionIds { get; set; } = []; +} diff --git a/content/ModTemplate/ModTemplateCode/Snapshots/SnapshotManager.cs b/content/ModTemplate/ModTemplateCode/Snapshots/SnapshotManager.cs new file mode 100644 index 0000000..120b8e1 --- /dev/null +++ b/content/ModTemplate/ModTemplateCode/Snapshots/SnapshotManager.cs @@ -0,0 +1,66 @@ +using System.Text.Json; +using Godot; + +namespace ModTemplate.ModTemplateCode.Snapshots; + +public static class SnapshotManager +{ + private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = true }; + + private static string? _runId; + + private static string SnapshotRoot => + Path.Combine(OS.GetUserDataDir(), "mod_snapshots"); + + private static string RunDir => + Path.Combine(SnapshotRoot, _runId ?? "active"); + + public static void OnRunStart() + { + _runId = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss") + "_" + Guid.NewGuid().ToString("N")[..6]; + Directory.CreateDirectory(RunDir); + MainFile.Logger.Info($"[Snapshot] Run session started: {_runId}"); + } + + public static void Save(RunSnapshot snapshot) + { + // Start a session automatically if the run-start hook didn't fire. + if (_runId is null) OnRunStart(); + + snapshot.RunId = _runId!; + Directory.CreateDirectory(RunDir); + string path = Path.Combine(RunDir, $"{snapshot.SnapshotId}.json"); + File.WriteAllText(path, JsonSerializer.Serialize(snapshot, JsonOpts)); + MainFile.Logger.Info( + $"[Snapshot] Saved [{snapshot.SnapshotId}] Floor {snapshot.Floor} " + + $"HP {snapshot.CurrentHp}/{snapshot.MaxHp} Gold {snapshot.Gold}"); + } + + public static List LoadAll() + { + if (!Directory.Exists(RunDir)) return []; + return [.. Directory + .GetFiles(RunDir, "*.json") + .Select(f => + { + try { return JsonSerializer.Deserialize(File.ReadAllText(f)); } + catch { return null; } + }) + .OfType() + .OrderByDescending(s => s.CreatedAt)]; + } + + public static RunSnapshot? LoadLatest() => LoadAll().FirstOrDefault(); + + public static void OnRunEnd() + { + if (!Directory.Exists(RunDir)) + { + _runId = null; + return; + } + Directory.Delete(RunDir, recursive: true); + MainFile.Logger.Info($"[Snapshot] Deleted all snapshots for run {_runId}"); + _runId = null; + } +} From 2de2f8ff616c27e91602582ef2c56b01d8990e89 Mon Sep 17 00:00:00 2001 From: JZ-Zhou-UofC Date: Tue, 23 Jun 2026 16:33:53 -0600 Subject: [PATCH 2/8] ui created --- content/ModTemplate/ModTemplate.json | 4 +- .../ModTemplate/ModTemplateCode/MainFile.cs | 2 +- .../Nodes/SnapshotInputNode.cs | 40 ---- .../ModTemplateCode/Nodes/SnapshotUiNode.cs | 203 ++++++++++++++++++ .../Patches/RunLifecyclePatch.cs | 38 ++-- .../ModTemplateCode/Patches/SnapshotPatch.cs | 177 ++++++--------- .../ModTemplateCode/Snapshots/SnapshotData.cs | 20 +- .../Snapshots/SnapshotManager.cs | 129 ++++++++--- 8 files changed, 399 insertions(+), 214 deletions(-) delete mode 100644 content/ModTemplate/ModTemplateCode/Nodes/SnapshotInputNode.cs create mode 100644 content/ModTemplate/ModTemplateCode/Nodes/SnapshotUiNode.cs diff --git a/content/ModTemplate/ModTemplate.json b/content/ModTemplate/ModTemplate.json index cbe1ec0..9b0406e 100644 --- a/content/ModTemplate/ModTemplate.json +++ b/content/ModTemplate/ModTemplate.json @@ -5,10 +5,10 @@ "description": "Slay the Spire 2 mod created from a template for use with BaseLib", "version": "v0.0.0", "min_game_version": "0.107.0", - "has_pck": true, + "has_pck": false, "has_dll": true, "dependencies": [ - {"id": "BaseLib", "min_version": "3.3.1"} + {"id": "BaseLib", "min_version": "3.3.2"} ], "affects_gameplay": true } diff --git a/content/ModTemplate/ModTemplateCode/MainFile.cs b/content/ModTemplate/ModTemplateCode/MainFile.cs index 0c4ae6a..a043d0f 100644 --- a/content/ModTemplate/ModTemplateCode/MainFile.cs +++ b/content/ModTemplate/ModTemplateCode/MainFile.cs @@ -7,7 +7,7 @@ namespace ModTemplate.ModTemplateCode; [ModInitializer(nameof(Initialize))] public partial class MainFile : Node { - public const string ModId = "ModTemplate"; + public const string ModId = "SnapShot"; public static MegaCrit.Sts2.Core.Logging.Logger Logger { get; } = new(ModId, MegaCrit.Sts2.Core.Logging.LogType.Generic); diff --git a/content/ModTemplate/ModTemplateCode/Nodes/SnapshotInputNode.cs b/content/ModTemplate/ModTemplateCode/Nodes/SnapshotInputNode.cs deleted file mode 100644 index ce6e911..0000000 --- a/content/ModTemplate/ModTemplateCode/Nodes/SnapshotInputNode.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Godot; -using ModTemplate.ModTemplateCode.Patches; - -namespace ModTemplate.ModTemplateCode.Nodes; - -// Persistent node added to the scene root. Polls F5 (save) and F9 (load latest). -// Requires a brief hold to avoid accidental triggers. -public partial class SnapshotInputNode : Node -{ - private const double HoldSeconds = 0.4; - - private double _saveHeld; - private double _loadHeld; - - public override void _Process(double delta) - { - Track(ref _saveHeld, delta, Key.F5, SnapshotPatch.SaveCurrent); - Track(ref _loadHeld, delta, Key.F9, SnapshotPatch.RestoreLatest); - } - - private static void Track(ref double held, double delta, Key key, Action action) - { - if (Input.IsKeyPressed(key)) - { - if (held >= 0) - { - held += delta; - if (held >= HoldSeconds) - { - held = double.MinValue; // suppress until key is released - action(); - } - } - } - else - { - held = 0; - } - } -} diff --git a/content/ModTemplate/ModTemplateCode/Nodes/SnapshotUiNode.cs b/content/ModTemplate/ModTemplateCode/Nodes/SnapshotUiNode.cs new file mode 100644 index 0000000..986ef42 --- /dev/null +++ b/content/ModTemplate/ModTemplateCode/Nodes/SnapshotUiNode.cs @@ -0,0 +1,203 @@ +using Godot; +using ModTemplate.ModTemplateCode.Snapshots; + +namespace ModTemplate.ModTemplateCode.Nodes; + +// Static UI manager — deliberately NOT a Node subclass. +// Custom partial Node classes crash in mod context because Harmony's MonoMod +// JIT hook fires when Godot tries to JIT-compile InvokeGodotClassMethod and +// throws ArgumentException. Using a static class with built-in Godot nodes +// (CanvasLayer, Label, etc.) avoids this entirely. +// Input polling and HUD updates are driven by a Harmony patch on NRun._Process. +internal static class SnapshotUi +{ + private static Label? _hudLabel; + private static Control? _panel; + private static VBoxContainer? _list; + + private static double _lHeld; + private const double ToggleHold = 0.3; + + // Called from NRunReadyPatch — builds the UI once per run scene. + public static void Initialize(Node sceneRoot) + { + if (sceneRoot.HasNode("SnapshotUiLayer")) return; + + var layer = new CanvasLayer { Layer = 128, Name = "SnapshotUiLayer" }; + sceneRoot.AddChild(layer); + BuildLayout(layer); + MainFile.Logger.Info("[Snapshot] SnapshotUi initialized."); + } + + // Called from RunEndPatch — clears stale references when the run ends. + public static void Teardown() + { + _hudLabel = null; + _panel = null; + _list = null; + _lHeld = 0; + } + + // Called every frame from NRunProcessPatch. + public static void Update(double delta) + { + if (_hudLabel == null) return; + + _hudLabel.Text = SnapshotManager.SnapshotCount > 0 + ? $"[Snapshots: {SnapshotManager.SnapshotCount}] hold L" + : "[Snapshot Mod] hold L"; + + if (Input.IsKeyPressed(Key.L)) + { + if (_lHeld >= 0) + { + _lHeld += delta; + if (_lHeld >= ToggleHold) + { + _lHeld = double.MinValue; + TogglePanel(); + } + } + } + else + { + _lHeld = 0; + } + + if (_panel?.Visible == true && Input.IsKeyPressed(Key.Escape)) + _panel.Visible = false; + } + + // ── Layout ──────────────────────────────────────────────────────────────── + + private static void BuildLayout(CanvasLayer layer) + { + var root = new Control(); + root.SetAnchorsPreset(Control.LayoutPreset.FullRect); + root.MouseFilter = Control.MouseFilterEnum.Ignore; + layer.AddChild(root); + + _hudLabel = new Label + { + HorizontalAlignment = HorizontalAlignment.Right, + MouseFilter = Control.MouseFilterEnum.Ignore, + }; + _hudLabel.AnchorLeft = 1f; + _hudLabel.AnchorRight = 1f; + _hudLabel.AnchorTop = 0f; + _hudLabel.AnchorBottom = 0f; + _hudLabel.OffsetLeft = -260f; + _hudLabel.OffsetRight = -8f; + _hudLabel.OffsetTop = 8f; + _hudLabel.OffsetBottom = 32f; + root.AddChild(_hudLabel); + + _panel = new Control(); + _panel.SetAnchorsPreset(Control.LayoutPreset.FullRect); + _panel.MouseFilter = Control.MouseFilterEnum.Stop; + _panel.Visible = false; + root.AddChild(_panel); + + var overlay = new ColorRect { Color = new Color(0f, 0f, 0f, 0.7f) }; + overlay.SetAnchorsPreset(Control.LayoutPreset.FullRect); + overlay.GuiInput += e => + { + if (e is InputEventMouseButton { Pressed: true }) _panel.Visible = false; + }; + _panel.AddChild(overlay); + + var bg = new Panel(); + bg.AnchorLeft = 0.10f; + bg.AnchorTop = 0.05f; + bg.AnchorRight = 0.90f; + bg.AnchorBottom = 0.95f; + _panel.AddChild(bg); + + var margin = new MarginContainer(); + margin.SetAnchorsPreset(Control.LayoutPreset.FullRect); + margin.AddThemeConstantOverride("margin_left", 16); + margin.AddThemeConstantOverride("margin_top", 16); + margin.AddThemeConstantOverride("margin_right", 16); + margin.AddThemeConstantOverride("margin_bottom", 16); + bg.AddChild(margin); + + var outer = new VBoxContainer(); + margin.AddChild(outer); + + var header = new HBoxContainer(); + outer.AddChild(header); + header.AddChild(new Label + { + Text = "Snapshot History (hold L or Esc to close)", + SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, + }); + var closeBtn = new Button { Text = "X" }; + closeBtn.Pressed += () => _panel.Visible = false; + header.AddChild(closeBtn); + + outer.AddChild(new HSeparator()); + + var cols = new HBoxContainer(); + outer.AddChild(cols); + cols.AddChild(ColLabel("Floor", 80)); + cols.AddChild(ColLabel("Saved at", 180)); + cols.AddChild(new Control { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }); + outer.AddChild(new HSeparator()); + + var scroll = new ScrollContainer(); + scroll.SizeFlagsVertical = Control.SizeFlags.ExpandFill; + outer.AddChild(scroll); + + _list = new VBoxContainer(); + _list.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + scroll.AddChild(_list); + } + + private static Label ColLabel(string text, int minWidth) => new() + { + Text = text, + CustomMinimumSize = new Vector2(minWidth, 0), + }; + + // ── Panel ───────────────────────────────────────────────────────────────── + + private static void TogglePanel() + { + if (_panel == null) return; + if (_panel.Visible) { _panel.Visible = false; return; } + Refresh(); + _panel.Visible = true; + } + + private static void Refresh() + { + if (_list == null) return; + foreach (var child in _list.GetChildren()) child.QueueFree(); + + var snapshots = SnapshotManager.LoadAll().OrderByDescending(s => s.Floor).ToList(); + + if (snapshots.Count == 0) + { + _list.AddChild(new Label { Text = "No snapshots yet." }); + return; + } + + foreach (var snap in snapshots) + { + var row = new HBoxContainer(); + _list.AddChild(row); + row.AddChild(new Label { Text = $"Floor {snap.Floor,2}", CustomMinimumSize = new Vector2(80, 0) }); + row.AddChild(new Label { Text = snap.SavedAt.ToLocalTime().ToString("HH:mm:ss"), CustomMinimumSize = new Vector2(180, 0) }); + row.AddChild(new Control { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }); + + var btn = new Button { Text = "Restore" }; + var captured = snap; + btn.Pressed += () => + { + SnapshotManager.Restore(captured); + if (_panel != null) _panel.Visible = false; + }; + row.AddChild(btn); + } + } +} diff --git a/content/ModTemplate/ModTemplateCode/Patches/RunLifecyclePatch.cs b/content/ModTemplate/ModTemplateCode/Patches/RunLifecyclePatch.cs index 98b990e..2269d2f 100644 --- a/content/ModTemplate/ModTemplateCode/Patches/RunLifecyclePatch.cs +++ b/content/ModTemplate/ModTemplateCode/Patches/RunLifecyclePatch.cs @@ -1,5 +1,10 @@ +using Godot; using HarmonyLib; +using MegaCrit.Sts2.Core.Hooks; using MegaCrit.Sts2.Core.Nodes; +using MegaCrit.Sts2.Core.Nodes.Screens.MainMenu; +using MegaCrit.Sts2.Core.Rooms; +using MegaCrit.Sts2.Core.Runs; using ModTemplate.ModTemplateCode.Nodes; using ModTemplate.ModTemplateCode.Snapshots; @@ -25,24 +30,33 @@ static class RunEndPatch [HarmonyPrefix] static void Prefix() { - try { SnapshotManager.OnRunEnd(); } + try { SnapshotManager.OnRunEnd(); SnapshotUi.Teardown(); } catch (Exception ex) { MainFile.Logger.Info($"[Snapshot] RunEnd error: {ex.Message}"); } } } -// Add our input-listening node as a child of NGame so it persists for the whole session. -[HarmonyPatch(typeof(NGame), "_Ready")] -static class NGameReadyPatch +// Auto-save a snapshot at the start of every floor. +// Hook.BeforeRoomEntered is the game's canonical hook point fired before any room logic runs. +[HarmonyPatch(typeof(Hook), nameof(Hook.BeforeRoomEntered))] +static class FloorSnapshotPatch { [HarmonyPostfix] - static void Postfix(NGame __instance) + static void Postfix(IRunState runState, AbstractRoom room) { - try - { - if (!__instance.HasNode("SnapshotInputNode")) - __instance.AddChild(new SnapshotInputNode { Name = "SnapshotInputNode" }); - MainFile.Logger.Info("[Snapshot] Input node added to NGame (F5 = save, F9 = restore)."); - } - catch (Exception ex) { MainFile.Logger.Info($"[Snapshot] NGameReady error: {ex.Message}"); } + try { SnapshotPatch.SaveSnapshot(runState.TotalFloor); } + catch (Exception ex) { MainFile.Logger.Info($"[Snapshot] FloorSnapshot error: {ex.Message}"); } + } +} + +// Auto-press Continue when the main menu loads after a restore so the +// player lands directly back in the restored run without any manual click. +[HarmonyPatch(typeof(NMainMenuContinueButton), "_Ready")] +static class AutoContinuePatch +{ + [HarmonyPostfix] + static void Postfix(NMainMenuContinueButton __instance) + { + if (!SnapshotManager.IsRestoring) return; + Callable.From(() => __instance.EmitSignal(Button.SignalName.Pressed)).CallDeferred(); } } diff --git a/content/ModTemplate/ModTemplateCode/Patches/SnapshotPatch.cs b/content/ModTemplate/ModTemplateCode/Patches/SnapshotPatch.cs index cde89b4..6290740 100644 --- a/content/ModTemplate/ModTemplateCode/Patches/SnapshotPatch.cs +++ b/content/ModTemplate/ModTemplateCode/Patches/SnapshotPatch.cs @@ -1,17 +1,14 @@ +using Godot; using HarmonyLib; using MegaCrit.Sts2.Core.Nodes; -using MegaCrit.Sts2.Core.Runs; -using MegaCrit.Sts2.Core.Entities.Players; +using ModTemplate.ModTemplateCode.Nodes; using ModTemplate.ModTemplateCode.Snapshots; namespace ModTemplate.ModTemplateCode.Patches; internal static class SnapshotPatch { - // Cached at NRun._Ready so we can read state on F5 without scene traversal. - private static RunState? _runState; - - // ── Cache RunState when a run scene loads ────────────────────────────────── + // ── Add UI when a run scene loads ───────────────────────────────────────── [HarmonyPatch(typeof(NRun), "_Ready")] static class NRunReadyPatch @@ -21,135 +18,89 @@ static void Postfix(NRun __instance) { try { - _runState = null; - // RunState is stored in a private field on NRun. - // We iterate all fields to find whichever one holds a RunState — - // this is resilient to field renames across game updates. - var t = Traverse.Create(__instance); - foreach (var fieldName in t.Fields()) + var sceneRoot = __instance.GetTree()?.Root; + if (sceneRoot != null) + SnapshotUi.Initialize(sceneRoot); + + // Wire up the delegate that lets Restore() trigger the scene transition. + var nGame = FindInTree(__instance.GetTree().Root); + if (nGame != null) { - if (t.Field(fieldName).GetValue() is RunState rs) + // Log both candidate methods so we know their exact signatures. + LogMethodSig(typeof(NGame), "LaunchMainMenu"); + LogMethodSig(typeof(NGame), "LoadRun"); + + // Wire up scene transition for Restore(). + // Currently uses LaunchMainMenu → AutoContinuePatch path. + // Once we see LoadRun's signature in the log we can switch to + // calling it directly and skip the main menu entirely. + var launchMethod = AccessTools.Method(typeof(NGame), "LaunchMainMenu"); + if (launchMethod != null) { - _runState = rs; - MainFile.Logger.Info("[Snapshot] RunState cached from NRun."); - break; + var args = launchMethod.GetParameters().Select(p => + p.HasDefaultValue ? p.DefaultValue : + p.ParameterType.IsValueType ? Activator.CreateInstance(p.ParameterType) + : (object?)null + ).ToArray(); + SnapshotManager.LaunchMainMenuAction = () => launchMethod.Invoke(nGame, args); } + else + MainFile.Logger.Info("[Snapshot] NRunReady: LaunchMainMenu method not found."); } - if (_runState is null) - MainFile.Logger.Info("[Snapshot] Warning: RunState not found in NRun fields."); - } - catch (Exception ex) { MainFile.Logger.Info($"[Snapshot] NRunReady error: {ex.Message}"); } - } - } - - // ── Public entry points called from SnapshotInputNode ───────────────────── + else + MainFile.Logger.Info("[Snapshot] NRunReady: NGame not found in tree — restore will not work."); - public static void SaveCurrent() - { - var snapshot = Capture(); - if (snapshot is null) - { - MainFile.Logger.Info("[Snapshot] Cannot save: run is not active or RunState is not cached."); - return; + // Clear the flag after the restored run has fully loaded so the + // next run-end will delete snapshots normally. + SnapshotManager.IsRestoring = false; + } + catch (Exception ex) { MainFile.Logger.Info($"[Snapshot] NRunReady error: {ex}"); } } - SnapshotManager.Save(snapshot); - } - public static void RestoreLatest() - { - var snapshot = SnapshotManager.LoadLatest(); - if (snapshot is null) + static void LogMethodSig(Type type, string name) { - MainFile.Logger.Info("[Snapshot] No snapshot to restore."); - return; + var m = AccessTools.Method(type, name); + if (m == null) { MainFile.Logger.Info($"[Snapshot] {name}: not found"); return; } + var p = string.Join(", ", m.GetParameters().Select(x => $"{x.ParameterType.Name} {x.Name}")); + MainFile.Logger.Info($"[Snapshot] {name}({p}) -> {m.ReturnType.Name}"); } - Restore(snapshot); - } - // ── Capture ──────────────────────────────────────────────────────────────── - - private static RunSnapshot? Capture() - { - if (_runState is null) return null; - var player = GetSoloPlayer(); - if (player is null) return null; - - var pt = Traverse.Create(player); - var snapshot = new RunSnapshot + static T? FindInTree(Node node) where T : Node { - Floor = _runState.TotalFloor, - // TODO: update private field names below after decompiling sts2.dll. - // Common patterns tried first; adjust if Traverse returns defaults (0). - CurrentHp = pt.Field("_currentHp").GetValue(), - MaxHp = pt.Field("_maxHp").GetValue(), - Gold = pt.Field("_gold").GetValue(), - }; - - // Deck — cards are stored in a private CardPile field on the player. - // TODO: update "_masterDeck" and "_group" to match actual field names. - var deckItems = pt.Field("_masterDeck").Field("_group").GetValue() - ?? pt.Field("masterDeck").Field("group").GetValue(); - if (deckItems is not null) - { - foreach (var card in deckItems) - { - var ct = Traverse.Create(card); - snapshot.Deck.Add(new CardData - { - ModelId = ct.Property("ModelId").GetValue() ?? "", - UpgradeCount = ct.Field("_timesUpgraded").GetValue(), - }); - } - } - - // Relics — stored in a private list on the player. - // TODO: update "_relics" to match the actual field name. - var relicList = pt.Field("_relics").GetValue() - ?? pt.Field("relics").GetValue(); - if (relicList is not null) - { - foreach (var relic in relicList) + if (node is T t) return t; + foreach (var child in node.GetChildren()) { - var id = Traverse.Create(relic).Property("ModelId").GetValue() ?? ""; - if (id.Length > 0) snapshot.RelicIds.Add(id); + var found = FindInTree(child); + if (found != null) return found; } + return null; } - - return snapshot; } - // ── Restore ──────────────────────────────────────────────────────────────── + // ── Drive UI every frame ────────────────────────────────────────────────── - private static void Restore(RunSnapshot snapshot) + [HarmonyPatch(typeof(NRun), "_Process")] + static class NRunProcessPatch { - if (_runState is null) + [HarmonyPostfix] + static void Postfix(double delta) { - MainFile.Logger.Info("[Snapshot] Cannot restore: run is not active."); - return; + try { SnapshotUi.Update(delta); } + catch (Exception ex) { MainFile.Logger.Info($"[Snapshot] UI error: {ex.Message}"); } } - var player = GetSoloPlayer(); - if (player is null) return; - - // Player does not publicly expose Creature inheritance without publicization, - // so we invoke the game's command methods via reflection. - AccessTools.Method("MegaCrit.Sts2.Core.Commands.CreatureCmd:SetMaxAndCurrentHp") - ?.Invoke(null, [player, snapshot.CurrentHp]); - AccessTools.Method("MegaCrit.Sts2.Core.Commands.PlayerCmd:SetGold") - ?.Invoke(null, [snapshot.Gold, player]); + } - // TODO: restore deck and relics. - // Cards: iterate deck, call RunState.RemoveCard() on each, then RunState.AddCard() - // using ModelDb to look up CardModel by snapshot.Deck[i].ModelId. - // Relics: similar pattern via RunState or PlayerCmd. + // ── Public surface used by FloorSnapshotPatch and SnapshotUi ───────────── - MainFile.Logger.Info( - $"[Snapshot] Restored HP {snapshot.CurrentHp}/{snapshot.MaxHp}, " + - $"Gold {snapshot.Gold} (Floor {snapshot.Floor}). " + - "Deck/relic restore pending — see SnapshotPatch.Restore()."); + public static void SaveSnapshot(int floor) + { + try { SnapshotManager.Save(floor); } + catch (Exception ex) { MainFile.Logger.Info($"[Snapshot] Save error: {ex}"); } } - // ── Helpers ──────────────────────────────────────────────────────────────── - - private static Player? GetSoloPlayer() - => _runState?.Players.FirstOrDefault(); + public static void RestoreSnapshot(RunSnapshot snapshot) + { + try { SnapshotManager.Restore(snapshot); } + catch (Exception ex) { MainFile.Logger.Info($"[Snapshot] Restore error: {ex}"); } + } } diff --git a/content/ModTemplate/ModTemplateCode/Snapshots/SnapshotData.cs b/content/ModTemplate/ModTemplateCode/Snapshots/SnapshotData.cs index 0510321..0bdfe1b 100644 --- a/content/ModTemplate/ModTemplateCode/Snapshots/SnapshotData.cs +++ b/content/ModTemplate/ModTemplateCode/Snapshots/SnapshotData.cs @@ -1,21 +1,9 @@ namespace ModTemplate.ModTemplateCode.Snapshots; -public class CardData -{ - public string ModelId { get; set; } = ""; - public int UpgradeCount { get; set; } -} - +// Lightweight descriptor — actual state lives in the copied save files on disk. public class RunSnapshot { - public string SnapshotId { get; set; } = Guid.NewGuid().ToString("N")[..8]; - public string RunId { get; set; } = ""; - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - public int Floor { get; set; } - public decimal CurrentHp { get; set; } // game uses decimal - public decimal MaxHp { get; set; } - public decimal Gold { get; set; } - public List Deck { get; set; } = []; - public List RelicIds { get; set; } = []; - public List PotionIds { get; set; } = []; + public int Floor { get; set; } + public DateTime SavedAt { get; set; } = DateTime.UtcNow; + public string Dir { get; set; } = ""; // absolute path to the snapshot folder } diff --git a/content/ModTemplate/ModTemplateCode/Snapshots/SnapshotManager.cs b/content/ModTemplate/ModTemplateCode/Snapshots/SnapshotManager.cs index 120b8e1..ab48fb0 100644 --- a/content/ModTemplate/ModTemplateCode/Snapshots/SnapshotManager.cs +++ b/content/ModTemplate/ModTemplateCode/Snapshots/SnapshotManager.cs @@ -1,66 +1,135 @@ -using System.Text.Json; using Godot; namespace ModTemplate.ModTemplateCode.Snapshots; public static class SnapshotManager { - private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = true }; + public static int SnapshotCount { get; private set; } + public static bool IsRestoring { get; set; } + // Set by NRunReadyPatch so Restore can trigger a scene transition + // without SnapshotManager needing to know about NGame directly. + public static Action? LaunchMainMenuAction { get; set; } private static string? _runId; + private static string? _gameSaveDir; - private static string SnapshotRoot => - Path.Combine(OS.GetUserDataDir(), "mod_snapshots"); + private static string SnapshotRoot => Path.Combine(OS.GetUserDataDir(), "mod_snapshots"); + private static string RunDir => Path.Combine(SnapshotRoot, _runId ?? "active"); - private static string RunDir => - Path.Combine(SnapshotRoot, _runId ?? "active"); + // ── Run lifecycle ───────────────────────────────────────────────────────── public static void OnRunStart() { - _runId = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss") + "_" + Guid.NewGuid().ToString("N")[..6]; + _runId = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss") + "_" + Guid.NewGuid().ToString("N")[..6]; + _gameSaveDir = FindGameSaveDir(); + SnapshotCount = 0; + IsRestoring = false; Directory.CreateDirectory(RunDir); - MainFile.Logger.Info($"[Snapshot] Run session started: {_runId}"); + MainFile.Logger.Info($"[Snapshot] Run started: {_runId} | saves: {_gameSaveDir ?? "NOT FOUND"}"); } - public static void Save(RunSnapshot snapshot) + public static void OnRunEnd() { - // Start a session automatically if the run-start hook didn't fire. + if (IsRestoring) + { + // Player triggered a restore — keep snapshots so they survive the + // trip through the main menu back into the restored run. + MainFile.Logger.Info("[Snapshot] RunEnd skipped deletion (restore in progress)."); + return; + } + if (Directory.Exists(RunDir)) + { + Directory.Delete(RunDir, recursive: true); + MainFile.Logger.Info($"[Snapshot] Deleted snapshots for run {_runId}."); + } + _runId = null; + _gameSaveDir = null; + SnapshotCount = 0; + } + + // ── Save ────────────────────────────────────────────────────────────────── + + public static void Save(int floor) + { + if (_gameSaveDir is null) _gameSaveDir = FindGameSaveDir(); + if (_gameSaveDir is null) + { + MainFile.Logger.Info("[Snapshot] Save skipped: game save dir not found."); + return; + } if (_runId is null) OnRunStart(); - snapshot.RunId = _runId!; - Directory.CreateDirectory(RunDir); - string path = Path.Combine(RunDir, $"{snapshot.SnapshotId}.json"); - File.WriteAllText(path, JsonSerializer.Serialize(snapshot, JsonOpts)); - MainFile.Logger.Info( - $"[Snapshot] Saved [{snapshot.SnapshotId}] Floor {snapshot.Floor} " + - $"HP {snapshot.CurrentHp}/{snapshot.MaxHp} Gold {snapshot.Gold}"); + var snapshotDir = Path.Combine(RunDir, $"floor_{floor:D2}"); + Directory.CreateDirectory(snapshotDir); + + CopyIfExists(Path.Combine(_gameSaveDir, "current_run.save"), snapshotDir); + + SnapshotCount++; + MainFile.Logger.Info($"[Snapshot] Saved floor {floor}."); } + // ── Load ────────────────────────────────────────────────────────────────── + public static List LoadAll() { if (!Directory.Exists(RunDir)) return []; - return [.. Directory - .GetFiles(RunDir, "*.json") - .Select(f => + return [.. Directory.GetDirectories(RunDir) + .Select(d => { - try { return JsonSerializer.Deserialize(File.ReadAllText(f)); } - catch { return null; } + var name = Path.GetFileName(d); + if (!name.StartsWith("floor_") || !int.TryParse(name[6..], out var f)) return null; + return new RunSnapshot { Floor = f, SavedAt = Directory.GetCreationTimeUtc(d), Dir = d }; }) .OfType() - .OrderByDescending(s => s.CreatedAt)]; + .OrderByDescending(s => s.Floor)]; } - public static RunSnapshot? LoadLatest() => LoadAll().FirstOrDefault(); + // ── Restore ─────────────────────────────────────────────────────────────── - public static void OnRunEnd() + public static void Restore(RunSnapshot snapshot) { - if (!Directory.Exists(RunDir)) + if (_gameSaveDir is null) _gameSaveDir = FindGameSaveDir(); + if (_gameSaveDir is null) { - _runId = null; + MainFile.Logger.Info("[Snapshot] Restore failed: game save dir not found."); return; } - Directory.Delete(RunDir, recursive: true); - MainFile.Logger.Info($"[Snapshot] Deleted all snapshots for run {_runId}"); - _runId = null; + if (!Directory.Exists(snapshot.Dir)) + { + MainFile.Logger.Info($"[Snapshot] Restore failed: snapshot dir missing ({snapshot.Dir})."); + return; + } + + // Only restore the run save — progress.save holds account-wide stats/unlocks + // that must not be rolled back when rewinding a floor. + CopyIfExists(Path.Combine(snapshot.Dir, "current_run.save"), _gameSaveDir); + + IsRestoring = true; + MainFile.Logger.Info($"[Snapshot] Restored floor {snapshot.Floor} — launching main menu."); + LaunchMainMenuAction?.Invoke(); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static string? FindGameSaveDir() + { + try + { + var files = Directory.GetFiles(OS.GetUserDataDir(), "current_run.save", + SearchOption.AllDirectories); + var found = files.FirstOrDefault(); + return found is not null ? Path.GetDirectoryName(found) : null; + } + catch (Exception ex) + { + MainFile.Logger.Info($"[Snapshot] FindGameSaveDir error: {ex.Message}"); + return null; + } + } + + private static void CopyIfExists(string src, string destDir) + { + if (!File.Exists(src)) return; + File.Copy(src, Path.Combine(destDir, Path.GetFileName(src)), overwrite: true); } } From b66ed3637064638da3458ada2a154dd90000f3bd Mon Sep 17 00:00:00 2001 From: JZ-Zhou-UofC Date: Tue, 23 Jun 2026 17:06:09 -0600 Subject: [PATCH 3/8] rewrite the saved snapshot --- .../Patches/RunLifecyclePatch.cs | 28 +++++++++---- .../Snapshots/SnapshotManager.cs | 39 ++++++++++++------- 2 files changed, 44 insertions(+), 23 deletions(-) diff --git a/content/ModTemplate/ModTemplateCode/Patches/RunLifecyclePatch.cs b/content/ModTemplate/ModTemplateCode/Patches/RunLifecyclePatch.cs index 2269d2f..c159cc7 100644 --- a/content/ModTemplate/ModTemplateCode/Patches/RunLifecyclePatch.cs +++ b/content/ModTemplate/ModTemplateCode/Patches/RunLifecyclePatch.cs @@ -22,19 +22,31 @@ static void Postfix() } } -// Run ends when the player returns to the main menu (covers both death and victory). -// LaunchMainMenu is internal in sts2.dll so we resolve it by name at runtime. -[HarmonyPatch("MegaCrit.Sts2.Core.Nodes.NGame", "LaunchMainMenu")] -static class RunEndPatch +// Run continues when the player resumes an existing run from the main menu. +[HarmonyPatch("MegaCrit.Sts2.Core.Nodes.NGame", "LoadRun")] +static class RunContinuePatch { - [HarmonyPrefix] - static void Prefix() + [HarmonyPostfix] + static void Postfix() { - try { SnapshotManager.OnRunEnd(); SnapshotUi.Teardown(); } - catch (Exception ex) { MainFile.Logger.Info($"[Snapshot] RunEnd error: {ex.Message}"); } + try { SnapshotManager.OnRunContinue(); } + catch (Exception ex) { MainFile.Logger.Info($"[Snapshot] RunContinue error: {ex.Message}"); } } } +// Run ends when the player returns to the main menu (covers both death and victory). +// LaunchMainMenu is internal in sts2.dll so we resolve it by name at runtime. +// [HarmonyPatch("MegaCrit.Sts2.Core.Nodes.NGame", "LaunchMainMenu")] +// static class RunEndPatch +// { +// [HarmonyPrefix] +// static void Prefix() +// { +// try { SnapshotManager.OnRunEnd(); SnapshotUi.Teardown(); } +// catch (Exception ex) { MainFile.Logger.Info($"[Snapshot] RunEnd error: {ex.Message}"); } +// } +// } + // Auto-save a snapshot at the start of every floor. // Hook.BeforeRoomEntered is the game's canonical hook point fired before any room logic runs. [HarmonyPatch(typeof(Hook), nameof(Hook.BeforeRoomEntered))] diff --git a/content/ModTemplate/ModTemplateCode/Snapshots/SnapshotManager.cs b/content/ModTemplate/ModTemplateCode/Snapshots/SnapshotManager.cs index ab48fb0..74c9d22 100644 --- a/content/ModTemplate/ModTemplateCode/Snapshots/SnapshotManager.cs +++ b/content/ModTemplate/ModTemplateCode/Snapshots/SnapshotManager.cs @@ -10,22 +10,32 @@ public static class SnapshotManager // without SnapshotManager needing to know about NGame directly. public static Action? LaunchMainMenuAction { get; set; } - private static string? _runId; private static string? _gameSaveDir; private static string SnapshotRoot => Path.Combine(OS.GetUserDataDir(), "mod_snapshots"); - private static string RunDir => Path.Combine(SnapshotRoot, _runId ?? "active"); + private static string ActiveDir => Path.Combine(SnapshotRoot, "active"); // ── Run lifecycle ───────────────────────────────────────────────────────── public static void OnRunStart() { - _runId = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss") + "_" + Guid.NewGuid().ToString("N")[..6]; + if (Directory.Exists(ActiveDir)) + Directory.Delete(ActiveDir, recursive: true); + Directory.CreateDirectory(ActiveDir); _gameSaveDir = FindGameSaveDir(); SnapshotCount = 0; IsRestoring = false; - Directory.CreateDirectory(RunDir); - MainFile.Logger.Info($"[Snapshot] Run started: {_runId} | saves: {_gameSaveDir ?? "NOT FOUND"}"); + MainFile.Logger.Info($"[Snapshot] New run started | saves: {_gameSaveDir ?? "NOT FOUND"}"); + } + + public static void OnRunContinue() + { + _gameSaveDir = FindGameSaveDir(); + SnapshotCount = Directory.Exists(ActiveDir) + ? Directory.GetDirectories(ActiveDir).Count(d => Path.GetFileName(d).StartsWith("floor_")) + : 0; + IsRestoring = false; + MainFile.Logger.Info($"[Snapshot] Continued run | {SnapshotCount} snapshot(s) | saves: {_gameSaveDir ?? "NOT FOUND"}"); } public static void OnRunEnd() @@ -37,12 +47,11 @@ public static void OnRunEnd() MainFile.Logger.Info("[Snapshot] RunEnd skipped deletion (restore in progress)."); return; } - if (Directory.Exists(RunDir)) + if (Directory.Exists(ActiveDir)) { - Directory.Delete(RunDir, recursive: true); - MainFile.Logger.Info($"[Snapshot] Deleted snapshots for run {_runId}."); + Directory.Delete(ActiveDir, recursive: true); + MainFile.Logger.Info("[Snapshot] Cleared mod_snapshots."); } - _runId = null; _gameSaveDir = null; SnapshotCount = 0; } @@ -57,23 +66,23 @@ public static void Save(int floor) MainFile.Logger.Info("[Snapshot] Save skipped: game save dir not found."); return; } - if (_runId is null) OnRunStart(); - var snapshotDir = Path.Combine(RunDir, $"floor_{floor:D2}"); + var snapshotDir = Path.Combine(ActiveDir, $"floor_{floor:D2}"); + bool isNew = !Directory.Exists(snapshotDir); Directory.CreateDirectory(snapshotDir); CopyIfExists(Path.Combine(_gameSaveDir, "current_run.save"), snapshotDir); - SnapshotCount++; - MainFile.Logger.Info($"[Snapshot] Saved floor {floor}."); + if (isNew) SnapshotCount++; + MainFile.Logger.Info($"[Snapshot] {(isNew ? "Saved" : "Overwrote")} floor {floor}."); } // ── Load ────────────────────────────────────────────────────────────────── public static List LoadAll() { - if (!Directory.Exists(RunDir)) return []; - return [.. Directory.GetDirectories(RunDir) + if (!Directory.Exists(ActiveDir)) return []; + return [.. Directory.GetDirectories(ActiveDir) .Select(d => { var name = Path.GetFileName(d); From c5583104d144188d508613b658e19274374dff09 Mon Sep 17 00:00:00 2001 From: JZ-Zhou-UofC Date: Wed, 24 Jun 2026 00:43:45 -0600 Subject: [PATCH 4/8] run save worked --- .../ModTemplateCode/Nodes/SnapshotUiNode.cs | 4 +- .../Patches/RunLifecyclePatch.cs | 27 +-- .../ModTemplateCode/Patches/SnapshotPatch.cs | 58 +---- .../ModTemplateCode/Snapshots/SnapshotData.cs | 6 +- .../Snapshots/SnapshotManager.cs | 216 +++++++++++------- 5 files changed, 142 insertions(+), 169 deletions(-) diff --git a/content/ModTemplate/ModTemplateCode/Nodes/SnapshotUiNode.cs b/content/ModTemplate/ModTemplateCode/Nodes/SnapshotUiNode.cs index 986ef42..c7f4822 100644 --- a/content/ModTemplate/ModTemplateCode/Nodes/SnapshotUiNode.cs +++ b/content/ModTemplate/ModTemplateCode/Nodes/SnapshotUiNode.cs @@ -190,11 +190,11 @@ private static void Refresh() row.AddChild(new Label { Text = snap.SavedAt.ToLocalTime().ToString("HH:mm:ss"), CustomMinimumSize = new Vector2(180, 0) }); row.AddChild(new Control { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }); - var btn = new Button { Text = "Restore" }; + var btn = new Button { Text = "Load Snapshot" }; var captured = snap; btn.Pressed += () => { - SnapshotManager.Restore(captured); + SnapshotManager.LoadSnapshot(captured); if (_panel != null) _panel.Visible = false; }; row.AddChild(btn); diff --git a/content/ModTemplate/ModTemplateCode/Patches/RunLifecyclePatch.cs b/content/ModTemplate/ModTemplateCode/Patches/RunLifecyclePatch.cs index c159cc7..ebc16c1 100644 --- a/content/ModTemplate/ModTemplateCode/Patches/RunLifecyclePatch.cs +++ b/content/ModTemplate/ModTemplateCode/Patches/RunLifecyclePatch.cs @@ -1,8 +1,6 @@ -using Godot; using HarmonyLib; using MegaCrit.Sts2.Core.Hooks; using MegaCrit.Sts2.Core.Nodes; -using MegaCrit.Sts2.Core.Nodes.Screens.MainMenu; using MegaCrit.Sts2.Core.Rooms; using MegaCrit.Sts2.Core.Runs; using ModTemplate.ModTemplateCode.Nodes; @@ -34,18 +32,6 @@ static void Postfix() } } -// Run ends when the player returns to the main menu (covers both death and victory). -// LaunchMainMenu is internal in sts2.dll so we resolve it by name at runtime. -// [HarmonyPatch("MegaCrit.Sts2.Core.Nodes.NGame", "LaunchMainMenu")] -// static class RunEndPatch -// { -// [HarmonyPrefix] -// static void Prefix() -// { -// try { SnapshotManager.OnRunEnd(); SnapshotUi.Teardown(); } -// catch (Exception ex) { MainFile.Logger.Info($"[Snapshot] RunEnd error: {ex.Message}"); } -// } -// } // Auto-save a snapshot at the start of every floor. // Hook.BeforeRoomEntered is the game's canonical hook point fired before any room logic runs. @@ -60,15 +46,4 @@ static void Postfix(IRunState runState, AbstractRoom room) } } -// Auto-press Continue when the main menu loads after a restore so the -// player lands directly back in the restored run without any manual click. -[HarmonyPatch(typeof(NMainMenuContinueButton), "_Ready")] -static class AutoContinuePatch -{ - [HarmonyPostfix] - static void Postfix(NMainMenuContinueButton __instance) - { - if (!SnapshotManager.IsRestoring) return; - Callable.From(() => __instance.EmitSignal(Button.SignalName.Pressed)).CallDeferred(); - } -} + diff --git a/content/ModTemplate/ModTemplateCode/Patches/SnapshotPatch.cs b/content/ModTemplate/ModTemplateCode/Patches/SnapshotPatch.cs index 6290740..a7e24b5 100644 --- a/content/ModTemplate/ModTemplateCode/Patches/SnapshotPatch.cs +++ b/content/ModTemplate/ModTemplateCode/Patches/SnapshotPatch.cs @@ -22,59 +22,11 @@ static void Postfix(NRun __instance) if (sceneRoot != null) SnapshotUi.Initialize(sceneRoot); - // Wire up the delegate that lets Restore() trigger the scene transition. - var nGame = FindInTree(__instance.GetTree().Root); - if (nGame != null) - { - // Log both candidate methods so we know their exact signatures. - LogMethodSig(typeof(NGame), "LaunchMainMenu"); - LogMethodSig(typeof(NGame), "LoadRun"); - - // Wire up scene transition for Restore(). - // Currently uses LaunchMainMenu → AutoContinuePatch path. - // Once we see LoadRun's signature in the log we can switch to - // calling it directly and skip the main menu entirely. - var launchMethod = AccessTools.Method(typeof(NGame), "LaunchMainMenu"); - if (launchMethod != null) - { - var args = launchMethod.GetParameters().Select(p => - p.HasDefaultValue ? p.DefaultValue : - p.ParameterType.IsValueType ? Activator.CreateInstance(p.ParameterType) - : (object?)null - ).ToArray(); - SnapshotManager.LaunchMainMenuAction = () => launchMethod.Invoke(nGame, args); - } - else - MainFile.Logger.Info("[Snapshot] NRunReady: LaunchMainMenu method not found."); - } - else - MainFile.Logger.Info("[Snapshot] NRunReady: NGame not found in tree — restore will not work."); - - // Clear the flag after the restored run has fully loaded so the - // next run-end will delete snapshots normally. - SnapshotManager.IsRestoring = false; + // Sync snapshot count now that the run scene is live. + SnapshotManager.OnRunContinue(); } catch (Exception ex) { MainFile.Logger.Info($"[Snapshot] NRunReady error: {ex}"); } } - - static void LogMethodSig(Type type, string name) - { - var m = AccessTools.Method(type, name); - if (m == null) { MainFile.Logger.Info($"[Snapshot] {name}: not found"); return; } - var p = string.Join(", ", m.GetParameters().Select(x => $"{x.ParameterType.Name} {x.Name}")); - MainFile.Logger.Info($"[Snapshot] {name}({p}) -> {m.ReturnType.Name}"); - } - - static T? FindInTree(Node node) where T : Node - { - if (node is T t) return t; - foreach (var child in node.GetChildren()) - { - var found = FindInTree(child); - if (found != null) return found; - } - return null; - } } // ── Drive UI every frame ────────────────────────────────────────────────── @@ -98,9 +50,9 @@ public static void SaveSnapshot(int floor) catch (Exception ex) { MainFile.Logger.Info($"[Snapshot] Save error: {ex}"); } } - public static void RestoreSnapshot(RunSnapshot snapshot) + public static void LoadSnapshot(RunSnapshot snapshot) { - try { SnapshotManager.Restore(snapshot); } - catch (Exception ex) { MainFile.Logger.Info($"[Snapshot] Restore error: {ex}"); } + try { SnapshotManager.LoadSnapshot(snapshot); } + catch (Exception ex) { MainFile.Logger.Info($"[Snapshot] Load snapshot error: {ex}"); } } } diff --git a/content/ModTemplate/ModTemplateCode/Snapshots/SnapshotData.cs b/content/ModTemplate/ModTemplateCode/Snapshots/SnapshotData.cs index 0bdfe1b..34f8d36 100644 --- a/content/ModTemplate/ModTemplateCode/Snapshots/SnapshotData.cs +++ b/content/ModTemplate/ModTemplateCode/Snapshots/SnapshotData.cs @@ -1,9 +1,7 @@ namespace ModTemplate.ModTemplateCode.Snapshots; -// Lightweight descriptor — actual state lives in the copied save files on disk. public class RunSnapshot { - public int Floor { get; set; } - public DateTime SavedAt { get; set; } = DateTime.UtcNow; - public string Dir { get; set; } = ""; // absolute path to the snapshot folder + public int Floor { get; set; } + public DateTime SavedAt { get; set; } = DateTime.UtcNow; } diff --git a/content/ModTemplate/ModTemplateCode/Snapshots/SnapshotManager.cs b/content/ModTemplate/ModTemplateCode/Snapshots/SnapshotManager.cs index 74c9d22..ec6db48 100644 --- a/content/ModTemplate/ModTemplateCode/Snapshots/SnapshotManager.cs +++ b/content/ModTemplate/ModTemplateCode/Snapshots/SnapshotManager.cs @@ -1,16 +1,17 @@ using Godot; +using HarmonyLib; +using MegaCrit.Sts2.Core.Nodes; +using MegaCrit.Sts2.Core.Runs; +using MegaCrit.Sts2.Core.Saves; namespace ModTemplate.ModTemplateCode.Snapshots; public static class SnapshotManager { - public static int SnapshotCount { get; private set; } - public static bool IsRestoring { get; set; } - // Set by NRunReadyPatch so Restore can trigger a scene transition - // without SnapshotManager needing to know about NGame directly. - public static Action? LaunchMainMenuAction { get; set; } + public static int SnapshotCount => _snapshots.Count; - private static string? _gameSaveDir; + // Floor → (complete run state at that floor, time it was captured) + private static readonly Dictionary _snapshots = new(); private static string SnapshotRoot => Path.Combine(OS.GetUserDataDir(), "mod_snapshots"); private static string ActiveDir => Path.Combine(SnapshotRoot, "active"); @@ -19,126 +20,173 @@ public static class SnapshotManager public static void OnRunStart() { + _snapshots.Clear(); if (Directory.Exists(ActiveDir)) Directory.Delete(ActiveDir, recursive: true); Directory.CreateDirectory(ActiveDir); - _gameSaveDir = FindGameSaveDir(); - SnapshotCount = 0; - IsRestoring = false; - MainFile.Logger.Info($"[Snapshot] New run started | saves: {_gameSaveDir ?? "NOT FOUND"}"); + MainFile.Logger.Info("[Snapshot] New run started."); } public static void OnRunContinue() { - _gameSaveDir = FindGameSaveDir(); - SnapshotCount = Directory.Exists(ActiveDir) - ? Directory.GetDirectories(ActiveDir).Count(d => Path.GetFileName(d).StartsWith("floor_")) - : 0; - IsRestoring = false; - MainFile.Logger.Info($"[Snapshot] Continued run | {SnapshotCount} snapshot(s) | saves: {_gameSaveDir ?? "NOT FOUND"}"); + _snapshots.Clear(); + if (Directory.Exists(ActiveDir)) + { + foreach (var dir in Directory.GetDirectories(ActiveDir)) + { + var name = Path.GetFileName(dir); + if (!name.StartsWith("floor_") || !int.TryParse(name[6..], out var floor)) continue; + + var snapshotFile = Path.Combine(dir, "snapshot.json"); + var metaFile = Path.Combine(dir, "meta.json"); + if (!File.Exists(snapshotFile)) continue; + + try + { + var result = JsonSerializationUtility.FromJson( + File.ReadAllText(snapshotFile)); + if (!result.Success || result.SaveData == null) continue; + var runSave = result.SaveData; + + var savedAt = File.Exists(metaFile) + ? DateTime.Parse(File.ReadAllText(metaFile), null, System.Globalization.DateTimeStyles.RoundtripKind) + : Directory.GetCreationTimeUtc(dir); + + _snapshots[floor] = (runSave, savedAt); + MainFile.Logger.Info($"[Snapshot] Restored floor {floor} from disk."); + } + catch (Exception ex) + { + MainFile.Logger.Info($"[Snapshot] Could not restore floor {floor}: {ex.Message}"); + } + } + } + MainFile.Logger.Info($"[Snapshot] Continued run | {_snapshots.Count} snapshot(s)."); } public static void OnRunEnd() { - if (IsRestoring) - { - // Player triggered a restore — keep snapshots so they survive the - // trip through the main menu back into the restored run. - MainFile.Logger.Info("[Snapshot] RunEnd skipped deletion (restore in progress)."); - return; - } + _snapshots.Clear(); if (Directory.Exists(ActiveDir)) { Directory.Delete(ActiveDir, recursive: true); - MainFile.Logger.Info("[Snapshot] Cleared mod_snapshots."); + MainFile.Logger.Info("[Snapshot] Run ended, snapshots cleared."); } - _gameSaveDir = null; - SnapshotCount = 0; } // ── Save ────────────────────────────────────────────────────────────────── public static void Save(int floor) { - if (_gameSaveDir is null) _gameSaveDir = FindGameSaveDir(); - if (_gameSaveDir is null) - { - MainFile.Logger.Info("[Snapshot] Save skipped: game save dir not found."); - return; - } + _ = SaveAsync(floor); + } - var snapshotDir = Path.Combine(ActiveDir, $"floor_{floor:D2}"); - bool isNew = !Directory.Exists(snapshotDir); - Directory.CreateDirectory(snapshotDir); + private static async Task SaveAsync(int floor) + { + try + { + var saveManager = SaveManager.Instance; - CopyIfExists(Path.Combine(_gameSaveDir, "current_run.save"), snapshotDir); + // Wait for any in-flight save so we read the final committed state. + var pending = saveManager.CurrentRunSaveTask; + if (pending != null) await pending; - if (isNew) SnapshotCount++; - MainFile.Logger.Info($"[Snapshot] {(isNew ? "Saved" : "Overwrote")} floor {floor}."); + var readResult = saveManager.LoadRunSave(); + if (!readResult.Success || readResult.SaveData == null) + { + MainFile.Logger.Info($"[Snapshot] Save floor {floor}: LoadRunSave failed (status={readResult.Status})."); + return; + } + + var runSave = readResult.SaveData; + bool isNew = !_snapshots.ContainsKey(floor); + var savedAt = DateTime.UtcNow; + _snapshots[floor] = (runSave, savedAt); + + // Persist to disk so snapshots survive game restarts. + var dir = Path.Combine(ActiveDir, $"floor_{floor:D2}"); + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, "snapshot.json"), + JsonSerializationUtility.ToJson(runSave)); + File.WriteAllText(Path.Combine(dir, "meta.json"), + savedAt.ToString("O")); // ISO 8601 round-trip format + + MainFile.Logger.Info($"[Snapshot] {(isNew ? "Saved" : "Overwrote")} floor {floor} ({_snapshots.Count} total)."); + } + catch (Exception ex) + { + MainFile.Logger.Info($"[Snapshot] SaveAsync error: {ex}"); + } } - // ── Load ────────────────────────────────────────────────────────────────── + // ── Enumerate ───────────────────────────────────────────────────────────── - public static List LoadAll() - { - if (!Directory.Exists(ActiveDir)) return []; - return [.. Directory.GetDirectories(ActiveDir) - .Select(d => - { - var name = Path.GetFileName(d); - if (!name.StartsWith("floor_") || !int.TryParse(name[6..], out var f)) return null; - return new RunSnapshot { Floor = f, SavedAt = Directory.GetCreationTimeUtc(d), Dir = d }; - }) - .OfType() + public static List LoadAll() => + [.. _snapshots + .Select(kvp => new RunSnapshot { Floor = kvp.Key, SavedAt = kvp.Value.SavedAt }) .OrderByDescending(s => s.Floor)]; - } - // ── Restore ─────────────────────────────────────────────────────────────── + // ── Load ────────────────────────────────────────────────────────────────── - public static void Restore(RunSnapshot snapshot) + public static void LoadSnapshot(RunSnapshot snapshot) { - if (_gameSaveDir is null) _gameSaveDir = FindGameSaveDir(); - if (_gameSaveDir is null) - { - MainFile.Logger.Info("[Snapshot] Restore failed: game save dir not found."); - return; - } - if (!Directory.Exists(snapshot.Dir)) + if (!_snapshots.TryGetValue(snapshot.Floor, out var entry)) { - MainFile.Logger.Info($"[Snapshot] Restore failed: snapshot dir missing ({snapshot.Dir})."); + MainFile.Logger.Info($"[Snapshot] Load floor {snapshot.Floor}: not found in memory."); return; } - - // Only restore the run save — progress.save holds account-wide stats/unlocks - // that must not be rolled back when rewinding a floor. - CopyIfExists(Path.Combine(snapshot.Dir, "current_run.save"), _gameSaveDir); - - IsRestoring = true; - MainFile.Logger.Info($"[Snapshot] Restored floor {snapshot.Floor} — launching main menu."); - LaunchMainMenuAction?.Invoke(); + MainFile.Logger.Info($"[Snapshot] Starting load for floor {snapshot.Floor}."); + _ = LoadSnapshotAsync(snapshot.Floor, entry.RunSave); } - // ── Helpers ─────────────────────────────────────────────────────────────── - - private static string? FindGameSaveDir() + private static async Task LoadSnapshotAsync(int floor, SerializableRun runSave) { try { - var files = Directory.GetFiles(OS.GetUserDataDir(), "current_run.save", - SearchOption.AllDirectories); - var found = files.FirstOrDefault(); - return found is not null ? Path.GetDirectoryName(found) : null; + var game = NGame.Instance; + var saveManager = SaveManager.Instance; + var runManager = RunManager.Instance; + + // Wait for any pending save so we don't race against an in-flight write. + var pending = saveManager.CurrentRunSaveTask; + if (pending != null) + { + MainFile.Logger.Info("[Snapshot] LoadAsync: waiting for pending save..."); + await pending; + } + + runManager.ActionExecutor.Cancel(); + runManager.ActionQueueSet.Reset(); + + // Reconstruct run state directly from the captured object — no file I/O needed. + RunState runState = RunState.FromSerializable(runSave); + + await game.Transition.FadeOut(); + + runManager.CleanUp(); + + // Method name changed capitalisation between game versions. + var setupMethod = + AccessTools.Method(typeof(RunManager), "SetUpSavedSingleplayer", [typeof(RunState), typeof(SerializableRun)]) ?? + AccessTools.Method(typeof(RunManager), "SetUpSavedSinglePlayer", [typeof(RunState), typeof(SerializableRun)]); + if (setupMethod != null) + { + var result = setupMethod.Invoke(runManager, [runState, runSave]); + if (result is Task t) await t; + } + else + { + MainFile.Logger.Info("[Snapshot] LoadAsync: SetUpSavedSingleplayer not found."); + } + + await game.LoadRun(runState, runSave.PreFinishedRoom); + await game.Transition.FadeIn(); + + MainFile.Logger.Info($"[Snapshot] Load complete for floor {floor}."); } catch (Exception ex) { - MainFile.Logger.Info($"[Snapshot] FindGameSaveDir error: {ex.Message}"); - return null; + MainFile.Logger.Info($"[Snapshot] LoadAsync error: {ex}"); } } - - private static void CopyIfExists(string src, string destDir) - { - if (!File.Exists(src)) return; - File.Copy(src, Path.Combine(destDir, Path.GetFileName(src)), overwrite: true); - } } From 41330545e9a16137562cf2686954972b131d1981 Mon Sep 17 00:00:00 2001 From: JZ-Zhou-UofC Date: Wed, 24 Jun 2026 00:59:37 -0600 Subject: [PATCH 5/8] description --- content/ModTemplate/ModTemplate.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/content/ModTemplate/ModTemplate.json b/content/ModTemplate/ModTemplate.json index 9b0406e..9485d13 100644 --- a/content/ModTemplate/ModTemplate.json +++ b/content/ModTemplate/ModTemplate.json @@ -1,9 +1,9 @@ { - "id": "ModTemplate", - "name": "ModTemplate", - "author": "{ModAuthor}", - "description": "Slay the Spire 2 mod created from a template for use with BaseLib", - "version": "v0.0.0", + "id": "STS2.Checkpoint", + "name": "Checkpoint", + "author": "Misachu", + "description": "Allows you to go back to any floor in a run", + "version": "v1.0.0", "min_game_version": "0.107.0", "has_pck": false, "has_dll": true, From df914abf3aa00829d949e07458acf046ea8cafd2 Mon Sep 17 00:00:00 2001 From: JZ-Zhou-UofC Date: Wed, 24 Jun 2026 01:35:52 -0600 Subject: [PATCH 6/8] rename snapshot to checkpoint --- .../CheckpointData.cs} | 4 +- .../CheckpointManager.cs} | 78 +++++++++---------- .../ModTemplate/ModTemplateCode/MainFile.cs | 4 +- ...{SnapshotUiNode.cs => CheckpointUiNode.cs} | 36 ++++----- .../Patches/CheckpointPatch.cs | 40 ++++++++++ .../Patches/RunLifecyclePatch.cs | 22 +++--- .../ModTemplateCode/Patches/SnapshotPatch.cs | 58 -------------- 7 files changed, 110 insertions(+), 132 deletions(-) rename content/ModTemplate/ModTemplateCode/{Snapshots/SnapshotData.cs => Checkpoints/CheckpointData.cs} (58%) rename content/ModTemplate/ModTemplateCode/{Snapshots/SnapshotManager.cs => Checkpoints/CheckpointManager.cs} (64%) rename content/ModTemplate/ModTemplateCode/Nodes/{SnapshotUiNode.cs => CheckpointUiNode.cs} (83%) create mode 100644 content/ModTemplate/ModTemplateCode/Patches/CheckpointPatch.cs delete mode 100644 content/ModTemplate/ModTemplateCode/Patches/SnapshotPatch.cs diff --git a/content/ModTemplate/ModTemplateCode/Snapshots/SnapshotData.cs b/content/ModTemplate/ModTemplateCode/Checkpoints/CheckpointData.cs similarity index 58% rename from content/ModTemplate/ModTemplateCode/Snapshots/SnapshotData.cs rename to content/ModTemplate/ModTemplateCode/Checkpoints/CheckpointData.cs index 34f8d36..172ebae 100644 --- a/content/ModTemplate/ModTemplateCode/Snapshots/SnapshotData.cs +++ b/content/ModTemplate/ModTemplateCode/Checkpoints/CheckpointData.cs @@ -1,6 +1,6 @@ -namespace ModTemplate.ModTemplateCode.Snapshots; +namespace ModTemplate.ModTemplateCode.Checkpoints; -public class RunSnapshot +public class RunCheckpoint { public int Floor { get; set; } public DateTime SavedAt { get; set; } = DateTime.UtcNow; diff --git a/content/ModTemplate/ModTemplateCode/Snapshots/SnapshotManager.cs b/content/ModTemplate/ModTemplateCode/Checkpoints/CheckpointManager.cs similarity index 64% rename from content/ModTemplate/ModTemplateCode/Snapshots/SnapshotManager.cs rename to content/ModTemplate/ModTemplateCode/Checkpoints/CheckpointManager.cs index ec6db48..3818abb 100644 --- a/content/ModTemplate/ModTemplateCode/Snapshots/SnapshotManager.cs +++ b/content/ModTemplate/ModTemplateCode/Checkpoints/CheckpointManager.cs @@ -4,32 +4,32 @@ using MegaCrit.Sts2.Core.Runs; using MegaCrit.Sts2.Core.Saves; -namespace ModTemplate.ModTemplateCode.Snapshots; +namespace ModTemplate.ModTemplateCode.Checkpoints; -public static class SnapshotManager +public static class CheckpointManager { - public static int SnapshotCount => _snapshots.Count; + public static int CheckpointCount => _checkpoints.Count; // Floor → (complete run state at that floor, time it was captured) - private static readonly Dictionary _snapshots = new(); + private static readonly Dictionary _checkpoints = new(); - private static string SnapshotRoot => Path.Combine(OS.GetUserDataDir(), "mod_snapshots"); - private static string ActiveDir => Path.Combine(SnapshotRoot, "active"); + private static string CheckpointRoot => Path.Combine(OS.GetUserDataDir(), "mod_checkpoints"); + private static string ActiveDir => Path.Combine(CheckpointRoot, "active"); // ── Run lifecycle ───────────────────────────────────────────────────────── public static void OnRunStart() { - _snapshots.Clear(); + _checkpoints.Clear(); if (Directory.Exists(ActiveDir)) Directory.Delete(ActiveDir, recursive: true); Directory.CreateDirectory(ActiveDir); - MainFile.Logger.Info("[Snapshot] New run started."); + MainFile.Logger.Info("[Checkpoint] New run started."); } public static void OnRunContinue() { - _snapshots.Clear(); + _checkpoints.Clear(); if (Directory.Exists(ActiveDir)) { foreach (var dir in Directory.GetDirectories(ActiveDir)) @@ -37,14 +37,14 @@ public static void OnRunContinue() var name = Path.GetFileName(dir); if (!name.StartsWith("floor_") || !int.TryParse(name[6..], out var floor)) continue; - var snapshotFile = Path.Combine(dir, "snapshot.json"); - var metaFile = Path.Combine(dir, "meta.json"); - if (!File.Exists(snapshotFile)) continue; + var checkpointFile = Path.Combine(dir, "checkpoint.json"); + var metaFile = Path.Combine(dir, "meta.json"); + if (!File.Exists(checkpointFile)) continue; try { var result = JsonSerializationUtility.FromJson( - File.ReadAllText(snapshotFile)); + File.ReadAllText(checkpointFile)); if (!result.Success || result.SaveData == null) continue; var runSave = result.SaveData; @@ -52,25 +52,25 @@ public static void OnRunContinue() ? DateTime.Parse(File.ReadAllText(metaFile), null, System.Globalization.DateTimeStyles.RoundtripKind) : Directory.GetCreationTimeUtc(dir); - _snapshots[floor] = (runSave, savedAt); - MainFile.Logger.Info($"[Snapshot] Restored floor {floor} from disk."); + _checkpoints[floor] = (runSave, savedAt); + MainFile.Logger.Info($"[Checkpoint] Restored floor {floor} from disk."); } catch (Exception ex) { - MainFile.Logger.Info($"[Snapshot] Could not restore floor {floor}: {ex.Message}"); + MainFile.Logger.Info($"[Checkpoint] Could not restore floor {floor}: {ex.Message}"); } } } - MainFile.Logger.Info($"[Snapshot] Continued run | {_snapshots.Count} snapshot(s)."); + MainFile.Logger.Info($"[Checkpoint] Continued run | {_checkpoints.Count} checkpoint(s)."); } public static void OnRunEnd() { - _snapshots.Clear(); + _checkpoints.Clear(); if (Directory.Exists(ActiveDir)) { Directory.Delete(ActiveDir, recursive: true); - MainFile.Logger.Info("[Snapshot] Run ended, snapshots cleared."); + MainFile.Logger.Info("[Checkpoint] Run ended, checkpoints cleared."); } } @@ -94,52 +94,52 @@ private static async Task SaveAsync(int floor) var readResult = saveManager.LoadRunSave(); if (!readResult.Success || readResult.SaveData == null) { - MainFile.Logger.Info($"[Snapshot] Save floor {floor}: LoadRunSave failed (status={readResult.Status})."); + MainFile.Logger.Info($"[Checkpoint] Save floor {floor}: LoadRunSave failed (status={readResult.Status})."); return; } var runSave = readResult.SaveData; - bool isNew = !_snapshots.ContainsKey(floor); + bool isNew = !_checkpoints.ContainsKey(floor); var savedAt = DateTime.UtcNow; - _snapshots[floor] = (runSave, savedAt); + _checkpoints[floor] = (runSave, savedAt); - // Persist to disk so snapshots survive game restarts. + // Persist to disk so checkpoints survive game restarts. var dir = Path.Combine(ActiveDir, $"floor_{floor:D2}"); Directory.CreateDirectory(dir); - File.WriteAllText(Path.Combine(dir, "snapshot.json"), + File.WriteAllText(Path.Combine(dir, "checkpoint.json"), JsonSerializationUtility.ToJson(runSave)); File.WriteAllText(Path.Combine(dir, "meta.json"), savedAt.ToString("O")); // ISO 8601 round-trip format - MainFile.Logger.Info($"[Snapshot] {(isNew ? "Saved" : "Overwrote")} floor {floor} ({_snapshots.Count} total)."); + MainFile.Logger.Info($"[Checkpoint] {(isNew ? "Saved" : "Overwrote")} floor {floor} ({_checkpoints.Count} total)."); } catch (Exception ex) { - MainFile.Logger.Info($"[Snapshot] SaveAsync error: {ex}"); + MainFile.Logger.Info($"[Checkpoint] SaveAsync error: {ex}"); } } // ── Enumerate ───────────────────────────────────────────────────────────── - public static List LoadAll() => - [.. _snapshots - .Select(kvp => new RunSnapshot { Floor = kvp.Key, SavedAt = kvp.Value.SavedAt }) + public static List LoadAll() => + [.. _checkpoints + .Select(kvp => new RunCheckpoint { Floor = kvp.Key, SavedAt = kvp.Value.SavedAt }) .OrderByDescending(s => s.Floor)]; // ── Load ────────────────────────────────────────────────────────────────── - public static void LoadSnapshot(RunSnapshot snapshot) + public static void LoadCheckpoint(RunCheckpoint checkpoint) { - if (!_snapshots.TryGetValue(snapshot.Floor, out var entry)) + if (!_checkpoints.TryGetValue(checkpoint.Floor, out var entry)) { - MainFile.Logger.Info($"[Snapshot] Load floor {snapshot.Floor}: not found in memory."); + MainFile.Logger.Info($"[Checkpoint] Load floor {checkpoint.Floor}: not found in memory."); return; } - MainFile.Logger.Info($"[Snapshot] Starting load for floor {snapshot.Floor}."); - _ = LoadSnapshotAsync(snapshot.Floor, entry.RunSave); + MainFile.Logger.Info($"[Checkpoint] Starting load for floor {checkpoint.Floor}."); + _ = LoadCheckpointAsync(checkpoint.Floor, entry.RunSave); } - private static async Task LoadSnapshotAsync(int floor, SerializableRun runSave) + private static async Task LoadCheckpointAsync(int floor, SerializableRun runSave) { try { @@ -151,7 +151,7 @@ private static async Task LoadSnapshotAsync(int floor, SerializableRun runSave) var pending = saveManager.CurrentRunSaveTask; if (pending != null) { - MainFile.Logger.Info("[Snapshot] LoadAsync: waiting for pending save..."); + MainFile.Logger.Info("[Checkpoint] LoadAsync: waiting for pending save..."); await pending; } @@ -176,17 +176,17 @@ private static async Task LoadSnapshotAsync(int floor, SerializableRun runSave) } else { - MainFile.Logger.Info("[Snapshot] LoadAsync: SetUpSavedSingleplayer not found."); + MainFile.Logger.Info("[Checkpoint] LoadAsync: SetUpSavedSingleplayer not found."); } await game.LoadRun(runState, runSave.PreFinishedRoom); await game.Transition.FadeIn(); - MainFile.Logger.Info($"[Snapshot] Load complete for floor {floor}."); + MainFile.Logger.Info($"[Checkpoint] Load complete for floor {floor}."); } catch (Exception ex) { - MainFile.Logger.Info($"[Snapshot] LoadAsync error: {ex}"); + MainFile.Logger.Info($"[Checkpoint] LoadAsync error: {ex}"); } } } diff --git a/content/ModTemplate/ModTemplateCode/MainFile.cs b/content/ModTemplate/ModTemplateCode/MainFile.cs index a043d0f..d8b6a17 100644 --- a/content/ModTemplate/ModTemplateCode/MainFile.cs +++ b/content/ModTemplate/ModTemplateCode/MainFile.cs @@ -7,7 +7,7 @@ namespace ModTemplate.ModTemplateCode; [ModInitializer(nameof(Initialize))] public partial class MainFile : Node { - public const string ModId = "SnapShot"; + public const string ModId = "Checkpoint"; public static MegaCrit.Sts2.Core.Logging.Logger Logger { get; } = new(ModId, MegaCrit.Sts2.Core.Logging.LogType.Generic); @@ -16,6 +16,6 @@ public static void Initialize() { Harmony harmony = new(ModId); try { harmony.PatchAll(); } - catch (Exception ex) { Logger.Info($"[Snapshot] PatchAll error: {ex.Message}"); } + catch (Exception ex) { Logger.Info($"[Checkpoint] PatchAll error: {ex.Message}"); } } } diff --git a/content/ModTemplate/ModTemplateCode/Nodes/SnapshotUiNode.cs b/content/ModTemplate/ModTemplateCode/Nodes/CheckpointUiNode.cs similarity index 83% rename from content/ModTemplate/ModTemplateCode/Nodes/SnapshotUiNode.cs rename to content/ModTemplate/ModTemplateCode/Nodes/CheckpointUiNode.cs index c7f4822..f460766 100644 --- a/content/ModTemplate/ModTemplateCode/Nodes/SnapshotUiNode.cs +++ b/content/ModTemplate/ModTemplateCode/Nodes/CheckpointUiNode.cs @@ -1,5 +1,5 @@ using Godot; -using ModTemplate.ModTemplateCode.Snapshots; +using ModTemplate.ModTemplateCode.Checkpoints; namespace ModTemplate.ModTemplateCode.Nodes; @@ -9,7 +9,7 @@ namespace ModTemplate.ModTemplateCode.Nodes; // throws ArgumentException. Using a static class with built-in Godot nodes // (CanvasLayer, Label, etc.) avoids this entirely. // Input polling and HUD updates are driven by a Harmony patch on NRun._Process. -internal static class SnapshotUi +internal static class CheckpointUi { private static Label? _hudLabel; private static Control? _panel; @@ -21,12 +21,12 @@ internal static class SnapshotUi // Called from NRunReadyPatch — builds the UI once per run scene. public static void Initialize(Node sceneRoot) { - if (sceneRoot.HasNode("SnapshotUiLayer")) return; + if (sceneRoot.HasNode("CheckpointUiLayer")) return; - var layer = new CanvasLayer { Layer = 128, Name = "SnapshotUiLayer" }; + var layer = new CanvasLayer { Layer = 128, Name = "CheckpointUiLayer" }; sceneRoot.AddChild(layer); BuildLayout(layer); - MainFile.Logger.Info("[Snapshot] SnapshotUi initialized."); + MainFile.Logger.Info("[Checkpoint] CheckpointUi initialized."); } // Called from RunEndPatch — clears stale references when the run ends. @@ -43,9 +43,9 @@ public static void Update(double delta) { if (_hudLabel == null) return; - _hudLabel.Text = SnapshotManager.SnapshotCount > 0 - ? $"[Snapshots: {SnapshotManager.SnapshotCount}] hold L" - : "[Snapshot Mod] hold L"; + _hudLabel.Text = CheckpointManager.CheckpointCount > 0 + ? $"[Checkpoints: {CheckpointManager.CheckpointCount}] hold L" + : "[Checkpoint] hold L"; if (Input.IsKeyPressed(Key.L)) { @@ -128,7 +128,7 @@ private static void BuildLayout(CanvasLayer layer) outer.AddChild(header); header.AddChild(new Label { - Text = "Snapshot History (hold L or Esc to close)", + Text = "Checkpoint History (hold L or Esc to close)", SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, }); var closeBtn = new Button { Text = "X" }; @@ -174,27 +174,27 @@ private static void Refresh() if (_list == null) return; foreach (var child in _list.GetChildren()) child.QueueFree(); - var snapshots = SnapshotManager.LoadAll().OrderByDescending(s => s.Floor).ToList(); + var checkpoints = CheckpointManager.LoadAll(); - if (snapshots.Count == 0) + if (checkpoints.Count == 0) { - _list.AddChild(new Label { Text = "No snapshots yet." }); + _list.AddChild(new Label { Text = "No checkpoints yet." }); return; } - foreach (var snap in snapshots) + foreach (var cp in checkpoints) { var row = new HBoxContainer(); _list.AddChild(row); - row.AddChild(new Label { Text = $"Floor {snap.Floor,2}", CustomMinimumSize = new Vector2(80, 0) }); - row.AddChild(new Label { Text = snap.SavedAt.ToLocalTime().ToString("HH:mm:ss"), CustomMinimumSize = new Vector2(180, 0) }); + row.AddChild(new Label { Text = $"Floor {cp.Floor,2}", CustomMinimumSize = new Vector2(80, 0) }); + row.AddChild(new Label { Text = cp.SavedAt.ToLocalTime().ToString("HH:mm:ss"), CustomMinimumSize = new Vector2(180, 0) }); row.AddChild(new Control { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }); - var btn = new Button { Text = "Load Snapshot" }; - var captured = snap; + var btn = new Button { Text = "Load Checkpoint" }; + var captured = cp; btn.Pressed += () => { - SnapshotManager.LoadSnapshot(captured); + CheckpointManager.LoadCheckpoint(captured); if (_panel != null) _panel.Visible = false; }; row.AddChild(btn); diff --git a/content/ModTemplate/ModTemplateCode/Patches/CheckpointPatch.cs b/content/ModTemplate/ModTemplateCode/Patches/CheckpointPatch.cs new file mode 100644 index 0000000..b5b5fc2 --- /dev/null +++ b/content/ModTemplate/ModTemplateCode/Patches/CheckpointPatch.cs @@ -0,0 +1,40 @@ +using Godot; +using HarmonyLib; +using MegaCrit.Sts2.Core.Nodes; +using ModTemplate.ModTemplateCode.Nodes; + +namespace ModTemplate.ModTemplateCode.Patches; + +internal static class CheckpointPatch +{ + // ── Add UI when a run scene loads ───────────────────────────────────────── + + [HarmonyPatch(typeof(NRun), "_Ready")] + static class NRunReadyPatch + { + [HarmonyPostfix] + static void Postfix(NRun __instance) + { + try + { + var sceneRoot = __instance.GetTree()?.Root; + if (sceneRoot != null) + CheckpointUi.Initialize(sceneRoot); + } + catch (Exception ex) { MainFile.Logger.Info($"[Checkpoint] NRunReady error: {ex}"); } + } + } + + // ── Drive UI every frame ────────────────────────────────────────────────── + + [HarmonyPatch(typeof(NRun), "_Process")] + static class NRunProcessPatch + { + [HarmonyPostfix] + static void Postfix(double delta) + { + try { CheckpointUi.Update(delta); } + catch (Exception ex) { MainFile.Logger.Info($"[Checkpoint] UI error: {ex.Message}"); } + } + } +} diff --git a/content/ModTemplate/ModTemplateCode/Patches/RunLifecyclePatch.cs b/content/ModTemplate/ModTemplateCode/Patches/RunLifecyclePatch.cs index ebc16c1..d59f58c 100644 --- a/content/ModTemplate/ModTemplateCode/Patches/RunLifecyclePatch.cs +++ b/content/ModTemplate/ModTemplateCode/Patches/RunLifecyclePatch.cs @@ -3,8 +3,7 @@ using MegaCrit.Sts2.Core.Nodes; using MegaCrit.Sts2.Core.Rooms; using MegaCrit.Sts2.Core.Runs; -using ModTemplate.ModTemplateCode.Nodes; -using ModTemplate.ModTemplateCode.Snapshots; +using ModTemplate.ModTemplateCode.Checkpoints; namespace ModTemplate.ModTemplateCode.Patches; @@ -15,8 +14,8 @@ static class RunStartPatch [HarmonyPostfix] static void Postfix() { - try { SnapshotManager.OnRunStart(); } - catch (Exception ex) { MainFile.Logger.Info($"[Snapshot] RunStart error: {ex.Message}"); } + try { CheckpointManager.OnRunStart(); } + catch (Exception ex) { MainFile.Logger.Info($"[Checkpoint] RunStart error: {ex.Message}"); } } } @@ -27,23 +26,20 @@ static class RunContinuePatch [HarmonyPostfix] static void Postfix() { - try { SnapshotManager.OnRunContinue(); } - catch (Exception ex) { MainFile.Logger.Info($"[Snapshot] RunContinue error: {ex.Message}"); } + try { CheckpointManager.OnRunContinue(); } + catch (Exception ex) { MainFile.Logger.Info($"[Checkpoint] RunContinue error: {ex.Message}"); } } } - -// Auto-save a snapshot at the start of every floor. +// Auto-save a checkpoint at the start of every floor. // Hook.BeforeRoomEntered is the game's canonical hook point fired before any room logic runs. [HarmonyPatch(typeof(Hook), nameof(Hook.BeforeRoomEntered))] -static class FloorSnapshotPatch +static class FloorCheckpointPatch { [HarmonyPostfix] static void Postfix(IRunState runState, AbstractRoom room) { - try { SnapshotPatch.SaveSnapshot(runState.TotalFloor); } - catch (Exception ex) { MainFile.Logger.Info($"[Snapshot] FloorSnapshot error: {ex.Message}"); } + try { CheckpointManager.Save(runState.TotalFloor); } + catch (Exception ex) { MainFile.Logger.Info($"[Checkpoint] FloorCheckpoint error: {ex.Message}"); } } } - - diff --git a/content/ModTemplate/ModTemplateCode/Patches/SnapshotPatch.cs b/content/ModTemplate/ModTemplateCode/Patches/SnapshotPatch.cs deleted file mode 100644 index a7e24b5..0000000 --- a/content/ModTemplate/ModTemplateCode/Patches/SnapshotPatch.cs +++ /dev/null @@ -1,58 +0,0 @@ -using Godot; -using HarmonyLib; -using MegaCrit.Sts2.Core.Nodes; -using ModTemplate.ModTemplateCode.Nodes; -using ModTemplate.ModTemplateCode.Snapshots; - -namespace ModTemplate.ModTemplateCode.Patches; - -internal static class SnapshotPatch -{ - // ── Add UI when a run scene loads ───────────────────────────────────────── - - [HarmonyPatch(typeof(NRun), "_Ready")] - static class NRunReadyPatch - { - [HarmonyPostfix] - static void Postfix(NRun __instance) - { - try - { - var sceneRoot = __instance.GetTree()?.Root; - if (sceneRoot != null) - SnapshotUi.Initialize(sceneRoot); - - // Sync snapshot count now that the run scene is live. - SnapshotManager.OnRunContinue(); - } - catch (Exception ex) { MainFile.Logger.Info($"[Snapshot] NRunReady error: {ex}"); } - } - } - - // ── Drive UI every frame ────────────────────────────────────────────────── - - [HarmonyPatch(typeof(NRun), "_Process")] - static class NRunProcessPatch - { - [HarmonyPostfix] - static void Postfix(double delta) - { - try { SnapshotUi.Update(delta); } - catch (Exception ex) { MainFile.Logger.Info($"[Snapshot] UI error: {ex.Message}"); } - } - } - - // ── Public surface used by FloorSnapshotPatch and SnapshotUi ───────────── - - public static void SaveSnapshot(int floor) - { - try { SnapshotManager.Save(floor); } - catch (Exception ex) { MainFile.Logger.Info($"[Snapshot] Save error: {ex}"); } - } - - public static void LoadSnapshot(RunSnapshot snapshot) - { - try { SnapshotManager.LoadSnapshot(snapshot); } - catch (Exception ex) { MainFile.Logger.Info($"[Snapshot] Load snapshot error: {ex}"); } - } -} From 82a13a47f195b16b91fc6afcb1ed44acf5146cbb Mon Sep 17 00:00:00 2001 From: JZ-Zhou-UofC Date: Wed, 24 Jun 2026 01:52:12 -0600 Subject: [PATCH 7/8] clean up --- .../Patches/CheckpointPatch.cs | 59 ++++++++++--------- .../Patches/CheckpointUiPatch.cs | 40 +++++++++++++ .../Patches/RunLifecyclePatch.cs | 45 -------------- 3 files changed, 72 insertions(+), 72 deletions(-) create mode 100644 content/ModTemplate/ModTemplateCode/Patches/CheckpointUiPatch.cs delete mode 100644 content/ModTemplate/ModTemplateCode/Patches/RunLifecyclePatch.cs diff --git a/content/ModTemplate/ModTemplateCode/Patches/CheckpointPatch.cs b/content/ModTemplate/ModTemplateCode/Patches/CheckpointPatch.cs index b5b5fc2..d59f58c 100644 --- a/content/ModTemplate/ModTemplateCode/Patches/CheckpointPatch.cs +++ b/content/ModTemplate/ModTemplateCode/Patches/CheckpointPatch.cs @@ -1,40 +1,45 @@ -using Godot; using HarmonyLib; +using MegaCrit.Sts2.Core.Hooks; using MegaCrit.Sts2.Core.Nodes; -using ModTemplate.ModTemplateCode.Nodes; +using MegaCrit.Sts2.Core.Rooms; +using MegaCrit.Sts2.Core.Runs; +using ModTemplate.ModTemplateCode.Checkpoints; namespace ModTemplate.ModTemplateCode.Patches; -internal static class CheckpointPatch +// Run starts when a new singleplayer run is initiated. +[HarmonyPatch(typeof(NGame), nameof(NGame.StartNewSingleplayerRun))] +static class RunStartPatch { - // ── Add UI when a run scene loads ───────────────────────────────────────── - - [HarmonyPatch(typeof(NRun), "_Ready")] - static class NRunReadyPatch + [HarmonyPostfix] + static void Postfix() { - [HarmonyPostfix] - static void Postfix(NRun __instance) - { - try - { - var sceneRoot = __instance.GetTree()?.Root; - if (sceneRoot != null) - CheckpointUi.Initialize(sceneRoot); - } - catch (Exception ex) { MainFile.Logger.Info($"[Checkpoint] NRunReady error: {ex}"); } - } + try { CheckpointManager.OnRunStart(); } + catch (Exception ex) { MainFile.Logger.Info($"[Checkpoint] RunStart error: {ex.Message}"); } } +} - // ── Drive UI every frame ────────────────────────────────────────────────── +// Run continues when the player resumes an existing run from the main menu. +[HarmonyPatch("MegaCrit.Sts2.Core.Nodes.NGame", "LoadRun")] +static class RunContinuePatch +{ + [HarmonyPostfix] + static void Postfix() + { + try { CheckpointManager.OnRunContinue(); } + catch (Exception ex) { MainFile.Logger.Info($"[Checkpoint] RunContinue error: {ex.Message}"); } + } +} - [HarmonyPatch(typeof(NRun), "_Process")] - static class NRunProcessPatch +// Auto-save a checkpoint at the start of every floor. +// Hook.BeforeRoomEntered is the game's canonical hook point fired before any room logic runs. +[HarmonyPatch(typeof(Hook), nameof(Hook.BeforeRoomEntered))] +static class FloorCheckpointPatch +{ + [HarmonyPostfix] + static void Postfix(IRunState runState, AbstractRoom room) { - [HarmonyPostfix] - static void Postfix(double delta) - { - try { CheckpointUi.Update(delta); } - catch (Exception ex) { MainFile.Logger.Info($"[Checkpoint] UI error: {ex.Message}"); } - } + try { CheckpointManager.Save(runState.TotalFloor); } + catch (Exception ex) { MainFile.Logger.Info($"[Checkpoint] FloorCheckpoint error: {ex.Message}"); } } } diff --git a/content/ModTemplate/ModTemplateCode/Patches/CheckpointUiPatch.cs b/content/ModTemplate/ModTemplateCode/Patches/CheckpointUiPatch.cs new file mode 100644 index 0000000..b5b5fc2 --- /dev/null +++ b/content/ModTemplate/ModTemplateCode/Patches/CheckpointUiPatch.cs @@ -0,0 +1,40 @@ +using Godot; +using HarmonyLib; +using MegaCrit.Sts2.Core.Nodes; +using ModTemplate.ModTemplateCode.Nodes; + +namespace ModTemplate.ModTemplateCode.Patches; + +internal static class CheckpointPatch +{ + // ── Add UI when a run scene loads ───────────────────────────────────────── + + [HarmonyPatch(typeof(NRun), "_Ready")] + static class NRunReadyPatch + { + [HarmonyPostfix] + static void Postfix(NRun __instance) + { + try + { + var sceneRoot = __instance.GetTree()?.Root; + if (sceneRoot != null) + CheckpointUi.Initialize(sceneRoot); + } + catch (Exception ex) { MainFile.Logger.Info($"[Checkpoint] NRunReady error: {ex}"); } + } + } + + // ── Drive UI every frame ────────────────────────────────────────────────── + + [HarmonyPatch(typeof(NRun), "_Process")] + static class NRunProcessPatch + { + [HarmonyPostfix] + static void Postfix(double delta) + { + try { CheckpointUi.Update(delta); } + catch (Exception ex) { MainFile.Logger.Info($"[Checkpoint] UI error: {ex.Message}"); } + } + } +} diff --git a/content/ModTemplate/ModTemplateCode/Patches/RunLifecyclePatch.cs b/content/ModTemplate/ModTemplateCode/Patches/RunLifecyclePatch.cs deleted file mode 100644 index d59f58c..0000000 --- a/content/ModTemplate/ModTemplateCode/Patches/RunLifecyclePatch.cs +++ /dev/null @@ -1,45 +0,0 @@ -using HarmonyLib; -using MegaCrit.Sts2.Core.Hooks; -using MegaCrit.Sts2.Core.Nodes; -using MegaCrit.Sts2.Core.Rooms; -using MegaCrit.Sts2.Core.Runs; -using ModTemplate.ModTemplateCode.Checkpoints; - -namespace ModTemplate.ModTemplateCode.Patches; - -// Run starts when a new singleplayer run is initiated. -[HarmonyPatch(typeof(NGame), nameof(NGame.StartNewSingleplayerRun))] -static class RunStartPatch -{ - [HarmonyPostfix] - static void Postfix() - { - try { CheckpointManager.OnRunStart(); } - catch (Exception ex) { MainFile.Logger.Info($"[Checkpoint] RunStart error: {ex.Message}"); } - } -} - -// Run continues when the player resumes an existing run from the main menu. -[HarmonyPatch("MegaCrit.Sts2.Core.Nodes.NGame", "LoadRun")] -static class RunContinuePatch -{ - [HarmonyPostfix] - static void Postfix() - { - try { CheckpointManager.OnRunContinue(); } - catch (Exception ex) { MainFile.Logger.Info($"[Checkpoint] RunContinue error: {ex.Message}"); } - } -} - -// Auto-save a checkpoint at the start of every floor. -// Hook.BeforeRoomEntered is the game's canonical hook point fired before any room logic runs. -[HarmonyPatch(typeof(Hook), nameof(Hook.BeforeRoomEntered))] -static class FloorCheckpointPatch -{ - [HarmonyPostfix] - static void Postfix(IRunState runState, AbstractRoom room) - { - try { CheckpointManager.Save(runState.TotalFloor); } - catch (Exception ex) { MainFile.Logger.Info($"[Checkpoint] FloorCheckpoint error: {ex.Message}"); } - } -} From b437c253c3c03119380cd82567a6b452013ceffb Mon Sep 17 00:00:00 2001 From: JZ-Zhou-UofC Date: Wed, 24 Jun 2026 02:55:20 -0600 Subject: [PATCH 8/8] better ui --- content/ModTemplate/ModTemplate.json | 2 +- .../Checkpoints/CheckpointManager.cs | 19 ++++++++++++++++--- .../ModTemplateCode/Nodes/CheckpointUiNode.cs | 17 ++++------------- .../Patches/CheckpointUiPatch.cs | 7 +++++-- 4 files changed, 26 insertions(+), 19 deletions(-) diff --git a/content/ModTemplate/ModTemplate.json b/content/ModTemplate/ModTemplate.json index 9485d13..c87362c 100644 --- a/content/ModTemplate/ModTemplate.json +++ b/content/ModTemplate/ModTemplate.json @@ -1,5 +1,5 @@ { - "id": "STS2.Checkpoint", + "id": "ModTemplate", "name": "Checkpoint", "author": "Misachu", "description": "Allows you to go back to any floor in a run", diff --git a/content/ModTemplate/ModTemplateCode/Checkpoints/CheckpointManager.cs b/content/ModTemplate/ModTemplateCode/Checkpoints/CheckpointManager.cs index 3818abb..979d6b8 100644 --- a/content/ModTemplate/ModTemplateCode/Checkpoints/CheckpointManager.cs +++ b/content/ModTemplate/ModTemplateCode/Checkpoints/CheckpointManager.cs @@ -13,6 +13,8 @@ public static class CheckpointManager // Floor → (complete run state at that floor, time it was captured) private static readonly Dictionary _checkpoints = new(); + private static bool _isLoadingCheckpoint; + private static string CheckpointRoot => Path.Combine(OS.GetUserDataDir(), "mod_checkpoints"); private static string ActiveDir => Path.Combine(CheckpointRoot, "active"); @@ -29,6 +31,7 @@ public static void OnRunStart() public static void OnRunContinue() { + if (_isLoadingCheckpoint) return; _checkpoints.Clear(); if (Directory.Exists(ActiveDir)) { @@ -147,6 +150,12 @@ private static async Task LoadCheckpointAsync(int floor, SerializableRun runSave var saveManager = SaveManager.Instance; var runManager = RunManager.Instance; + if (game == null || saveManager == null || runManager == null) + { + MainFile.Logger.Info("[Checkpoint] LoadAsync: required instances not available."); + return; + } + // Wait for any pending save so we don't race against an in-flight write. var pending = saveManager.CurrentRunSaveTask; if (pending != null) @@ -161,7 +170,8 @@ private static async Task LoadCheckpointAsync(int floor, SerializableRun runSave // Reconstruct run state directly from the captured object — no file I/O needed. RunState runState = RunState.FromSerializable(runSave); - await game.Transition.FadeOut(); + var transition = game.Transition; + if (transition != null) await transition.FadeOut(); runManager.CleanUp(); @@ -179,8 +189,11 @@ private static async Task LoadCheckpointAsync(int floor, SerializableRun runSave MainFile.Logger.Info("[Checkpoint] LoadAsync: SetUpSavedSingleplayer not found."); } - await game.LoadRun(runState, runSave.PreFinishedRoom); - await game.Transition.FadeIn(); + _isLoadingCheckpoint = true; + try { await game.LoadRun(runState, runSave.PreFinishedRoom); } + finally { _isLoadingCheckpoint = false; } + + if (transition != null) await transition.FadeIn(); MainFile.Logger.Info($"[Checkpoint] Load complete for floor {floor}."); } diff --git a/content/ModTemplate/ModTemplateCode/Nodes/CheckpointUiNode.cs b/content/ModTemplate/ModTemplateCode/Nodes/CheckpointUiNode.cs index f460766..6f0e171 100644 --- a/content/ModTemplate/ModTemplateCode/Nodes/CheckpointUiNode.cs +++ b/content/ModTemplate/ModTemplateCode/Nodes/CheckpointUiNode.cs @@ -29,15 +29,6 @@ public static void Initialize(Node sceneRoot) MainFile.Logger.Info("[Checkpoint] CheckpointUi initialized."); } - // Called from RunEndPatch — clears stale references when the run ends. - public static void Teardown() - { - _hudLabel = null; - _panel = null; - _list = null; - _lHeld = 0; - } - // Called every frame from NRunProcessPatch. public static void Update(double delta) { @@ -86,8 +77,8 @@ private static void BuildLayout(CanvasLayer layer) _hudLabel.AnchorRight = 1f; _hudLabel.AnchorTop = 0f; _hudLabel.AnchorBottom = 0f; - _hudLabel.OffsetLeft = -260f; - _hudLabel.OffsetRight = -8f; + _hudLabel.OffsetLeft = -560f; + _hudLabel.OffsetRight = -300f; _hudLabel.OffsetTop = 8f; _hudLabel.OffsetBottom = 32f; root.AddChild(_hudLabel); @@ -140,7 +131,7 @@ private static void BuildLayout(CanvasLayer layer) var cols = new HBoxContainer(); outer.AddChild(cols); cols.AddChild(ColLabel("Floor", 80)); - cols.AddChild(ColLabel("Saved at", 180)); + cols.AddChild(ColLabel("Saved at", 150)); cols.AddChild(new Control { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }); outer.AddChild(new HSeparator()); @@ -187,7 +178,7 @@ private static void Refresh() var row = new HBoxContainer(); _list.AddChild(row); row.AddChild(new Label { Text = $"Floor {cp.Floor,2}", CustomMinimumSize = new Vector2(80, 0) }); - row.AddChild(new Label { Text = cp.SavedAt.ToLocalTime().ToString("HH:mm:ss"), CustomMinimumSize = new Vector2(180, 0) }); + row.AddChild(new Label { Text = cp.SavedAt.ToLocalTime().ToString("MM/dd HH:mm:ss"), CustomMinimumSize = new Vector2(150, 0) }); row.AddChild(new Control { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }); var btn = new Button { Text = "Load Checkpoint" }; diff --git a/content/ModTemplate/ModTemplateCode/Patches/CheckpointUiPatch.cs b/content/ModTemplate/ModTemplateCode/Patches/CheckpointUiPatch.cs index b5b5fc2..006ae47 100644 --- a/content/ModTemplate/ModTemplateCode/Patches/CheckpointUiPatch.cs +++ b/content/ModTemplate/ModTemplateCode/Patches/CheckpointUiPatch.cs @@ -1,13 +1,14 @@ using Godot; using HarmonyLib; using MegaCrit.Sts2.Core.Nodes; +using ModTemplate.ModTemplateCode.Checkpoints; using ModTemplate.ModTemplateCode.Nodes; namespace ModTemplate.ModTemplateCode.Patches; -internal static class CheckpointPatch +internal static class CheckpointUiPatch { - // ── Add UI when a run scene loads ───────────────────────────────────────── + // ── Initialize UI and load checkpoints when a run scene is ready ────────── [HarmonyPatch(typeof(NRun), "_Ready")] static class NRunReadyPatch @@ -20,6 +21,8 @@ static void Postfix(NRun __instance) var sceneRoot = __instance.GetTree()?.Root; if (sceneRoot != null) CheckpointUi.Initialize(sceneRoot); + + CheckpointManager.OnRunContinue(); } catch (Exception ex) { MainFile.Logger.Info($"[Checkpoint] NRunReady error: {ex}"); } }