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:
gamer147
2026-07-07 00:19:11 -04:00
parent dc78a92ecd
commit b762b3d1c0
3 changed files with 166 additions and 0 deletions

View 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}");
}
}