diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index 0784558..2389f83 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -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. diff --git a/docs/remake-architecture-and-roadmap.md b/docs/remake-architecture-and-roadmap.md index 491cb70..a641a21 100644 --- a/docs/remake-architecture-and-roadmap.md +++ b/docs/remake-architecture-and-roadmap.md @@ -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 diff --git a/engine/Age.Engine.Tests/SaveUiIntegrationTests.cs b/engine/Age.Engine.Tests/SaveUiIntegrationTests.cs new file mode 100644 index 0000000..1e007c1 --- /dev/null +++ b/engine/Age.Engine.Tests/SaveUiIntegrationTests.cs @@ -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 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(() => 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()), + (Table.ByLabel("mov")!.Value, + [new Operand(3, 0x500), new Operand(0, 1)]), + (0x2, Array.Empty()), + ], []), resumedId); + Script loadCallback = WithPackedId(ScriptAssembler.Assemble( + Table, "CALLBACK_LOAD.BIN", + [(0x2, Array.Empty())], []), 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())], []), 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(() => 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; + } +}