Persist shared profile on clean shutdown
This commit is contained in:
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[] _extendedValues = Array.Empty<uint>();
|
||||
private uint[] _reservedTail = new uint[SharedProfilePayloadCodec.ReservedTailDwordCount];
|
||||
private uint _accumulatedPlaySeconds;
|
||||
|
||||
public IReadOnlyDictionary<int, uint> IntegerCells => _integerCells;
|
||||
public IReadOnlyDictionary<int, string> StringCells => _stringCells;
|
||||
public uint AccumulatedPlaySeconds => _accumulatedPlaySeconds;
|
||||
public ReadTextDatabase ReadText { get; } = new();
|
||||
/// <summary>The engine setting manipulated by opcodes 0x1ca/0x1cb.</summary>
|
||||
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()
|
||||
|
||||
@@ -8,6 +8,19 @@ namespace Age.Engine.Vm;
|
||||
/// <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 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<string> _activeFrameNames = new();
|
||||
private readonly List<ExecFrame> _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();
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// Writes are copied here and applied by the VM thread before another opcode executes.</summary>
|
||||
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)
|
||||
{
|
||||
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;
|
||||
|
||||
@@ -14,7 +14,9 @@ namespace Age.Engine.Vm;
|
||||
/// <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,
|
||||
/// 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,
|
||||
bool HaltAtWaitForInput = false, bool IgnoreExitRequests = false,
|
||||
int NativeStringCodePage = 932, bool CreateObject = true,
|
||||
bool AutoFreeTextures = false);
|
||||
bool AutoFreeTextures = false, bool NoSaveDat = false);
|
||||
|
||||
Reference in New Issue
Block a user