feat(phase-b): cross-scene state — GameSession + play runner
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 <SCENE...> [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) <noreply@anthropic.com>
This commit is contained in:
@@ -64,6 +64,33 @@ if (args[0] == "gfx")
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args[0] == "play")
|
||||||
|
{
|
||||||
|
// play <SCENE.BIN...> [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 <SCENE.BIN...> [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")
|
if (args[0] == "trace")
|
||||||
{
|
{
|
||||||
var scene = new Regex(@"^S[CP]\d{4}\.BIN$");
|
var scene = new Regex(@"^S[CP]\d{4}\.BIN$");
|
||||||
|
|||||||
96
engine/Age.Engine.Tests/GameSessionTests.cs
Normal file
96
engine/Age.Engine.Tests/GameSessionTests.cs
Normal file
@@ -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<int, int> { { 0, 0 } },
|
||||||
|
Strings = new Dictionary<int, string>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class VoiceCountHost : IHost
|
||||||
|
{
|
||||||
|
public int Voices;
|
||||||
|
public List<int> 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
43
engine/Age.Engine/Vm/GameSession.cs
Normal file
43
engine/Age.Engine/Vm/GameSession.cs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
using Age.Engine.Hosting;
|
||||||
|
using Age.Engine.Model;
|
||||||
|
|
||||||
|
namespace Age.Engine.Vm;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
///
|
||||||
|
/// <para>Seam rule: references only <c>Model</c> + <c>Hosting</c> (never <c>Sys4</c>). The
|
||||||
|
/// <see cref="VirtualMachine"/> is unchanged — its globals are pre-seeded before <c>Run</c> 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).</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GameSession
|
||||||
|
{
|
||||||
|
public Dictionary<int, long> Globals { get; } = new();
|
||||||
|
public Dictionary<int, string> GlobalStrings { get; } = new();
|
||||||
|
|
||||||
|
public void Seed(int addr, long value) => Globals[addr] = value;
|
||||||
|
public void SeedString(int addr, string value) => GlobalStrings[addr] = value;
|
||||||
|
|
||||||
|
/// <summary>Run one scene: seed a fresh VM from session state, execute, merge final state back.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The observable result of running one scene into a <see cref="GameSession"/>.</summary>
|
||||||
|
public sealed record SceneResult(IReadOnlyList<(int Offset, string Text)> Emitted, string? Halt, long Steps);
|
||||||
Reference in New Issue
Block a user