From 1828701872b64f57200c5940c8669566691a922e Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 24 Jul 2026 23:55:06 -0400 Subject: [PATCH] Persist shared profile on clean shutdown --- docs/engine-re.md | 18 +- docs/phase-a-slice-plan.md | 30 ++- docs/phase-b-framework.md | 5 +- docs/remake-architecture-and-roadmap.md | 5 +- .../SharedProfileLifecycleTests.cs | 211 ++++++++++++++++++ .../Age.Engine/Persistence/SharedProfile.cs | 6 + engine/Age.Engine/Vm/VirtualMachine.cs | 57 +++++ engine/Age.Engine/Vm/VmOptions.cs | 4 +- godot/Main.cs | 47 +++- 9 files changed, 370 insertions(+), 13 deletions(-) create mode 100644 engine/Age.Engine.Tests/SharedProfileLifecycleTests.cs diff --git a/docs/engine-re.md b/docs/engine-re.md index 2e4bf23..33a1936 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -1929,8 +1929,11 @@ Persistence is shared across numbered save slots. `shared_profile_save@0x40c950` `SAVE.DAT`, then serializes `ReadTextDB` through `$$RT.DAT` to `RT.DAT`, with `RT.BAK` handling. `shared_profile_load@0x40ccd0` loads `SAVE.DAT` (falling back to `SAVE.BAK`) and then independently loads `RT.DAT` when present. Numbered saves use the separate `SAVE%2.2d.DAT` pattern. A successful context/slot -save calls the shared-profile writer, and shutdown also calls it unless `set:NoSaveDat` suppresses shared -data writes. +save calls the shared-profile writer directly. On accepted `WM_CLOSE`, `age_main_window_proc@0x486320` +queries `set:NoSaveDat` and calls the same writer only when that value is zero; forced and confirmed close +share that post-acceptance path. `engine_settings_register_defaults@0x46be30` registers `NoSaveDat=0`. +Thus the switch suppresses only the shutdown write, not the shared flush following a successful numbered +save. The selected integer-cell portion of shared `SAVE.DAT` is the `0x1a2` store / `0x1a3` restore service documented above. It is independent of the `RT.DAT` read-message database even though the shared-profile @@ -1951,6 +1954,13 @@ pointer residue, and writes zero in that ignored field. `DirectoryNativeDatStore `$$RT.DAT` → `RT.DAT` / `RT.BAK` replacement; load follows native behavior and reads `RT.DAT` directly rather than treating `RT.BAK` as a fallback. `SharedProfile.ReadText` owns records and the pending queue across fresh VMs, and shared-profile Save/Load updates SAVE.DAT and RT.DAT as separate native domains. +Godot now requests a VM stop, releases the blocking host, waits for the VM worker to leave its opcode +boundary, and performs one shared-profile flush before frontend teardown. Repeated exit notifications do +not rotate backups twice; expected I/O failures are reported without crashing teardown; self-test uses +`NoSaveDat`; and the loaded shared header's accumulated-playtime value seeds the new process baseline. +Focused restart coverage proves selected integer/string cells and committed read flags survive a clean +exit without any numbered slot write. `NoSaveDat` suppresses that exit write while a successful `0x19e` +still writes both shared files. Scripts now retain their packed resource id and decoded F7/T1 table. Opcode `0x71` commits pending tuples and snapshots its T1 coordinate; `0x6e`/`0x71`/`0x72` refresh read eligibility; wait completion queues @@ -1958,8 +1968,8 @@ and snapshots its T1 coordinate; `0x6e`/`0x71`/`0x72` refresh read eligibility; `message:ReadTextSkip` setting, while `0x1cc` reports the current message state. This implements native read-message behavior without scene-offset special cases and leaves JSON/export tooling as extended mode. -The `/v2` Ghidra image now names/comments the lookup, queue, commit, mark, file read/write, and shared-profile -save/load chain and corrects the relevant function prototypes; saved 2026-07-18. +The `/v2` Ghidra image now names/comments the lookup, queue, commit, mark, file read/write, shared-profile +save/load chain, exact `WM_CLOSE` gate, and `NoSaveDat=0` default; saved/refined through 2026-07-24. ### Remaining ADV control-strip actions and implementation cost (2026-07-18) diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index c5a0d51..fb12605 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -3730,8 +3730,34 @@ the numbered-load reload policy; bit 1 remains identified but has no known consu Validation: engine **393/393**, clean opcode lint/tooling, zero-warning Godot build, threaded `SELFTEST OK`, and exact slot-006 software replay with zoom 80 and a nonblack dungeon raster. -Acceptance: rebuild and interactively load existing slot 006. The expected result is the restored -dungeon map and controls without a re-save. +Manual acceptance passed: the unchanged slot 006 restores its dungeon map and controls without a re-save. + +### Persistence implementation step 14 — shared-profile shutdown lifecycle closeout (2026-07-24) + +Shared `SAVE.DAT` and `RT.DAT` already loaded at Godot startup and flushed after successful numbered +opcode `0x19e`, but closing the port only disposed frontend resources. Profile-selected cells and +committed ReadTextDB flags could therefore be lost if the user exited without making a numbered save. + +Native `age_main_window_proc@0x486320` resolves the policy exactly. Once `WM_CLOSE` is forced or accepted +by the normal confirmation path, it queries `set:NoSaveDat`; zero calls `shared_profile_save@0x40c950` +before teardown and nonzero skips it. `engine_settings_register_defaults@0x46be30` registers zero. +Successful numbered/context save calls the shared writer directly, so `NoSaveDat` gates shutdown only. +Both native functions are annotated in the saved `/v2` image. + +Godot now requests a clean VM stop, releases the blocking host, waits up to five seconds for the worker +to leave its opcode boundary, and only then flushes the shared profile. This avoids serializing while the +VM can still mutate selected cells or read flags. The shutdown operation is idempotent across repeated +frontend notifications, reports expected I/O failures without throwing through teardown, and skips the +write rather than racing if the worker cannot stop. Self-test uses the native `NoSaveDat` seam and remains +filesystem-isolated. + +`SharedProfile` now retains the accumulated-playtime value from the loaded `SAVE.DAT` header; a new VM +adds its elapsed process time to that baseline instead of resetting the shared header to the current +launch. Six focused regressions prove restart continuity without any numbered save, `NoSaveDat` +suppression, numbered-save independence from that switch, single-write teardown, playtime preservation, +and graceful I/O failure. + +Validation: engine **399/399**, zero-warning Godot build, and threaded `SELFTEST OK`. ## Data-semantics sidebar: focused append EBINIT inspection (2026-07-24) diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index 4e97e43..3346049 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -361,8 +361,9 @@ In scope: Deferred to bounded follow-ups unless the happy path requires them: - Full configuration UI and every setting. -- Full load/save opcode and logical-payload implementation. Native format reversal and the common DAT - codec/store foundation landed on 2026-07-24. +- Remaining configuration/save UI branches beyond the native compatibility floor. Shared and numbered + codecs, real SAVE.BIN listing/load, port-authored round trips, and clean-shutdown profile persistence + landed on 2026-07-24. - Extras, galleries, replay modes, and unrelated submenus. - Menu visual polish that does not obstruct correct selection or state production. diff --git a/docs/remake-architecture-and-roadmap.md b/docs/remake-architecture-and-roadmap.md index 57eb869..63bdd18 100644 --- a/docs/remake-architecture-and-roadmap.md +++ b/docs/remake-architecture-and-roadmap.md @@ -491,7 +491,10 @@ the later SYSTEM4 unwind that otherwise re-entered the Eushully intro. Slot 005 base-load and stage-launch round trip. Dungeon-authored slot 006 exposed one further native-ordering requirement: restored scripts must begin at their ordinary entry, run frame-local prologues, and reach `0xae` themselves. Matching that order restores FIELD's 80% zoom table entry and produces the dungeon -map from the unchanged slot; interactive slot-006 confirmation is the remaining visual gate. +map from the unchanged slot; interactive slot-006 confirmation passed. The shared-profile lifecycle is +also closed: accepted frontend shutdown stops and joins the VM worker before writing `SAVE.DAT` plus +`RT.DAT`, honors native `NoSaveDat=0` shutdown policy without suppressing numbered-save flushes, preserves +loaded accumulated playtime, and handles repeated teardown and I/O failure safely. JSON inspection/export, namespaced mod data, and migrations remain additive extended-mode work rather than 1.0 compatibility requirements. diff --git a/engine/Age.Engine.Tests/SharedProfileLifecycleTests.cs b/engine/Age.Engine.Tests/SharedProfileLifecycleTests.cs new file mode 100644 index 0000000..3cfa1e3 --- /dev/null +++ b/engine/Age.Engine.Tests/SharedProfileLifecycleTests.cs @@ -0,0 +1,211 @@ +using Age.Engine.Model; +using Age.Engine.Persistence; +using Age.Engine.Sys4; +using Age.Engine.Vm; + +public class SharedProfileLifecycleTests +{ + private static readonly NativeSaveIdentity Identity = + new(NativeSaveMagic.S4SD, 0x4a343234, "himegari-test", 3, 10, 0x42323234); + private static readonly NativeSystemTime Timestamp = + new(2026, 7, 5, 24, 13, 42, 17, 321); + private static readonly OpcodeTable Table = OpcodeTableJson.Load(Paths.OpcodesJson); + + [Fact] + public void ShutdownFlushPersistsSharedCellsAndReadFlagsWithoutNumberedSave() + { + string root = NewTemporaryDirectory(); + try + { + var store = new DirectoryNativeDatStore(root, Identity); + var profile = new SharedProfile(); + profile.StoreInteger(0x123, 77); + profile.StoreString(0x456, "終了保存"); + profile.ReadText.QueueMessage(0x22, 3, 5); + profile.ReadText.CommitPending(); + var vm = NewVm(profile, store); + + vm.Run(); + SharedProfileShutdownFlushResult result = vm.FlushSharedProfileOnShutdown(); + + Assert.Equal(SharedProfileShutdownFlushOutcome.Saved, result.Outcome); + Assert.True(File.Exists(Path.Combine(root, DirectoryNativeDatStore.SharedFileName))); + Assert.True(File.Exists(Path.Combine(root, DirectoryNativeDatStore.ReadTextFileName))); + Assert.DoesNotContain( + Directory.EnumerateFiles(root), + path => + { + string name = Path.GetFileName(path); + return name.Length == 10 + && name.StartsWith("SAVE", StringComparison.Ordinal) + && char.IsAsciiDigit(name[4]) + && char.IsAsciiDigit(name[5]) + && name.EndsWith(".DAT", StringComparison.Ordinal); + }); + + var restarted = new SharedProfile(); + Assert.True(restarted.Load(store)); + Assert.Equal(77, restarted.LoadInteger(0x123)); + Assert.Equal("終了保存", restarted.LoadString(0x456)); + Assert.True(restarted.ReadText.IsMessageRead(0x22, 3)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void NoSaveDatSuppressesOnlyShutdownFlush() + { + string root = NewTemporaryDirectory(); + try + { + var store = new DirectoryNativeDatStore(root, Identity); + var profile = new SharedProfile(); + profile.StoreInteger(1, 2); + var vm = NewVm(profile, store, new VmOptions(NoSaveDat: true)); + + SharedProfileShutdownFlushResult result = vm.FlushSharedProfileOnShutdown(); + + Assert.Equal(SharedProfileShutdownFlushOutcome.Suppressed, result.Outcome); + Assert.False(File.Exists(Path.Combine(root, DirectoryNativeDatStore.SharedFileName))); + Assert.False(File.Exists(Path.Combine(root, DirectoryNativeDatStore.ReadTextFileName))); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void NoSaveDatDoesNotSuppressSuccessfulNumberedSaveSharedFlush() + { + string root = NewTemporaryDirectory(); + try + { + var store = new DirectoryNativeDatStore(root, Identity); + var profile = new SharedProfile(); + profile.StoreInteger(1, 2); + Script script = ScriptAssembler.Assemble( + Table, "NUMBERED_WITH_NOSAVEDAT.BIN", + [ + (0x1ad, Array.Empty()), + (0x19e, [new Operand(3, 0), new Operand(0, 0)]), + (0x2, Array.Empty()), + ], + []); + var vm = new VirtualMachine( + script, Table, new RecordingHost(), new VmOptions(NoSaveDat: true), + sharedProfile: profile, nativeDatStore: store); + + vm.Run(); + + Assert.Equal(0, vm.Globals[0]); + Assert.NotNull(store.LoadNumberedFile(0)); + Assert.NotNull(store.LoadShared()); + Assert.NotNull(store.LoadReadText()); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void RepeatedShutdownFlushDoesNotRotateBackupsAgain() + { + string root = NewTemporaryDirectory(); + try + { + var store = new DirectoryNativeDatStore(root, Identity); + var profile = new SharedProfile(); + profile.StoreInteger(1, 2); + var vm = NewVm(profile, store); + + SharedProfileShutdownFlushResult first = vm.FlushSharedProfileOnShutdown(); + SharedProfileShutdownFlushResult second = vm.FlushSharedProfileOnShutdown(); + + Assert.Equal(SharedProfileShutdownFlushOutcome.Saved, first.Outcome); + Assert.Equal(SharedProfileShutdownFlushOutcome.AlreadyHandled, second.Outcome); + Assert.False(File.Exists(Path.Combine(root, DirectoryNativeDatStore.SharedBackupFileName))); + Assert.False(File.Exists(Path.Combine(root, DirectoryNativeDatStore.ReadTextBackupFileName))); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void ShutdownFlushPreservesLoadedAccumulatedPlaytimeBaseline() + { + string root = NewTemporaryDirectory(); + try + { + var store = new DirectoryNativeDatStore(root, Identity); + var original = new SharedProfile(); + original.StoreInteger(1, 2); + original.Save(store, Timestamp, 777); + var loaded = new SharedProfile(); + Assert.True(loaded.Load(store)); + var vm = NewVm(loaded, store); + + SharedProfileShutdownFlushResult result = vm.FlushSharedProfileOnShutdown(); + + Assert.Equal(SharedProfileShutdownFlushOutcome.Saved, result.Outcome); + Assert.True(store.LoadShared()!.Metadata.AccumulatedPlaySeconds >= 777); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void ShutdownFlushReportsIoFailureWithoutThrowingOrRetrying() + { + string parent = NewTemporaryDirectory(); + string fileAsRoot = Path.Combine(parent, "not-a-directory"); + File.WriteAllText(fileAsRoot, "occupied"); + try + { + var store = new DirectoryNativeDatStore(fileAsRoot, Identity); + var profile = new SharedProfile(); + profile.StoreInteger(1, 2); + var vm = NewVm(profile, store); + + SharedProfileShutdownFlushResult first = vm.FlushSharedProfileOnShutdown(); + SharedProfileShutdownFlushResult second = vm.FlushSharedProfileOnShutdown(); + + Assert.Equal(SharedProfileShutdownFlushOutcome.Failed, first.Outcome); + Assert.NotNull(first.Error); + Assert.NotEmpty(first.Error); + Assert.Equal(SharedProfileShutdownFlushOutcome.AlreadyHandled, second.Outcome); + } + finally + { + Directory.Delete(parent, recursive: true); + } + } + + private static VirtualMachine NewVm( + SharedProfile profile, + INativeDatStore store, + VmOptions? options = null) + { + Script script = ScriptAssembler.Assemble( + Table, "SHARED_SHUTDOWN.BIN", [(0x2, Array.Empty())], []); + return new VirtualMachine( + script, Table, new RecordingHost(), options, + sharedProfile: profile, nativeDatStore: store); + } + + private static string NewTemporaryDirectory() + { + string path = Path.Combine( + Path.GetTempPath(), "age-shared-lifecycle-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } +} diff --git a/engine/Age.Engine/Persistence/SharedProfile.cs b/engine/Age.Engine/Persistence/SharedProfile.cs index 3c3a209..0b1c182 100644 --- a/engine/Age.Engine/Persistence/SharedProfile.cs +++ b/engine/Age.Engine/Persistence/SharedProfile.cs @@ -348,9 +348,11 @@ public sealed class SharedProfile private uint[] _extendedSelectorCounts = Array.Empty(); private uint[] _extendedValues = Array.Empty(); private uint[] _reservedTail = new uint[SharedProfilePayloadCodec.ReservedTailDwordCount]; + private uint _accumulatedPlaySeconds; public IReadOnlyDictionary IntegerCells => _integerCells; public IReadOnlyDictionary StringCells => _stringCells; + public uint AccumulatedPlaySeconds => _accumulatedPlaySeconds; public ReadTextDatabase ReadText { get; } = new(); /// The engine setting manipulated by opcodes 0x1ca/0x1cb. public bool ReadMessageSkipEnabled { get; set; } @@ -388,10 +390,12 @@ public sealed class SharedProfile if (document is null) { ClearSharedPayload(); + _accumulatedPlaySeconds = 0; } else { Replace(SharedProfilePayloadCodec.Decode(document.Payload, document.Metadata)); + _accumulatedPlaySeconds = document.Metadata.AccumulatedPlaySeconds; } bool readTextLoaded = ReadText.Load(store); return document is not null || readTextLoaded; @@ -406,6 +410,7 @@ public sealed class SharedProfile NativeSaveMetadata metadata = store.Identity.CreateMetadata(timestamp, accumulatedPlaySeconds); byte[] payload = SharedProfilePayloadCodec.Encode(Snapshot(), metadata); store.SaveShared(payload, timestamp, accumulatedPlaySeconds); + _accumulatedPlaySeconds = accumulatedPlaySeconds; ReadText.Save(store); } @@ -436,6 +441,7 @@ public sealed class SharedProfile ClearSharedPayload(); ReadText.Clear(); ReadMessageSkipEnabled = false; + _accumulatedPlaySeconds = 0; } private void ClearSharedPayload() diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index ee61b91..6ea035c 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -8,6 +8,19 @@ namespace Age.Engine.Vm; /// A stable identity/snapshot of the exact script frame currently executing. public sealed record DebugFrameSnapshot(long FrameId, string CurrentScript, IReadOnlyList CallStack); +public enum SharedProfileShutdownFlushOutcome +{ + Saved, + AlreadyHandled, + Suppressed, + StoreUnavailable, + Failed, +} + +public readonly record struct SharedProfileShutdownFlushResult( + SharedProfileShutdownFlushOutcome Outcome, + string? Error = null); + public sealed class VirtualMachine { private const long NoJump = 0xFFFFFFFF; @@ -34,6 +47,7 @@ public sealed class VirtualMachine private readonly ITraceSink _sink; private readonly object _interactiveLock = new(); private readonly object _debugControlLock = new(); + private readonly object _sharedProfileShutdownLock = new(); private readonly List _activeFrameNames = new(); private readonly List _activeExecutionFrames = new(); private ExecFrame? _saveResumeFrame; @@ -61,6 +75,8 @@ public sealed class VirtualMachine private long _autoMessageTime1Ms = 2000; private bool _autoVoicePending; private bool _initialRootRun = true; + private volatile bool _stopRequested; + private bool _sharedProfileShutdownHandled; private volatile bool _messageSkipEnabled; private volatile bool _messageSkipServiceActive; private volatile bool _advSkipServiceEnabled; @@ -135,9 +151,48 @@ public sealed class VirtualMachine _sink = sink ?? NullTraceSink.Instance; TextHistory = textHistory ?? new AdvTextHistory(); _sharedProfile = sharedProfile ?? new SharedProfile(); _nativeDatStore = nativeDatStore; + _accumulatedPlaySeconds = _sharedProfile.AccumulatedPlaySeconds; _sessionStartTimestamp = System.Diagnostics.Stopwatch.GetTimestamp(); } + /// Request a clean stop at the next opcode boundary. + public void RequestStop() => _stopRequested = true; + + /// + /// Match AGE's accepted-WM_CLOSE shared-profile lifecycle. The native NoSaveDat setting gates only + /// this shutdown write; numbered-save opcode 0x19e continues to flush shared state independently. + /// Repeated frontend teardown notifications are handled without rotating backups more than once. + /// + public SharedProfileShutdownFlushResult FlushSharedProfileOnShutdown() + { + lock (_sharedProfileShutdownLock) + { + if (_sharedProfileShutdownHandled) + return new(SharedProfileShutdownFlushOutcome.AlreadyHandled); + _sharedProfileShutdownHandled = true; + + if (_o.NoSaveDat) + return new(SharedProfileShutdownFlushOutcome.Suppressed); + if (_nativeDatStore == null) + return new(SharedProfileShutdownFlushOutcome.StoreUnavailable); + + try + { + _sharedProfile.Save( + _nativeDatStore, + NativeSystemTime.FromLocalDateTime(DateTime.Now), + AccumulatedPlaySeconds()); + return new(SharedProfileShutdownFlushOutcome.Saved); + } + catch (Exception error) when ( + error is IOException or UnauthorizedAccessException or InvalidDataException + or ArgumentOutOfRangeException or OverflowException) + { + return new(SharedProfileShutdownFlushOutcome.Failed, error.Message); + } + } + } + /// Queue global writes and return only the identified active frame at its next opcode boundary. /// Writes are copied here and applied by the VM thread before another opcode executes. public bool TryRequestDebugFrameReturn(long frameId, IReadOnlyDictionary globalWrites) @@ -721,6 +776,7 @@ public sealed class VirtualMachine { while (pc >= 0 && pc < frame.Script.Instructions.Count) { + if (_stopRequested) { outcome = FrameOutcome.ExitRequested; break; } if (Steps >= _o.MaxSteps) { HaltReason ??= "STEP-LIMIT"; outcome = FrameOutcome.Halted; break; } Steps++; frame.Pc = pc; @@ -1040,6 +1096,7 @@ public sealed class VirtualMachine _cur.CallStack.Add(HOTSPOT_RETURN); while (pc >= 0 && pc < _cur.Script.Instructions.Count) { + if (_stopRequested) break; if (Steps >= _o.MaxSteps) { HaltReason ??= "STEP-LIMIT"; break; } Steps++; _cur.Pc = pc; diff --git a/engine/Age.Engine/Vm/VmOptions.cs b/engine/Age.Engine/Vm/VmOptions.cs index 90233b2..a120db5 100644 --- a/engine/Age.Engine/Vm/VmOptions.cs +++ b/engine/Age.Engine/Vm/VmOptions.cs @@ -14,7 +14,9 @@ namespace Age.Engine.Vm; /// , controls the optional all-surface release before numbered load. /// Native set:AutoFreeTex profile setting. Himegari defaults this off, /// so initialized system textures survive a numbered load unless an explicit reload record replaces them. +/// Native set:NoSaveDat profile setting. This suppresses the clean-shutdown +/// shared SAVE.DAT/RT.DAT flush only; successful numbered saves still flush both shared files. public sealed record VmOptions(int EmitCap = 2, long MaxSteps = 2_000_000, int CallDepthCap = 64, bool HaltAtWaitForInput = false, bool IgnoreExitRequests = false, int NativeStringCodePage = 932, bool CreateObject = true, - bool AutoFreeTextures = false); + bool AutoFreeTextures = false, bool NoSaveDat = false); diff --git a/godot/Main.cs b/godot/Main.cs index 3660d2d..6552e92 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -71,6 +71,7 @@ public partial class Main : Godot.Control private bool _histDumped; private volatile bool _done; private bool _ended; + private Task? _vmTask; private bool _selftest; private string? _shotPath; // --shot : capture a page then quit (dev tool) private int _shotPage = 1; // --shot-page : which page to capture (default 1) @@ -260,7 +261,11 @@ public partial class Main : Godot.Control var sharedProfile = new SharedProfile(); if (!_selftest) sharedProfile.Load(nativeSaveStore); _vm = new VirtualMachine(script, table, _host, - new VmOptions(MaxSteps: 20_000_000, IgnoreExitRequests: nativeDebugMenu), provider, sink, + new VmOptions( + MaxSteps: 20_000_000, + IgnoreExitRequests: nativeDebugMenu, + NoSaveDat: _selftest), + provider, sink, sharedProfile: sharedProfile, nativeDatStore: nativeSaveStore); if (scripts != null) @@ -323,7 +328,11 @@ public partial class Main : Godot.Control if (!_selftest && scene.Equals("SC0000", System.StringComparison.OrdinalIgnoreCase)) _vm.ExternalGlobals[0x6242d] = 4; foreach (var (addr, val) in seeds) _vm.Globals[addr] = val; // --seed overrides boot state - _ = Task.Run(() => { _vm.Run(); _done = true; }); + _vmTask = Task.Run(() => + { + try { _vm.Run(); } + finally { _done = true; } + }); if (_selftest) _ = Task.Run(async () => { while (!_done) { if (_host.IsWaiting) _host.SignalInput(); await Task.Delay(1); } }); @@ -745,7 +754,39 @@ public partial class Main : Godot.Control public override void _ExitTree() { - DumpHistogram(); _host?.Stop(); _timeline?.Dispose(); _locator?.Dispose(); + bool vmStopped = true; + if (_vm != null) _vm.RequestStop(); + _host?.Stop(); + if (_vmTask != null) + { + try + { + vmStopped = _vmTask.Wait(System.TimeSpan.FromSeconds(5)); + } + catch (System.AggregateException error) + { + vmStopped = true; + GD.PushError($"[vm] worker failed during shutdown: {error.Flatten().InnerException?.Message}"); + } + } + + if (!_selftest && _vm != null) + { + if (!vmStopped) + { + GD.PushWarning("[persistence] VM did not stop within 5 seconds; skipped concurrent shared-profile flush"); + } + else + { + SharedProfileShutdownFlushResult flush = _vm.FlushSharedProfileOnShutdown(); + if (flush.Outcome == SharedProfileShutdownFlushOutcome.Saved) + GD.Print("[persistence] clean shutdown wrote SAVE.DAT and RT.DAT"); + else if (flush.Outcome == SharedProfileShutdownFlushOutcome.Failed) + GD.PushWarning($"[persistence] clean-shutdown shared-profile write failed: {flush.Error}"); + } + } + + DumpHistogram(); _timeline?.Dispose(); _locator?.Dispose(); _gpuRenderer?.Dispose(); if (_perf != null) {