Validate persistence through real SAVE UI

This commit is contained in:
gamer147
2026-07-24 16:48:32 -04:00
parent 001a5706f6
commit f4f2a11f46
3 changed files with 254 additions and 3 deletions

View File

@@ -3499,6 +3499,27 @@ implementation status. The remaining 1.0 persistence work is
gameplay validation through Himegari's real SAVE.BIN UI and any corrections that reveals; JSON inspection,
namespaced mod data, and migrations remain extended-mode work.
### Persistence implementation step 6 — real SAVE.BIN integration gate (2026-07-24)
The native persistence path is now exercised through Himegari's actual `SAVE.BIN` bytecode rather than
only synthetic opcode programs. A deterministic native pair proves the menu queries slot metadata,
rasterizes its timestamp/playtime fields, and decodes the paired 112x84 `.STH` thumbnail into the menu
surface. A scripted native mouse press/release on row zero then traverses the real load-mode control flow,
executes `0x1a1`, runs `CALLBACK_LOAD.BIN`, and reaches the saved script's active `0xae` rendezvous.
A second, read-only compatibility gate points the same real-script path at the installed Himegari
`SAVE00.DAT`. It decodes the complete native layout, resolves the installed save's first persisted script
through the mounted SYS4 catalogs, and enters that frame with `FrameCause.SaveRestore`; the test stops at
that boundary before gameplay continues and never writes to the original AppData tree. No codec, VM, or
script-control correction was required by these gates.
The Godot profile remains intentionally isolated at `user://SAVE`. For visual acceptance, a copied
`SAVE00.DAT`/`.STH` pair can be placed there without exposing the original save directory to writes.
JSON inspection/export, namespaced mod state, migrations, and richer import UX remain extended-mode work.
Validation: all 386 engine tests pass, opcode lint reports zero errors/warnings, the Godot C# build has
zero warnings, and the threaded headless run reports `SELFTEST OK`.
## Data-semantics sidebar: focused append EBINIT inspection (2026-07-24)
The static INIT surface now accepts a universal packed script id for focused append inspection.

View File

@@ -471,9 +471,11 @@ replacement for compatibility-mode import/export. The recovered native contract
their four native opcodes while opaque catalog/version sections round-trip unchanged. Native `RT.DAT`
import/export and the packed-script/T1 ReadTextDB queue/commit/query lifecycle are also implemented,
including `message:ReadTextSkip` ops `0x1ca`/`0x1cb` and state query `0x1cc`. Numbered active-frame state
remains later Phase B work. The outer numbered layer is now live: metadata query, paired `.DAT`/`.STH`
copy/delete, exact native BMP thumbnail I/O, and the active-frame boundary marker. Full layout-3 payload
serialization/restoration is the next persistence slice.
is now implemented in native layout 3: metadata query, paired `.DAT`/`.STH` lifecycle, exact native BMP
thumbnail I/O, six global banks, retained surface/gfx state, history, and nested frame restoration through
the `0xae` rendezvous. The real `SAVE.BIN` script is covered end to end for listing and loading, including
a read-only installed-save compatibility gate. JSON inspection/export, namespaced mod data, and migrations
remain additive extended-mode work rather than 1.0 compatibility requirements.
### Phase C — Externalize & modding foundation
- Add **editable named data overlays** mapped explicitly onto the VM's `*INIT`-produced state; external

View File

@@ -0,0 +1,228 @@
using Age.Engine.Diagnostics;
using Age.Engine.Hosting;
using Age.Engine.Model;
using Age.Engine.Persistence;
using Age.Engine.Sys4;
using Age.Engine.Vm;
public class SaveUiIntegrationTests
{
private sealed class MenuReadyException : Exception;
private sealed class InstalledResumeReachedException : Exception;
private sealed class StopAtFirstMenuPollHost : RecordingHost
{
public override void Sleep(long duration)
{
base.Sleep(duration);
throw new MenuReadyException();
}
}
private sealed class ClickFirstSlotHost : RecordingHost
{
private long _now;
private bool _pressed;
private bool _released;
public VirtualMachine Vm { get; set; } = null!;
public override long InputClockMilliseconds => _now;
public override void Sleep(long duration)
{
base.Sleep(duration);
_now += Math.Max(1, duration);
if (!_pressed)
{
Vm.UpdatePointer(400, 70);
Vm.UpdateMouseButtonState(0x1, true);
_pressed = true;
}
if (!_released && _now >= 32)
{
Vm.UpdateMouseButtonState(0x1, false);
_released = true;
}
}
}
private sealed class SaveUiProvider(
Sys4ScriptProvider native,
Script? resumed,
Script loadCallback) : IScriptProvider
{
public Script? GetById(long id)
=> resumed != null && id == resumed.PackedId ? resumed : native.GetById(id);
public Script? GetByName(string name)
=> name.Equals("CALLBACK_LOAD.BIN", StringComparison.OrdinalIgnoreCase)
? loadCallback
: native.GetByName(name);
public IReadOnlyList<int> MountedAppendSelectors => native.MountedAppendSelectors;
}
private sealed class StopAtInstalledResumeSink : ITraceSink
{
public bool TracingSteps => false;
public void Emit(in TraceEvent item)
{
if (item.Kind == TraceEventKind.FrameEnter
&& item.Cause == FrameCause.SaveRestore
&& !string.Equals(item.Name, "CALLBACK_LOAD.BIN",
StringComparison.OrdinalIgnoreCase))
throw new InstalledResumeReachedException();
}
}
private static readonly OpcodeTable Table = OpcodeTableJson.Load(Paths.OpcodesJson);
private static readonly NativeSaveIdentity Identity =
new(NativeSaveMagic.S4SD, 0x4a343234, "姫狩りダンジョンマイスター",
3, 10, 0x42323234);
[Fact]
public void RealSaveBinListsNativeSlotMetadataAndThumbnail()
{
string root = NewTemporaryDirectory();
try
{
var store = new DirectoryNativeDatStore(root, Identity);
store.SaveNumbered(
0, [1, 2, 3, 4],
new NativeSystemTime(2026, 7, 5, 24, 13, 42, 17, 321),
7_445);
var pixels = new byte[112 * 84 * 4];
for (int i = 0; i < pixels.Length; i += 4)
{
pixels[i] = 0x12;
pixels[i + 1] = 0x34;
pixels[i + 2] = 0x56;
pixels[i + 3] = 0xff;
}
store.SaveNumberedThumbnail(
0, NumberedThumbnailCodec.Encode(new(112, 84, pixels)));
var scripts = Sys4ScriptProvider.Load(Table);
var host = new StopAtFirstMenuPollHost();
var vm = new VirtualMachine(
scripts.RequireByName("SAVE.BIN"), Table, host,
new VmOptions(MaxSteps: 500_000), scripts,
nativeDatStore: store);
Assert.Throws<MenuReadyException>(() => vm.Run());
RgbaImage thumbnail = Assert.Single(host.SurfacePixels.Values);
Assert.Equal((112, 84), (thumbnail.Width, thumbnail.Height));
Assert.Equal(new byte[] { 0x12, 0x34, 0x56, 0xff }, thumbnail.Pixels[..4]);
Assert.Contains(host.SurfaceStrings, item => item.Text == "2026");
Assert.Contains(host.SurfaceStrings, item => item.Text == "07");
Assert.Contains(host.SurfaceStrings, item => item.Text == "24");
Assert.Contains(host.SurfaceStrings, item => item.Text == "13");
}
finally
{
Directory.Delete(root, recursive: true);
}
}
[Fact]
public void RealSaveBinClickLoadsNativeSlotAndEntersRestoreRendezvous()
{
const uint resumedId = 0x00fefefe;
string root = NewTemporaryDirectory();
try
{
var store = new DirectoryNativeDatStore(root, Identity);
Script resumed = WithPackedId(ScriptAssembler.Assemble(Table, "SAVE_UI_RESUME.BIN",
[
(0xae, Array.Empty<Operand>()),
(Table.ByLabel("mov")!.Value,
[new Operand(3, 0x500), new Operand(0, 1)]),
(0x2, Array.Empty<Operand>()),
], []), resumedId);
Script loadCallback = WithPackedId(ScriptAssembler.Assemble(
Table, "CALLBACK_LOAD.BIN",
[(0x2, Array.Empty<Operand>())], []), 0x00fefefd);
NativeNumberedSaveState state = NativeNumberedSaveCodec.Empty(
[new NativeSavedScriptFrame(-1, resumedId, [], -1, -1)]);
store.SaveNumberedFile(
0, NativeNumberedSaveCodec.Encode(state), [],
new NativeSystemTime(2026, 7, 5, 24, 13, 42, 17, 321),
7_445);
var nativeScripts = Sys4ScriptProvider.Load(Table);
var provider = new SaveUiProvider(nativeScripts, resumed, loadCallback);
var host = new ClickFirstSlotHost();
var trace = new RecordingTraceSink();
var vm = new VirtualMachine(
nativeScripts.RequireByName("SAVE.BIN"), Table, host,
new VmOptions(MaxSteps: 1_000_000), provider, trace,
nativeDatStore: store);
host.Vm = vm;
vm.Globals[0x6241b] = 1; // LOAD mode
vm.Globals[0x696] = 0; // caller allows direct load without the optional confirmation
vm.Run();
string entered = string.Join(",", trace.Events
.Where(item => item.Kind == Age.Engine.Diagnostics.TraceEventKind.FrameEnter)
.Select(item => item.Name));
Assert.True(vm.Globals.GetValueOrDefault(0x500) == 1,
$"halt={vm.HaltReason}; steps={vm.Steps}; sleeps={host.SleptDurations.Count}; entered={entered}");
Assert.Equal("exit", vm.HaltReason);
}
finally
{
Directory.Delete(root, recursive: true);
}
}
[Fact]
public void InstalledSave00DecodesAndResolvesThroughRealSaveBinWhenPresent()
{
string root = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Eushully", "姫狩りダンジョンマイスター", "SAVE");
if (!File.Exists(Path.Combine(root, "SAVE00.DAT"))) return;
var nativeScripts = Sys4ScriptProvider.Load(Table);
Script loadCallback = WithPackedId(ScriptAssembler.Assemble(
Table, "CALLBACK_LOAD.BIN",
[(0x2, Array.Empty<Operand>())], []), 0x00fefefd);
var provider = new SaveUiProvider(nativeScripts, null, loadCallback);
var host = new ClickFirstSlotHost();
var vm = new VirtualMachine(
nativeScripts.RequireByName("SAVE.BIN"), Table, host,
new VmOptions(MaxSteps: 1_000_000), provider,
new StopAtInstalledResumeSink(),
nativeDatStore: new DirectoryNativeDatStore(root, Identity));
host.Vm = vm;
vm.Globals[0x6241b] = 1;
vm.Globals[0x696] = 0;
Assert.Throws<InstalledResumeReachedException>(() => vm.Run());
}
private static Script WithPackedId(Script source, uint packedId)
=> new()
{
Name = source.Name,
PackedId = packedId,
Header = source.Header,
Instructions = source.Instructions,
IndexByOffset = source.IndexByOffset,
Strings = source.Strings,
BodyDwords = source.BodyDwords,
ReadMessageOffsets = source.ReadMessageOffsets,
ScriptCallOffsets = source.ScriptCallOffsets,
LocalCallOffsets = source.LocalCallOffsets,
};
private static string NewTemporaryDirectory()
{
string path = Path.Combine(
Path.GetTempPath(), "age-save-ui-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
return path;
}
}