Persist shared profile on clean shutdown
This commit is contained in:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
211
engine/Age.Engine.Tests/SharedProfileLifecycleTests.cs
Normal file
211
engine/Age.Engine.Tests/SharedProfileLifecycleTests.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user