diff --git a/engine/Age.Cli/Program.cs b/engine/Age.Cli/Program.cs index 403034b..9680be1 100644 --- a/engine/Age.Cli/Program.cs +++ b/engine/Age.Cli/Program.cs @@ -77,7 +77,12 @@ if (args[0] == "play") var userScenes = args.Skip(1).Where(a => a.ToUpperInvariant().EndsWith(".BIN")).ToList(); if (userScenes.Count == 0) { Console.WriteLine("usage: play [--boot] [0xADDR=VAL ...]"); return 1; } var scenes = (boot ? bootScripts.Concat(userScenes) : userScenes).ToList(); - var session = new GameSession(); + // --state : start from a saved snapshot (e.g. a pre-booted state) instead of booting fresh. + string? StateArg(string flag) { int i = Array.IndexOf(args, flag); return i >= 0 && i + 1 < args.Length ? args[i + 1] : null; } + var loadState = StateArg("--state"); + var saveState = StateArg("--save-state"); + var session = loadState != null ? GameSession.FromJson(File.ReadAllText(loadState)) : new GameSession(); + if (loadState != null) Console.WriteLine($"[state] loaded {session.Globals.Count} globals from {loadState}"); foreach (var s in args.Skip(1).Where(a => a.Contains('='))) { var kv = s.Split('='); @@ -94,6 +99,7 @@ if (args[0] == "play") Console.WriteLine($" {name,-14} {r.Emitted.Count,4} lines, {r.Steps,7} steps (halt: {r.Halt})"); } Console.WriteLine($"total: {totalLines} lines across {scenes.Count} scene(s); {session.Globals.Count} globals carried"); + if (saveState != null) { File.WriteAllText(saveState, session.ToJson()); Console.WriteLine($"[state] saved -> {saveState}"); } return 0; } diff --git a/engine/Age.Engine.Tests/GameSessionTests.cs b/engine/Age.Engine.Tests/GameSessionTests.cs index 418a1e2..a0eed2a 100644 --- a/engine/Age.Engine.Tests/GameSessionTests.cs +++ b/engine/Age.Engine.Tests/GameSessionTests.cs @@ -90,6 +90,30 @@ public class GameSessionTests Assert.True(session.Globals.Count > 1000, $"SKINIT should populate the skill table (got {session.Globals.Count})"); } + [Fact] + public void SnapshotRoundTripsState() + { + var s = new GameSession(); + s.Seed(0x10, 42); s.Seed(0x20, -7); s.SeedString(0x30, "リリィ"); + var t = GameSession.FromJson(s.ToJson()); + Assert.Equal(42, t.Globals[0x10]); + Assert.Equal(-7, t.Globals[0x20]); + Assert.Equal("リリィ", t.GlobalStrings[0x30]); + Assert.Equal(s.Globals.Count, t.Globals.Count); + } + + [Fact] + public void BootedStateSurvivesSnapshot() + { + var s = new GameSession(); + s.RunScene(Sys4Loader.Load(Paths.Scripts()["SKINIT.BIN"], Table), Table, new CaptureHost()); + var t = GameSession.FromJson(s.ToJson()); // snapshot the expensive booted state, rebuild + Assert.Equal("飛行", t.GlobalStrings[0x23a3]); + Assert.Equal(30, t.Globals[0xa6e5b]); + Assert.Equal(s.Globals.Count, t.Globals.Count); + Assert.Equal(s.GlobalStrings.Count, t.GlobalStrings.Count); + } + [Fact] public void SeedingFormFlagChangesBehavior() { diff --git a/engine/Age.Engine/Vm/GameSession.cs b/engine/Age.Engine/Vm/GameSession.cs index 81f1760..0698e79 100644 --- a/engine/Age.Engine/Vm/GameSession.cs +++ b/engine/Age.Engine/Vm/GameSession.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Age.Engine.Hosting; using Age.Engine.Model; @@ -37,6 +38,29 @@ public sealed class GameSession return new SceneResult(vm.Emitted.ToList(), vm.HaltReason, vm.Steps); } + + /// Serialize the persistent state to JSON (globals + string globals, keyed by decimal address). + /// Lets an expensive booted state be snapshotted and reused; foundation for save-file work. + public string ToJson() + { + var snap = new StateSnapshot( + Globals.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value), + GlobalStrings.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value)); + return JsonSerializer.Serialize(snap, + new JsonSerializerOptions { Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping }); + } + + /// Rebuild a session from a snapshot. + public static GameSession FromJson(string json) + { + var s = new GameSession(); + var snap = JsonSerializer.Deserialize(json) ?? new StateSnapshot(new(), new()); + foreach (var kv in snap.Globals) s.Globals[int.Parse(kv.Key)] = kv.Value; + foreach (var kv in snap.Strings) s.GlobalStrings[int.Parse(kv.Key)] = kv.Value; + return s; + } + + private sealed record StateSnapshot(Dictionary Globals, Dictionary Strings); } /// The observable result of running one scene into a .