From b762b3d1c08bf01b75ee2b168f8a34dbc618f648 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 7 Jul 2026 00:19:11 -0400 Subject: [PATCH] =?UTF-8?q?feat(phase-b):=20cross-scene=20state=20?= =?UTF-8?q?=E2=80=94=20GameSession=20+=20play=20runner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persistent global store carried across scenes (the engine's flat global bank), the substrate for cross-scene flow and state seeding. GameSession seeds a fresh VM from session state, runs, merges back; VM untouched so trace/selftest parity holds. Age.Cli play [0xADDR=VAL] runs a sequence carrying state. Tests (engine 15/15): state persists A->B; seed visible to scene; SC0000 via session byte-identical to single run; seeding form flag 0xa57=1 changes behavior. Demonstrated: play SC0000 0xa57=1 -> 186->229 lines (Lily's form-gated dialogue executes); SC0000->SC0030 carries 76 globals. Operationalizes the state- divergence finding. Godot selftest OK. Co-Authored-By: Claude Opus 4.8 (1M context) --- engine/Age.Cli/Program.cs | 27 ++++++ engine/Age.Engine.Tests/GameSessionTests.cs | 96 +++++++++++++++++++++ engine/Age.Engine/Vm/GameSession.cs | 43 +++++++++ 3 files changed, 166 insertions(+) create mode 100644 engine/Age.Engine.Tests/GameSessionTests.cs create mode 100644 engine/Age.Engine/Vm/GameSession.cs diff --git a/engine/Age.Cli/Program.cs b/engine/Age.Cli/Program.cs index 5c08fce..31453c0 100644 --- a/engine/Age.Cli/Program.cs +++ b/engine/Age.Cli/Program.cs @@ -64,6 +64,33 @@ if (args[0] == "gfx") return 0; } +if (args[0] == "play") +{ + // play [0xADDR=VAL ...] — run a sequence of scenes carrying persistent global state + // across them (optional up-front seeds). The state substrate for cross-scene flow; headless. + var scripts = Paths.Scripts(); + var scenes = args.Skip(1).Where(a => a.ToUpperInvariant().EndsWith(".BIN")).ToList(); + if (scenes.Count == 0) { Console.WriteLine("usage: play [0xADDR=VAL ...]"); return 1; } + var session = new GameSession(); + foreach (var s in args.Skip(1).Where(a => a.Contains('='))) + { + var kv = s.Split('='); + int k = kv[0].StartsWith("0x") ? Convert.ToInt32(kv[0], 16) : int.Parse(kv[0]); + long v = kv[1].StartsWith("0x") ? Convert.ToInt64(kv[1], 16) : long.Parse(kv[1]); + session.Seed(k, v); + } + long totalLines = 0; + foreach (var name in scenes) + { + var script = Sys4Loader.Load(scripts[name.ToUpperInvariant()], table); + var r = session.RunScene(script, table, new CaptureHost()); + totalLines += r.Emitted.Count; + 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"); + return 0; +} + if (args[0] == "trace") { var scene = new Regex(@"^S[CP]\d{4}\.BIN$"); diff --git a/engine/Age.Engine.Tests/GameSessionTests.cs b/engine/Age.Engine.Tests/GameSessionTests.cs new file mode 100644 index 0000000..46ffed3 --- /dev/null +++ b/engine/Age.Engine.Tests/GameSessionTests.cs @@ -0,0 +1,96 @@ +using System.Collections.Generic; +using System.Linq; +using Age.Engine.Hosting; +using Age.Engine.Model; +using Age.Engine.Sys4; +using Age.Engine.Vm; +using Xunit; + +public class GameSessionTests +{ + private const int T_IMM = 0, T_GINT = 3; + private static readonly OpcodeTable Table = OpcodeTableJson.Load(Paths.OpcodesJson); + + // A minimal one-instruction script: `mov (global-int dst) src`. Halts pc-out-of-range after. + private static Script MovScript(int dst, int srcType, long srcVal) => new() + { + Header = new ScriptHeader(0, 0, 0, 0, 0, 0), + Instructions = new[] + { + new Instruction(0, 0x55, new[] { new Operand(T_GINT, dst), new Operand(srcType, srcVal) }), + }, + IndexByOffset = new Dictionary { { 0, 0 } }, + Strings = new Dictionary(), + }; + + private sealed class VoiceCountHost : IHost + { + public int Voices; + public List Emitted = new(); + public void ShowText(int o, string t) => Emitted.Add(o); + public void CallScript(long id) { } + public void OnStub(int op) { } + public void WaitForInput() { } + public void CreateTexture(int s, int w, int h) { } + public void SetTexture(long r, int s) { } + public void DrawTexture(int s, int sx, int sy, int w, int h, int dx, int dy) { } + public (int Width, int Height) GetTextureSize(int slot) => (0, 0); + public void PlayBgm(long id) { } + public void PlayVoice(long id) => Voices++; + } + + [Fact] + public void StatePersistsAcrossScenes() + { + var session = new GameSession(); + // Scene A writes G[0x5000] = 42. + session.RunScene(MovScript(0x5000, T_IMM, 0x2a), Table, new CaptureHost()); + Assert.Equal(0x2a, session.Globals[0x5000]); + // Scene B copies G[0x5000] -> G[0x5001]; it must SEE A's write. + session.RunScene(MovScript(0x5001, T_GINT, 0x5000), Table, new CaptureHost()); + Assert.Equal(0x2a, session.Globals[0x5001]); + } + + [Fact] + public void SeedIsVisibleToTheScene() + { + var session = new GameSession(); + session.Seed(0x5000, 99); + session.RunScene(MovScript(0x5001, T_GINT, 0x5000), Table, new CaptureHost()); + Assert.Equal(99, session.Globals[0x5001]); + } + + [Fact] + public void SC0000ViaSessionMatchesSingleRun() + { + var script = Sys4Loader.Load(Paths.Scripts()["SC0000.BIN"], Table); + // single run + var host1 = new CaptureHost(); + new VirtualMachine(script, Table, host1).Run(); + var single = host1.Emitted.Select(e => e.Offset).ToList(); + // via session + var host2 = new CaptureHost(); + var r = new GameSession().RunScene(Sys4Loader.Load(Paths.Scripts()["SC0000.BIN"], Table), Table, host2); + var viaSession = r.Emitted.Select(e => e.Offset).ToList(); + Assert.Equal(single, viaSession); + Assert.Equal("exit", r.Halt); + } + + [Fact] + public void SeedingFormFlagChangesBehavior() + { + // Lily's lines are gated on form flags G[0xa57/8/9]; unseeded => all skipped (0 voices on her lines). + // Seeding form A (0xa57=1) makes her lines execute -> more play-voice calls. Proven finding (audio). + var scriptPath = Paths.Scripts()["SC0000.BIN"]; + var unseeded = new VoiceCountHost(); + new GameSession().RunScene(Sys4Loader.Load(scriptPath, Table), Table, unseeded); + + var s = new GameSession(); + s.Seed(0xa57, 1); + var seeded = new VoiceCountHost(); + s.RunScene(Sys4Loader.Load(scriptPath, Table), Table, seeded); + + Assert.True(seeded.Voices > unseeded.Voices, + $"seeding form flag should fire more voices: unseeded={unseeded.Voices} seeded={seeded.Voices}"); + } +} diff --git a/engine/Age.Engine/Vm/GameSession.cs b/engine/Age.Engine/Vm/GameSession.cs new file mode 100644 index 0000000..81f1760 --- /dev/null +++ b/engine/Age.Engine/Vm/GameSession.cs @@ -0,0 +1,43 @@ +using Age.Engine.Hosting; +using Age.Engine.Model; + +namespace Age.Engine.Vm; + +/// +/// A persistent global store carried across scenes. Every scene the game runs shares one flat global +/// bank (the engine's model); running scenes in isolation with empty state is why our headless VM +/// diverges from the real game (the bg/sprite geometry drift, the state-gated EMPTY scenes, Lily's +/// form-gated voices are all state divergence — see docs/phase-a-slice-plan.md A2b-Geometry). +/// +/// Seam rule: references only Model + Hosting (never Sys4). The +/// is unchanged — its globals are pre-seeded before Run and merged +/// back after, so trace/selftest parity is untouched. Local frames are per-call and correctly do NOT +/// persist (they live in the VM, not here). +/// +public sealed class GameSession +{ + public Dictionary Globals { get; } = new(); + public Dictionary GlobalStrings { get; } = new(); + + public void Seed(int addr, long value) => Globals[addr] = value; + public void SeedString(int addr, string value) => GlobalStrings[addr] = value; + + /// Run one scene: seed a fresh VM from session state, execute, merge final state back. + public SceneResult RunScene(Script script, OpcodeTable table, IHost host, VmOptions? options = null) + { + var vm = new VirtualMachine(script, table, host, options); + foreach (var kv in Globals) vm.Globals[kv.Key] = kv.Value; + foreach (var kv in GlobalStrings) vm.GlobalStrings[kv.Key] = kv.Value; + + vm.Run(); + + // Globals are one flat space; last write wins — the engine's single global bank. + foreach (var kv in vm.Globals) Globals[kv.Key] = kv.Value; + foreach (var kv in vm.GlobalStrings) GlobalStrings[kv.Key] = kv.Value; + + return new SceneResult(vm.Emitted.ToList(), vm.HaltReason, vm.Steps); + } +} + +/// The observable result of running one scene into a . +public sealed record SceneResult(IReadOnlyList<(int Offset, string Text)> Emitted, string? Halt, long Steps);