diff --git a/Theriapolis.Godot/Platform/SavePaths.cs b/Theriapolis.Godot/Platform/SavePaths.cs new file mode 100644 index 0000000..9582be4 --- /dev/null +++ b/Theriapolis.Godot/Platform/SavePaths.cs @@ -0,0 +1,63 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; + +namespace Theriapolis.GodotHost.Platform; + +/// +/// OS-aware save directory resolution. Direct port of +/// Theriapolis.Game/Platform/SavePaths.cs; deliberately uses the +/// same directories as the MonoGame build so saves are interoperable +/// across the two ports. +/// +/// Locations: +/// Windows: %LOCALAPPDATA%\Theriapolis\Saves\ +/// macOS: ~/Library/Application Support/Theriapolis/Saves/ +/// Linux: $XDG_DATA_HOME/Theriapolis/saves/ (default +/// ~/.local/share/Theriapolis/saves/) +/// +public static class SavePaths +{ + /// Top-level Theriapolis save directory. Created on first + /// call if missing. + public static string SavesDir + { + get + { + string dir = ResolveBase(); + Directory.CreateDirectory(dir); + return dir; + } + } + + public static string SlotPath(int slot) => Path.Combine(SavesDir, $"slot_{slot:D2}.trps"); + public static string AutosavePath() => Path.Combine(SavesDir, "autosave.trps"); + + private static string ResolveBase() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Theriapolis", "Saves"); + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Library", "Application Support", "Theriapolis", "Saves"); + // Linux + others: respect XDG_DATA_HOME, fall back to ~/.local/share. + string xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME") ?? ""; + if (string.IsNullOrEmpty(xdg)) + xdg = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".local", "share"); + return Path.Combine(xdg, "Theriapolis", "saves"); + } + + /// Atomic-rename file write so a crash mid-save can't + /// corrupt the slot. + public static void WriteAtomic(string path, byte[] bytes) + { + string dir = Path.GetDirectoryName(path)!; + Directory.CreateDirectory(dir); + string tmp = path + ".tmp"; + File.WriteAllBytes(tmp, bytes); + if (File.Exists(path)) File.Replace(tmp, path, destinationBackupFileName: null); + else File.Move(tmp, path); + } +} diff --git a/Theriapolis.Godot/Scenes/PlayScreen.cs b/Theriapolis.Godot/Scenes/PlayScreen.cs index 80656fd..697ebc3 100644 --- a/Theriapolis.Godot/Scenes/PlayScreen.cs +++ b/Theriapolis.Godot/Scenes/PlayScreen.cs @@ -1,10 +1,15 @@ +using System; using System.Collections.Generic; +using System.IO; using System.Linq; using Godot; using Theriapolis.Core; using Theriapolis.Core.Data; using Theriapolis.Core.Entities; +using Theriapolis.Core.Persistence; using Theriapolis.Core.Rules.Combat; +using Theriapolis.Core.Rules.Quests; +using Theriapolis.Core.Rules.Reputation; using Theriapolis.Core.Tactical; using Theriapolis.Core.Time; using Theriapolis.Core.Util; @@ -19,22 +24,25 @@ using Theriapolis.GodotHost.UI; namespace Theriapolis.GodotHost.Scenes; /// -/// M7.2 — the play screen. Wraps with the -/// game-state layer: player actor, world clock, chunk streamer, NPC -/// markers, player controller, and a top-left HUD overlay. Click on the -/// world map to travel; WASD to step at tactical zoom. +/// M7.2 + M7.3 — the play screen. Wraps +/// with the game-state layer: player actor, world clock, chunk +/// streamer, NPC markers, player controller, save layer, and a +/// top-left HUD overlay. Click on the world map to travel; WASD to +/// step at tactical zoom. F5 quicksaves; Esc returns to title (M7.4 +/// will replace this with a pause menu). /// -/// Per the M7 plan §6: PlayScreen owns the chunk streamer (so M7.3 save -/// can serialise its delta store) and the actor manager (so M7.5 can -/// drive interact prompts). The world-map view and the tactical view -/// are the same scene at different zoom levels — there is no separate -/// WorldMapScreen, by design. +/// Save round-trip (M7.3): wraps +/// + . +/// on init consumes +/// set by the +/// hand-off; replaces the new-game spawn. +/// The save format is owned by Core and untouched — saves written by +/// the MonoGame build load here byte-identically and vice versa. /// -/// M7.2 omissions (deferred to later sub-milestones): -/// - Save / load round-trip (M7.3) -/// - Pause menu (M7.4) -/// - Interact prompt + dialogue push (M7.5) -/// - Encounter detection stub + autosave toast (M7.6) +/// M7 sub-milestone status: +/// M7.4 (pause menu) — Esc still does quit-to-title for now. +/// M7.5 (interact prompt) — F-to-talk not yet wired. +/// M7.6 (encounter trigger stub) — hostile detection not yet wired. /// public partial class PlayScreen : Control { @@ -49,6 +57,19 @@ public partial class PlayScreen : Control private WorldClock _clock = null!; private PlayerController _controller = null!; private AnchorRegistry _anchorRegistry = null!; + private readonly PlayerReputation _reputation = new(); + private readonly Dictionary _flags = new(); + private readonly QuestEngine _questEngine = new(); + private QuestContext? _questCtx; + + // M7.3 — save round-trip plumbing + private readonly Dictionary> _killedByChunk = new(); + // Phase 5 M5: mid-combat encounter snapshot waiting for the CombatHUD + // push. Captured by load, picked up after chunks load; M8 will turn + // this into an actual push to the combat screen. + private EncounterState? _pendingEncounterRestore; + private float _saveFlashTimer; + private string _saveFlashText = ""; // Godot tree private WorldRenderNode _render = null!; @@ -56,6 +77,7 @@ public partial class PlayScreen : Control private readonly Dictionary _npcMarkers = new(); private Label _hudLabel = null!; private PanelContainer _hudPanel = null!; + private Label? _saveFlashLabel; // Click-vs-drag state (left-click only; PanZoomCamera handles // middle/right-drag pan independently). @@ -98,18 +120,29 @@ public partial class PlayScreen : Control _anchorRegistry = new AnchorRegistry(); _anchorRegistry.RegisterAllAnchors(_ctx.World); - // Spawn player at the Tier-1 anchor (Millhaven), or the centre of - // the world if no inhabited settlement exists. - var spawn = ChooseSpawn(_ctx.World); - if (session.PendingCharacter is not null) + // Phase 6 M4 — quest context wraps content/actors/rep/flags/clock/ + // world for the quest engine. Round-trips through the save body. + _questCtx = new QuestContext( + _content, _actors, _reputation, _flags, _anchorRegistry, _clock, _ctx.World); + + // Spawn or restore the player. Restore wins when a load was queued. + if (session.PendingRestore is not null) { - var p = _actors.SpawnPlayer(spawn, session.PendingCharacter); - if (!string.IsNullOrWhiteSpace(session.PendingName)) - p.Name = session.PendingName; + ApplyRestoredBody(session.PendingRestore); } else { - _actors.SpawnPlayer(spawn); + var spawn = ChooseSpawn(_ctx.World); + if (session.PendingCharacter is not null) + { + var p = _actors.SpawnPlayer(spawn, session.PendingCharacter); + if (!string.IsNullOrWhiteSpace(session.PendingName)) + p.Name = session.PendingName; + } + else + { + _actors.SpawnPlayer(spawn); + } } _controller = new PlayerController(_actors.Player!, _ctx.World, _clock); @@ -127,6 +160,14 @@ public partial class PlayScreen : Control SetInitialZoom(); BuildHud(); + // M7.5/M8 will pick up _pendingEncounterRestore here once the + // combat HUD screen exists. For now we keep the snapshot on the + // body so a re-save preserves it across the Godot↔MonoGame round + // trip, but we don't attempt to resume combat. + if (_pendingEncounterRestore is not null) + GD.Print("[play] Loaded save has an active encounter — " + + "combat HUD ships with M8; encounter preserved through save round-trip."); + // Clear pending so a quit-to-title doesn't see stale data. session.PendingCharacter = null; session.PendingName = "Wanderer"; @@ -178,6 +219,22 @@ public partial class PlayScreen : Control marker.Scale = new Vector2(inv, inv); } + // Save-flash toast decay. + if (_saveFlashTimer > 0f) + { + _saveFlashTimer = MathF.Max(0f, _saveFlashTimer - dt); + if (_saveFlashLabel is not null) + { + _saveFlashLabel.Text = _saveFlashText; + _saveFlashLabel.Modulate = new Color(1, 1, 1, Mathf.Min(1f, _saveFlashTimer / 0.5f)); + _saveFlashLabel.Visible = _saveFlashTimer > 0f; + } + } + else if (_saveFlashLabel is not null && _saveFlashLabel.Visible) + { + _saveFlashLabel.Visible = false; + } + UpdateHud(tactical); } @@ -196,7 +253,6 @@ public partial class PlayScreen : Control return; } - // Release. if (!_mouseDownTracked) return; _mouseDownTracked = false; bool wasClick = mb.Position.DistanceTo(_mouseDownPos) <= ClickSlopPixels; @@ -210,6 +266,22 @@ public partial class PlayScreen : Control } } + public override void _Input(InputEvent @event) + { + if (@event is not InputEventKey { Pressed: true } key) return; + switch (key.Keycode) + { + case Key.F5: + SaveTo(SavePaths.AutosavePath()); + GetViewport().SetInputAsHandled(); + break; + case Key.Escape: + GetViewport().SetInputAsHandled(); + BackToTitle(); + break; + } + } + private Vector2 ScreenToWorld(Vector2 screenPos) => _render.Camera.GetCanvasTransform().AffineInverse() * screenPos; @@ -222,8 +294,12 @@ public partial class PlayScreen : Control private void HandleChunkLoaded(TacticalChunk chunk) { if (_content is null) return; + _killedByChunk.TryGetValue(chunk.Coord, out var killed); for (int i = 0; i < chunk.Spawns.Count; i++) { + // Skip slots the player previously killed — they don't respawn + // on chunk reload until the save is wiped. + if (killed is not null && killed.Contains(i)) continue; var spawn = chunk.Spawns[i]; if (_actors.FindNpcBySource(chunk.Coord, i) is not null) continue; @@ -277,6 +353,140 @@ public partial class PlayScreen : Control _npcMarkers[npc.Id] = marker; } + // ────────────────────────────────────────────────────────────────────── + // M7.3 — Save / Load + + /// Write the current state to the given slot path (atomic). + public bool SaveTo(string path) + { + try + { + var header = BuildHeader(); + var body = CaptureBody(); + var bytes = SaveCodec.Serialize(header, body); + SavePaths.WriteAtomic(path, bytes); + FlashSavedToast($"Saved to {Path.GetFileName(path)}"); + return true; + } + catch (Exception ex) + { + FlashSavedToast($"Save failed: {ex.Message}"); + GD.PushError($"[save] {ex}"); + return false; + } + } + + private SaveHeader BuildHeader() + { + var h = new SaveHeader + { + WorldSeedHex = $"0x{_ctx.World.WorldSeed:X}", + PlayerName = _actors.Player!.Name, + PlayerTier = _actors.Player.HighestTierReached, + InGameSeconds = _clock.InGameSeconds, + SavedAtUtc = DateTime.UtcNow.ToString("u"), + }; + foreach (var kv in _ctx.World.StageHashes) + h.StageHashes[kv.Key] = $"0x{kv.Value:X}"; + return h; + } + + /// Build a save body from the current PlayScreen state. Mirrors + /// the MonoGame source's CaptureBody field-by-field so the + /// resulting byte stream is interoperable across builds. + private SaveBody CaptureBody() + { + // Mid-combat snapshot — null in M7 (combat HUD doesn't exist yet), + // but the field is preserved so a save loaded from MonoGame with + // an active encounter round-trips back through Godot intact. + EncounterState? activeEnc = _pendingEncounterRestore; + + // Push every loaded chunk through eviction so any in-memory deltas + // land in the store before we read it. + _streamer.FlushAll(); + + var body = new SaveBody + { + Player = _actors.Player!.CaptureState(), + Clock = _clock.CaptureState(), + }; + if (_actors.Player.Character is not null) + body.PlayerCharacter = CharacterCodec.Capture(_actors.Player.Character); + foreach (var kv in _deltas.All) + body.ModifiedChunks[kv.Key] = kv.Value; + + foreach (var kv in _killedByChunk) + body.NpcRoster.ChunkDeltas.Add(new NpcChunkDelta + { + ChunkX = kv.Key.X, + ChunkY = kv.Key.Y, + KilledSpawnIndices = kv.Value.ToArray(), + }); + + body.ActiveEncounter = activeEnc; + body.ReputationState = ReputationCodec.Capture(_reputation); + body.Flags = new Dictionary(_flags); + body.QuestEngineState = QuestCodec.Capture(_questEngine); + return body; + } + + /// Restore PlayScreen state from a deserialised body. Caller + /// must have already set _ctx, _content, _streamer, + /// _actors, _clock, and _anchorRegistry — this is + /// invoked from after those are wired but before + /// the player marker is created. + private void ApplyRestoredBody(SaveBody body) + { + var player = _actors.RestorePlayer(body.Player); + _clock.RestoreState(body.Clock); + + foreach (var kv in body.ModifiedChunks) + _deltas.Put(kv.Key, kv.Value); + + foreach (var d in body.ModifiedWorldTiles) + { + ref var t = ref _ctx.World.TileAt(d.X, d.Y); + t.Biome = (BiomeId)d.NewBiome; + t.Features = (FeatureFlags)d.NewFeatures; + } + + if (body.PlayerCharacter is not null) + player.Character = CharacterCodec.Restore(body.PlayerCharacter, _content); + + _killedByChunk.Clear(); + foreach (var d in body.NpcRoster.ChunkDeltas) + { + var coord = new ChunkCoord(d.ChunkX, d.ChunkY); + _killedByChunk[coord] = new HashSet(d.KilledSpawnIndices); + } + + // Defer the mid-combat encounter restore until M8 wires the combat + // HUD — but keep the body so a re-save round-trips byte-identical. + _pendingEncounterRestore = body.ActiveEncounter; + + // Reputation aggregate — mutate the existing instance in place so + // consumers holding a reference (future ReputationScreen / dialogue + // runner) keep working. + var restoredRep = ReputationCodec.Restore(body.ReputationState); + _reputation.Factions.Clear(); + foreach (var (k, v) in restoredRep.Factions.Standings) _reputation.Factions.Set(k, v); + _reputation.Personal.Clear(); + foreach (var (k, v) in restoredRep.Personal) _reputation.Personal[k] = v; + _reputation.Ledger.Clear(); + foreach (var ev in restoredRep.Ledger.Entries) _reputation.Ledger.Append(ev); + + _flags.Clear(); + foreach (var (k, v) in body.Flags) _flags[k] = v; + + QuestCodec.Restore(_questEngine, body.QuestEngineState); + } + + private void FlashSavedToast(string text) + { + _saveFlashText = text; + _saveFlashTimer = 2.5f; + } + // ────────────────────────────────────────────────────────────────────── // Spawn + initial-zoom helpers @@ -293,7 +503,6 @@ public partial class PlayScreen : Control private void SetInitialZoom() { - // Frame ~24 tiles across the screen — comfortable overland zoom. Vector2 viewport = GetViewport().GetVisibleRect().Size; float targetZoom = viewport.X / (24f * C.WORLD_TILE_PIXELS); targetZoom = Mathf.Clamp(targetZoom, @@ -303,7 +512,7 @@ public partial class PlayScreen : Control } // ────────────────────────────────────────────────────────────────────── - // HUD overlay (top-left panel, codex-styled) + // HUD overlay (top-left panel, codex-styled) + save-flash toast private void BuildHud() { @@ -334,6 +543,22 @@ public partial class PlayScreen : Control MouseFilter = MouseFilterEnum.Ignore, }; margin.AddChild(_hudLabel); + + // Save-flash toast, mounted bottom-center on the same canvas + // layer. Hidden by default; FlashSavedToast pops it in. + _saveFlashLabel = new Label + { + Text = "", + ThemeTypeVariation = "Eyebrow", + HorizontalAlignment = HorizontalAlignment.Center, + MouseFilter = MouseFilterEnum.Ignore, + AnchorLeft = 0.5f, AnchorRight = 0.5f, + AnchorTop = 1.0f, AnchorBottom = 1.0f, + OffsetLeft = -180, OffsetRight = 180, + OffsetTop = -56, OffsetBottom = -28, + Visible = false, + }; + hudLayer.AddChild(_saveFlashLabel); } private void UpdateHud(bool tactical) @@ -369,20 +594,11 @@ public partial class PlayScreen : Control $"{viewBlock}\n" + $"Time: Day {_clock.Day}, {_clock.Hour:D2}:{_clock.Minute:D2}\n" + $"{status}\n" + - "M7.3 brings save/load. ESC pause arrives M7.4."; + "F5 quicksaves · Esc → title (pause menu lands M7.4)"; } // ────────────────────────────────────────────────────────────────────── - // Quit path (M7.4 will wire ESC → pause; for now Esc returns to title) - - public override void _Input(InputEvent @event) - { - if (@event is InputEventKey { Pressed: true, Keycode: Key.Escape }) - { - GetViewport().SetInputAsHandled(); - BackToTitle(); - } - } + // Quit path private void BackToTitle() { diff --git a/Theriapolis.Godot/Scenes/SaveLoadScreen.cs b/Theriapolis.Godot/Scenes/SaveLoadScreen.cs new file mode 100644 index 0000000..115a0e8 --- /dev/null +++ b/Theriapolis.Godot/Scenes/SaveLoadScreen.cs @@ -0,0 +1,171 @@ +using System; +using System.IO; +using Godot; +using Theriapolis.Core; +using Theriapolis.Core.Persistence; +using Theriapolis.GodotHost.Platform; +using Theriapolis.GodotHost.UI; + +namespace Theriapolis.GodotHost.Scenes; + +/// +/// M7.3 — slot picker for *load*. Pushed by TitleScreen's "Continue" +/// when at least one compatible save exists. Lists the autosave row +/// followed by slots 1..; reads each +/// slot's header (cheap) for the label and disables incompatible / +/// unreadable rows. +/// +/// On slot click: deserialise, stash the body + header + seed on +/// , swap to +/// which will hand off to with the +/// restore-from-save path. +/// +/// Save-from-pause (write) is M7.4 territory and intentionally lives +/// in a separate widget — keeps each picker single-purpose. +/// +public partial class SaveLoadScreen : Control +{ + public override void _Ready() + { + Theme = CodexTheme.Build(); + SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); + + var bg = new Panel { MouseFilter = MouseFilterEnum.Ignore }; + AddChild(bg); + bg.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); + MoveChild(bg, 0); + + BuildUI(); + } + + private void BuildUI() + { + var center = new CenterContainer { MouseFilter = MouseFilterEnum.Ignore }; + AddChild(center); + center.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); + + var col = new VBoxContainer { CustomMinimumSize = new Vector2(520, 0) }; + col.AddThemeConstantOverride("separation", 10); + center.AddChild(col); + + col.AddChild(new Label + { + Text = "LOAD GAME", + ThemeTypeVariation = "H2", + HorizontalAlignment = HorizontalAlignment.Center, + }); + + // Autosave row first; numbered slots after. + AddSlotRow(col, "Autosave", SavePaths.AutosavePath()); + for (int i = 1; i <= C.SAVE_SLOT_COUNT; i++) + AddSlotRow(col, $"Slot {i:D2}", SavePaths.SlotPath(i)); + + var spacer = new Control { CustomMinimumSize = new Vector2(0, 12) }; + col.AddChild(spacer); + + var back = new Button + { + Text = "← Back", + CustomMinimumSize = new Vector2(220, 40), + SizeFlagsHorizontal = SizeFlags.ShrinkCenter, + }; + back.Pressed += BackToTitle; + col.AddChild(back); + } + + private void AddSlotRow(VBoxContainer parent, string label, string path) + { + string text; + bool clickable = false; + bool exists = File.Exists(path); + if (exists) + { + try + { + var bytes = File.ReadAllBytes(path); + var header = SaveCodec.DeserializeHeaderOnly(bytes); + if (SaveCodec.IsCompatible(header)) + { + text = $"{label} — {header.SlotLabel()}"; + clickable = true; + } + else + { + text = $"{label} — "; + } + } + catch (Exception ex) + { + text = $"{label} — "; + } + } + else + { + text = $"{label} — "; + } + + var btn = new Button + { + Text = text, + CustomMinimumSize = new Vector2(0, 40), + SizeFlagsHorizontal = SizeFlags.ExpandFill, + Disabled = !clickable, + Alignment = HorizontalAlignment.Left, + }; + if (clickable) btn.Pressed += () => LoadSlot(path); + parent.AddChild(btn); + } + + private void LoadSlot(string path) + { + try + { + var bytes = File.ReadAllBytes(path); + var (header, body) = SaveCodec.Deserialize(bytes); + if (!SaveCodec.IsCompatible(header)) + { + GD.PushError($"[saveload] Refused incompatible save at {path}: " + + SaveCodec.IncompatibilityReason(header)); + return; + } + + var session = GameSession.From(this); + session.Seed = header.ParseSeed(); + session.PendingRestore = body; + session.PendingHeader = header; + session.PendingCharacter = null; // restore path supplies it via body + + // Swap Title → WorldGenProgress (which will swap to PlayScreen + // once the pipeline finishes and stage-hash drift is checked). + var parent = GetParent(); + if (parent is null) return; + foreach (Node sibling in parent.GetChildren()) + if (sibling != this) sibling.QueueFree(); + parent.AddChild(new WorldGenProgressScreen()); + QueueFree(); + } + catch (Exception ex) + { + GD.PushError($"[saveload] Failed to load {path}: {ex}"); + } + } + + private void BackToTitle() + { + var parent = GetParent(); + if (parent is null) return; + foreach (Node sibling in parent.GetChildren()) + if (sibling != this) sibling.QueueFree(); + parent.AddChild(new TitleScreen()); + QueueFree(); + } + + public override void _UnhandledInput(InputEvent @event) + { + if (@event is InputEventKey { Pressed: true, Keycode: Key.Escape }) + { + GetViewport().SetInputAsHandled(); + BackToTitle(); + } + } +} diff --git a/Theriapolis.Godot/Scenes/TitleScreen.cs b/Theriapolis.Godot/Scenes/TitleScreen.cs index b88fc62..aff246d 100644 --- a/Theriapolis.Godot/Scenes/TitleScreen.cs +++ b/Theriapolis.Godot/Scenes/TitleScreen.cs @@ -18,7 +18,7 @@ namespace Theriapolis.GodotHost.Scenes; /// public partial class TitleScreen : Control { - private const string VersionLabel = "PORT / GODOT · M7.2"; + private const string VersionLabel = "PORT / GODOT · M7.3"; private const string WizardScenePath = "res://Scenes/Wizard.tscn"; public override void _Ready() @@ -70,7 +70,7 @@ public partial class TitleScreen : Control buttonStack.AddChild(newBtn); var continueBtn = MakeMenuButton("Continue", primary: false); - continueBtn.Disabled = !FileAccess.FileExists(CharacterAssembler.PersistedStatePath); + continueBtn.Disabled = !AnyCompatibleSaveExists(); continueBtn.Pressed += OnContinue; buttonStack.AddChild(continueBtn); @@ -162,12 +162,38 @@ public partial class TitleScreen : Control private void OnContinue() { - // M7 territory — the play-loop screens that consume the persisted - // character don't exist yet. For now, surface a print so the click - // does something visible and the button isn't dead UI. - GD.Print($"[title] Continue: {CharacterAssembler.PersistedStatePath} exists. " - + "Play-loop pickup lands with M7."); + var parent = GetParent(); + if (parent is null) return; + foreach (Node sibling in parent.GetChildren()) + if (sibling != this) sibling.QueueFree(); + parent.AddChild(new SaveLoadScreen()); + QueueFree(); } private void OnQuit() => GetTree().Quit(); + + /// True iff at least one slot under + /// has a header that + /// accepts. Cheap: + /// reads only the JSON prefix, not the binary body. + private static bool AnyCompatibleSaveExists() + { + try + { + string dir = Platform.SavePaths.SavesDir; + if (!System.IO.Directory.Exists(dir)) return false; + foreach (var path in System.IO.Directory.EnumerateFiles(dir, "*.trps")) + { + try + { + var bytes = System.IO.File.ReadAllBytes(path); + var header = Theriapolis.Core.Persistence.SaveCodec.DeserializeHeaderOnly(bytes); + if (Theriapolis.Core.Persistence.SaveCodec.IsCompatible(header)) return true; + } + catch { /* skip broken slot */ } + } + } + catch { /* defensive */ } + return false; + } }