Merge: Phase B — cross-scene state substrate
The Frida-free foundation for cross-scene playthrough and state seeding: - GameSession: persistent global bank carried across scenes (VM untouched, parity held). - play [--boot]: run a scene sequence carrying state; --boot runs the 9 *INIT data scripts into the bank (23646 globals) so scenes see real skill/item/unit data. - Snapshot save/load (--state/--save-state): capture an expensive booted state once, reuse it; foundation for save-files. - sweep [--boot]: corpus-scale validation (matches vm0.py: 294 exit + 3 LOOP). Findings operationalized: seeding the Lily form flag 0xa57=1 takes SC0000 186->229 lines (state divergence, headless). Data-boot is regression-free but doesn't change ADV flow — story-state flags drive that. Engine 18/18; Godot selftest OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -64,6 +64,81 @@ if (args[0] == "gfx")
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args[0] == "play")
|
||||
{
|
||||
// play [--boot] <SCENE.BIN...> [0xADDR=VAL ...] — run a sequence of scenes carrying persistent global
|
||||
// state across them (optional up-front seeds). --boot first runs the data-table *INIT scripts so scenes
|
||||
// see the real skill/item/unit/etc. state. The state substrate for cross-scene flow; headless.
|
||||
// The *INIT boot set — all run clean (halt: exit) and populate the game's data tables into globals.
|
||||
string[] bootScripts = { "SKINIT.BIN", "ITINIT.BIN", "EBINIT.BIN", "CGINIT.BIN", "MPINIT.BIN",
|
||||
"AFINIT.BIN", "CCINIT.BIN", "STINIT.BIN", "STINIT2.BIN" };
|
||||
var scripts = Paths.Scripts();
|
||||
bool boot = args.Contains("--boot");
|
||||
var userScenes = args.Skip(1).Where(a => a.ToUpperInvariant().EndsWith(".BIN")).ToList();
|
||||
if (userScenes.Count == 0) { Console.WriteLine("usage: play [--boot] <SCENE.BIN...> [0xADDR=VAL ...]"); return 1; }
|
||||
var scenes = (boot ? bootScripts.Concat(userScenes) : userScenes).ToList();
|
||||
// --state <file>: 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('=');
|
||||
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");
|
||||
if (saveState != null) { File.WriteAllText(saveState, session.ToJson()); Console.WriteLine($"[state] saved -> {saveState}"); }
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args[0] == "sweep")
|
||||
{
|
||||
// sweep [--boot] — run every SC/SP scene through GameSession (each from a fresh or booted-from-snapshot
|
||||
// baseline) and report halt distribution + line counts. Validates the VM + state substrate at scale and
|
||||
// surfaces how booted real data affects the corpus. Headless.
|
||||
var sceneRe = new Regex(@"^S[CP]\d{4}\.BIN$");
|
||||
var scripts = Paths.Scripts();
|
||||
var names = scripts.Keys.Where(n => sceneRe.IsMatch(n)).OrderBy(n => n, StringComparer.Ordinal).ToList();
|
||||
bool boot = args.Contains("--boot");
|
||||
string? baseline = null;
|
||||
if (boot)
|
||||
{
|
||||
var bootSession = new GameSession();
|
||||
foreach (var s in new[] { "SKINIT.BIN", "ITINIT.BIN", "EBINIT.BIN", "CGINIT.BIN", "MPINIT.BIN",
|
||||
"AFINIT.BIN", "CCINIT.BIN", "STINIT.BIN", "STINIT2.BIN" })
|
||||
bootSession.RunScene(Sys4Loader.Load(scripts[s], table), table, new CaptureHost());
|
||||
baseline = bootSession.ToJson();
|
||||
Console.WriteLine($"[boot] baseline = {bootSession.Globals.Count} globals; running {names.Count} scenes from it.");
|
||||
}
|
||||
var haltDist = new SortedDictionary<string, int>(StringComparer.Ordinal);
|
||||
long totalLines = 0; var anomalies = new List<string>();
|
||||
foreach (var name in names)
|
||||
{
|
||||
var session = baseline != null ? GameSession.FromJson(baseline) : new GameSession();
|
||||
var r = session.RunScene(Sys4Loader.Load(scripts[name], table), table, new CaptureHost());
|
||||
var halt = r.Halt ?? "null";
|
||||
haltDist[halt] = haltDist.GetValueOrDefault(halt) + 1;
|
||||
totalLines += r.Emitted.Count;
|
||||
if (halt != "exit") anomalies.Add($"{name}: {r.Emitted.Count} lines, halt={halt}");
|
||||
}
|
||||
Console.WriteLine($"swept {names.Count} scenes{(boot ? " (booted)" : "")}: {totalLines} total lines");
|
||||
Console.WriteLine("halt distribution: " + string.Join(", ", haltDist.Select(kv => $"{kv.Key}={kv.Value}")));
|
||||
if (anomalies.Count > 0) { Console.WriteLine($"non-exit halts ({anomalies.Count}):"); foreach (var a in anomalies) Console.WriteLine(" " + a); }
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args[0] == "trace")
|
||||
{
|
||||
var scene = new Regex(@"^S[CP]\d{4}\.BIN$");
|
||||
|
||||
134
engine/Age.Engine.Tests/GameSessionTests.cs
Normal file
134
engine/Age.Engine.Tests/GameSessionTests.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
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 BootingSkinitPopulatesDataTableGlobals()
|
||||
{
|
||||
// Running the SKINIT data script through the session populates the real skill table into the
|
||||
// global bank (the game's boot behavior). Cross-check vs the static extraction (build/data/SKINIT.json):
|
||||
// skill 0 = "飛行" at global-string 0x23a3, field G[0xa6e5b] = 30.
|
||||
var session = new GameSession();
|
||||
var r = session.RunScene(Sys4Loader.Load(Paths.Scripts()["SKINIT.BIN"], Table), Table, new CaptureHost());
|
||||
Assert.Equal("exit", r.Halt);
|
||||
Assert.Equal("飛行", session.GlobalStrings[0x23a3]);
|
||||
Assert.Equal(30, session.Globals[0xa6e5b]);
|
||||
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()
|
||||
{
|
||||
// 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}");
|
||||
}
|
||||
}
|
||||
67
engine/Age.Engine/Vm/GameSession.cs
Normal file
67
engine/Age.Engine/Vm/GameSession.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using System.Text.Json;
|
||||
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>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.</summary>
|
||||
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 });
|
||||
}
|
||||
|
||||
/// <summary>Rebuild a session from a <see cref="ToJson"/> snapshot.</summary>
|
||||
public static GameSession FromJson(string json)
|
||||
{
|
||||
var s = new GameSession();
|
||||
var snap = JsonSerializer.Deserialize<StateSnapshot>(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<string, long> Globals, Dictionary<string, string> Strings);
|
||||
}
|
||||
|
||||
/// <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