Persist shared profile on clean shutdown

This commit is contained in:
gamer147
2026-07-24 23:55:06 -04:00
parent b8bbd5c1eb
commit ec6b267ec1
9 changed files with 370 additions and 13 deletions

View File

@@ -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. `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 `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 `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 save calls the shared-profile writer directly. On accepted `WM_CLOSE`, `age_main_window_proc@0x486320`
data writes. 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 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 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 `$$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 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. 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 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 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 `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. 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 The `/v2` Ghidra image now names/comments the lookup, queue, commit, mark, file read/write, shared-profile
save/load chain and corrects the relevant function prototypes; saved 2026-07-18. 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) ### Remaining ADV control-strip actions and implementation cost (2026-07-18)

View File

@@ -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 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. `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 Manual acceptance passed: the unchanged slot 006 restores its dungeon map and controls without a re-save.
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) ## Data-semantics sidebar: focused append EBINIT inspection (2026-07-24)

View File

@@ -361,8 +361,9 @@ In scope:
Deferred to bounded follow-ups unless the happy path requires them: Deferred to bounded follow-ups unless the happy path requires them:
- Full configuration UI and every setting. - Full configuration UI and every setting.
- Full load/save opcode and logical-payload implementation. Native format reversal and the common DAT - Remaining configuration/save UI branches beyond the native compatibility floor. Shared and numbered
codec/store foundation landed on 2026-07-24. 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. - Extras, galleries, replay modes, and unrelated submenus.
- Menu visual polish that does not obstruct correct selection or state production. - Menu visual polish that does not obstruct correct selection or state production.

View File

@@ -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 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 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 `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, JSON inspection/export,
namespaced mod data, and migrations remain additive extended-mode work rather than 1.0 compatibility namespaced mod data, and migrations remain additive extended-mode work rather than 1.0 compatibility
requirements. requirements.

View File

@@ -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<Operand>()),
(0x19e, [new Operand(3, 0), new Operand(0, 0)]),
(0x2, Array.Empty<Operand>()),
],
[]);
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<Operand>())], []);
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;
}
}

View File

@@ -348,9 +348,11 @@ public sealed class SharedProfile
private uint[] _extendedSelectorCounts = Array.Empty<uint>(); private uint[] _extendedSelectorCounts = Array.Empty<uint>();
private uint[] _extendedValues = Array.Empty<uint>(); private uint[] _extendedValues = Array.Empty<uint>();
private uint[] _reservedTail = new uint[SharedProfilePayloadCodec.ReservedTailDwordCount]; private uint[] _reservedTail = new uint[SharedProfilePayloadCodec.ReservedTailDwordCount];
private uint _accumulatedPlaySeconds;
public IReadOnlyDictionary<int, uint> IntegerCells => _integerCells; public IReadOnlyDictionary<int, uint> IntegerCells => _integerCells;
public IReadOnlyDictionary<int, string> StringCells => _stringCells; public IReadOnlyDictionary<int, string> StringCells => _stringCells;
public uint AccumulatedPlaySeconds => _accumulatedPlaySeconds;
public ReadTextDatabase ReadText { get; } = new(); public ReadTextDatabase ReadText { get; } = new();
/// <summary>The engine setting manipulated by opcodes 0x1ca/0x1cb.</summary> /// <summary>The engine setting manipulated by opcodes 0x1ca/0x1cb.</summary>
public bool ReadMessageSkipEnabled { get; set; } public bool ReadMessageSkipEnabled { get; set; }
@@ -388,10 +390,12 @@ public sealed class SharedProfile
if (document is null) if (document is null)
{ {
ClearSharedPayload(); ClearSharedPayload();
_accumulatedPlaySeconds = 0;
} }
else else
{ {
Replace(SharedProfilePayloadCodec.Decode(document.Payload, document.Metadata)); Replace(SharedProfilePayloadCodec.Decode(document.Payload, document.Metadata));
_accumulatedPlaySeconds = document.Metadata.AccumulatedPlaySeconds;
} }
bool readTextLoaded = ReadText.Load(store); bool readTextLoaded = ReadText.Load(store);
return document is not null || readTextLoaded; return document is not null || readTextLoaded;
@@ -406,6 +410,7 @@ public sealed class SharedProfile
NativeSaveMetadata metadata = store.Identity.CreateMetadata(timestamp, accumulatedPlaySeconds); NativeSaveMetadata metadata = store.Identity.CreateMetadata(timestamp, accumulatedPlaySeconds);
byte[] payload = SharedProfilePayloadCodec.Encode(Snapshot(), metadata); byte[] payload = SharedProfilePayloadCodec.Encode(Snapshot(), metadata);
store.SaveShared(payload, timestamp, accumulatedPlaySeconds); store.SaveShared(payload, timestamp, accumulatedPlaySeconds);
_accumulatedPlaySeconds = accumulatedPlaySeconds;
ReadText.Save(store); ReadText.Save(store);
} }
@@ -436,6 +441,7 @@ public sealed class SharedProfile
ClearSharedPayload(); ClearSharedPayload();
ReadText.Clear(); ReadText.Clear();
ReadMessageSkipEnabled = false; ReadMessageSkipEnabled = false;
_accumulatedPlaySeconds = 0;
} }
private void ClearSharedPayload() private void ClearSharedPayload()

View File

@@ -8,6 +8,19 @@ namespace Age.Engine.Vm;
/// <summary>A stable identity/snapshot of the exact script frame currently executing.</summary> /// <summary>A stable identity/snapshot of the exact script frame currently executing.</summary>
public sealed record DebugFrameSnapshot(long FrameId, string CurrentScript, IReadOnlyList<string> CallStack); public sealed record DebugFrameSnapshot(long FrameId, string CurrentScript, IReadOnlyList<string> CallStack);
public enum SharedProfileShutdownFlushOutcome
{
Saved,
AlreadyHandled,
Suppressed,
StoreUnavailable,
Failed,
}
public readonly record struct SharedProfileShutdownFlushResult(
SharedProfileShutdownFlushOutcome Outcome,
string? Error = null);
public sealed class VirtualMachine public sealed class VirtualMachine
{ {
private const long NoJump = 0xFFFFFFFF; private const long NoJump = 0xFFFFFFFF;
@@ -34,6 +47,7 @@ public sealed class VirtualMachine
private readonly ITraceSink _sink; private readonly ITraceSink _sink;
private readonly object _interactiveLock = new(); private readonly object _interactiveLock = new();
private readonly object _debugControlLock = new(); private readonly object _debugControlLock = new();
private readonly object _sharedProfileShutdownLock = new();
private readonly List<string> _activeFrameNames = new(); private readonly List<string> _activeFrameNames = new();
private readonly List<ExecFrame> _activeExecutionFrames = new(); private readonly List<ExecFrame> _activeExecutionFrames = new();
private ExecFrame? _saveResumeFrame; private ExecFrame? _saveResumeFrame;
@@ -61,6 +75,8 @@ public sealed class VirtualMachine
private long _autoMessageTime1Ms = 2000; private long _autoMessageTime1Ms = 2000;
private bool _autoVoicePending; private bool _autoVoicePending;
private bool _initialRootRun = true; private bool _initialRootRun = true;
private volatile bool _stopRequested;
private bool _sharedProfileShutdownHandled;
private volatile bool _messageSkipEnabled; private volatile bool _messageSkipEnabled;
private volatile bool _messageSkipServiceActive; private volatile bool _messageSkipServiceActive;
private volatile bool _advSkipServiceEnabled; private volatile bool _advSkipServiceEnabled;
@@ -135,9 +151,48 @@ public sealed class VirtualMachine
_sink = sink ?? NullTraceSink.Instance; TextHistory = textHistory ?? new AdvTextHistory(); _sink = sink ?? NullTraceSink.Instance; TextHistory = textHistory ?? new AdvTextHistory();
_sharedProfile = sharedProfile ?? new SharedProfile(); _sharedProfile = sharedProfile ?? new SharedProfile();
_nativeDatStore = nativeDatStore; _nativeDatStore = nativeDatStore;
_accumulatedPlaySeconds = _sharedProfile.AccumulatedPlaySeconds;
_sessionStartTimestamp = System.Diagnostics.Stopwatch.GetTimestamp(); _sessionStartTimestamp = System.Diagnostics.Stopwatch.GetTimestamp();
} }
/// <summary>Request a clean stop at the next opcode boundary.</summary>
public void RequestStop() => _stopRequested = true;
/// <summary>
/// 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.
/// </summary>
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);
}
}
}
/// <summary>Queue global writes and return only the identified active frame at its next opcode boundary. /// <summary>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.</summary> /// Writes are copied here and applied by the VM thread before another opcode executes.</summary>
public bool TryRequestDebugFrameReturn(long frameId, IReadOnlyDictionary<int, long> globalWrites) public bool TryRequestDebugFrameReturn(long frameId, IReadOnlyDictionary<int, long> globalWrites)
@@ -721,6 +776,7 @@ public sealed class VirtualMachine
{ {
while (pc >= 0 && pc < frame.Script.Instructions.Count) 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; } if (Steps >= _o.MaxSteps) { HaltReason ??= "STEP-LIMIT"; outcome = FrameOutcome.Halted; break; }
Steps++; Steps++;
frame.Pc = pc; frame.Pc = pc;
@@ -1040,6 +1096,7 @@ public sealed class VirtualMachine
_cur.CallStack.Add(HOTSPOT_RETURN); _cur.CallStack.Add(HOTSPOT_RETURN);
while (pc >= 0 && pc < _cur.Script.Instructions.Count) while (pc >= 0 && pc < _cur.Script.Instructions.Count)
{ {
if (_stopRequested) break;
if (Steps >= _o.MaxSteps) { HaltReason ??= "STEP-LIMIT"; break; } if (Steps >= _o.MaxSteps) { HaltReason ??= "STEP-LIMIT"; break; }
Steps++; Steps++;
_cur.Pc = pc; _cur.Pc = pc;

View File

@@ -14,7 +14,9 @@ namespace Age.Engine.Vm;
/// <paramref name="AutoFreeTextures"/>, controls the optional all-surface release before numbered load.</param> /// <paramref name="AutoFreeTextures"/>, controls the optional all-surface release before numbered load.</param>
/// <param name="AutoFreeTextures">Native set:AutoFreeTex profile setting. Himegari defaults this off, /// <param name="AutoFreeTextures">Native set:AutoFreeTex profile setting. Himegari defaults this off,
/// so initialized system textures survive a numbered load unless an explicit reload record replaces them.</param> /// so initialized system textures survive a numbered load unless an explicit reload record replaces them.</param>
/// <param name="NoSaveDat">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.</param>
public sealed record VmOptions(int EmitCap = 2, long MaxSteps = 2_000_000, int CallDepthCap = 64, public sealed record VmOptions(int EmitCap = 2, long MaxSteps = 2_000_000, int CallDepthCap = 64,
bool HaltAtWaitForInput = false, bool IgnoreExitRequests = false, bool HaltAtWaitForInput = false, bool IgnoreExitRequests = false,
int NativeStringCodePage = 932, bool CreateObject = true, int NativeStringCodePage = 932, bool CreateObject = true,
bool AutoFreeTextures = false); bool AutoFreeTextures = false, bool NoSaveDat = false);

View File

@@ -71,6 +71,7 @@ public partial class Main : Godot.Control
private bool _histDumped; private bool _histDumped;
private volatile bool _done; private volatile bool _done;
private bool _ended; private bool _ended;
private Task? _vmTask;
private bool _selftest; private bool _selftest;
private string? _shotPath; // --shot <png>: capture a page then quit (dev tool) private string? _shotPath; // --shot <png>: capture a page then quit (dev tool)
private int _shotPage = 1; // --shot-page <n>: which page to capture (default 1) private int _shotPage = 1; // --shot-page <n>: which page to capture (default 1)
@@ -260,7 +261,11 @@ public partial class Main : Godot.Control
var sharedProfile = new SharedProfile(); var sharedProfile = new SharedProfile();
if (!_selftest) sharedProfile.Load(nativeSaveStore); if (!_selftest) sharedProfile.Load(nativeSaveStore);
_vm = new VirtualMachine(script, table, _host, _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, sharedProfile: sharedProfile,
nativeDatStore: nativeSaveStore); nativeDatStore: nativeSaveStore);
if (scripts != null) if (scripts != null)
@@ -323,7 +328,11 @@ public partial class Main : Godot.Control
if (!_selftest && scene.Equals("SC0000", System.StringComparison.OrdinalIgnoreCase)) if (!_selftest && scene.Equals("SC0000", System.StringComparison.OrdinalIgnoreCase))
_vm.ExternalGlobals[0x6242d] = 4; _vm.ExternalGlobals[0x6242d] = 4;
foreach (var (addr, val) in seeds) _vm.Globals[addr] = val; // --seed overrides boot state 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) if (_selftest)
_ = Task.Run(async () => { while (!_done) { if (_host.IsWaiting) _host.SignalInput(); await Task.Delay(1); } }); _ = 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() 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(); _gpuRenderer?.Dispose();
if (_perf != null) if (_perf != null)
{ {