Restore complete numbered save state

This commit is contained in:
gamer147
2026-07-24 18:43:21 -04:00
parent db41f77eb5
commit 7a11a3853d
15 changed files with 329 additions and 61 deletions

View File

@@ -14,8 +14,8 @@ public class NativeNumberedSaveCodecTests
NativeNumberedSaveState state = NativeNumberedSaveCodec.Empty(frames) with
{
SavedFrameOwner = 7,
EngineState = 9,
StateWords = Enumerable.Range(10, 10).ToArray(),
BgmTrackId = 9,
SoundEffectResourceIds = Enumerable.Range(10, 10).ToArray(),
IntegerGlobals = new[] { 12, -3, 0x12345678 },
FloatGlobals = new[] { BitConverter.SingleToInt32Bits(1.25f) },
StringGlobals = new[] { "姫狩り", "", "save" },
@@ -38,8 +38,8 @@ public class NativeNumberedSaveCodecTests
NativeNumberedSaveState decoded = NativeNumberedSaveCodec.Decode(encoded);
Assert.Equal(state.SavedFrameOwner, decoded.SavedFrameOwner);
Assert.Equal(state.EngineState, decoded.EngineState);
Assert.Equal(state.StateWords, decoded.StateWords);
Assert.Equal(state.BgmTrackId, decoded.BgmTrackId);
Assert.Equal(state.SoundEffectResourceIds, decoded.SoundEffectResourceIds);
Assert.Equal(state.Frames[0].ParentContext, decoded.Frames[0].ParentContext);
Assert.Equal(state.Frames[0].ScriptId, decoded.Frames[0].ScriptId);
Assert.Equal(state.Frames[0].ReturnIndices, decoded.Frames[0].ReturnIndices);

View File

@@ -22,6 +22,9 @@ public class NumberedSaveVmTests
var store = new DirectoryNativeDatStore(root, Identity);
Script script = WithPackedId(ScriptAssembler.Assemble(Table, "SAVE_TEST.BIN",
[
(0xbf, [new Operand(Immediate, 24)]),
(0xc0, [new Operand(GlobalInt, 0x21)]),
(0xb4, [new Operand(Immediate, 0x3321), new Operand(Immediate, 1)]),
(0x1ad, Array.Empty<Operand>()),
(0x19e, [new Operand(GlobalInt, 0x20), new Operand(Immediate, 2)]),
(0x2, Array.Empty<Operand>()),
@@ -40,10 +43,13 @@ public class NumberedSaveVmTests
vm.Run();
Assert.Equal(0, vm.Globals[0x20]);
Assert.Equal(24, vm.Globals[0x21]);
NativeNumberedSaveFile file = store.LoadNumberedFile(2)!;
NativeNumberedSaveState state = NativeNumberedSaveCodec.Decode(file.Document.Payload);
Assert.Equal(0x6241b, state.IntegerGlobals.Count);
Assert.Equal(456, state.IntegerGlobals[0x123]);
Assert.Equal(24, state.BgmTrackId);
Assert.Equal(0x3321, state.SoundEffectResourceIds[1]);
Assert.Equal("姫狩り", state.StringGlobals[4]);
Assert.Equal(0x77u, state.Frames.Single().ScriptId);
Assert.Contains(state.GfxObjects, item => item.Handle == 100);
@@ -97,6 +103,12 @@ public class NumberedSaveVmTests
new NativeSavedScriptFrame(0, 0x89, Array.Empty<int>(), -1, -1),
]) with
{
BgmTrackId = 24,
SoundEffectResourceIds =
[
0, 0x3321, 0x2aea, 0, 0,
0, 0, 0, 0, 0,
],
IntegerGlobals = DenseIntBank(0x124, (0x123, 456)),
FloatGlobals = [BitConverter.SingleToInt32Bits(3.5f)],
StringGlobals = ["復帰"],
@@ -113,8 +125,10 @@ public class NumberedSaveVmTests
1, NativeNumberedSaveCodec.Encode(state), NativeTextHistoryCodec.Encode(history),
NativeSystemTime.FromLocalDateTime(DateTime.Now), 123);
var liveHistory = new AdvTextHistory();
var trace = new RecordingTraceSink();
var host = new RecordingHost();
var vm = new VirtualMachine(
loader, Table, new RecordingHost(), provider: new MapProvider(
loader, Table, host, provider: new MapProvider(
new()
{
[0x88] = resumed,
@@ -124,22 +138,42 @@ public class NumberedSaveVmTests
{
["CALLBACK_LOAD.BIN"] = callback,
}),
textHistory: liveHistory, nativeDatStore: store);
sink: trace, textHistory: liveHistory, nativeDatStore: store);
vm.Globals[0x10] = 999;
vm.Globals[0x123] = 999;
vm.Globals[0x124] = 777;
vm.GlobalStrings[0] = "stale";
vm.GlobalStrings[1] = "static-unit-name";
vm.Run();
Assert.False(vm.Globals.ContainsKey(0x10));
Assert.Equal(456, vm.Globals[0x123]);
Assert.Equal(777, vm.Globals[0x124]);
Assert.Equal(456, vm.Globals[0x500]);
Assert.Equal(456, vm.Globals[0x501]);
Assert.Equal(1, vm.Globals[0x502]);
Assert.Equal("復帰", vm.GlobalStrings[0]);
Assert.Equal(0x123, vm.GlobalPointers[0]);
Assert.Equal("static-unit-name", vm.GlobalStrings[1]);
Assert.Equal([24L], host.BgmTracks);
Assert.Equal(
[(0x3321L, 1), (0x2aeaL, 2)],
host.SfxLoads);
Assert.Equal(Enumerable.Range(0, 10), host.SfxReleases);
Assert.Equal("履歴復帰", liveHistory.Records.Single().Text);
Assert.Equal(3, vm.Gfx.QuerySlot(100));
RenderObject restoredObject = Assert.Single(vm.Gfx.SnapshotVisibleObjects());
Assert.Equal(0x1234, restoredObject.SurfaceResId);
Assert.Equal((50, 60), (restoredObject.DstX, restoredObject.DstY));
Assert.Contains(trace.Events, item =>
item.Kind == Age.Engine.Diagnostics.TraceEventKind.FrameEnter
&& item.Name == "CHILD.BIN"
&& item.Cause == Age.Engine.Diagnostics.FrameCause.SaveRestore);
Assert.DoesNotContain(trace.Events, item =>
item.Kind == Age.Engine.Diagnostics.TraceEventKind.FrameEnter
&& item.Name == "CHILD.BIN"
&& item.Cause == Age.Engine.Diagnostics.FrameCause.CallScript);
Assert.Equal("exit", vm.HaltReason);
}
finally

View File

@@ -9,6 +9,8 @@ public class SaveUiIntegrationTests
{
private sealed class MenuReadyException : Exception;
private sealed class InstalledResumeReachedException : Exception;
private sealed class InstalledGameplayPollReachedException : Exception;
private sealed class InstalledRootReloadReachedException(string message) : Exception(message);
private sealed class StopAtFirstMenuPollHost : RecordingHost
{
@@ -19,7 +21,7 @@ public class SaveUiIntegrationTests
}
}
private sealed class ClickFirstSlotHost : RecordingHost
private class ClickFirstSlotHost : RecordingHost
{
private long _now;
private bool _pressed;
@@ -45,6 +47,53 @@ public class SaveUiIntegrationTests
}
}
private sealed class InstalledContinuationState(int expectedRestoreFrames)
{
public int RestoreFrameCount;
public bool TerminalRestoreEntered => RestoreFrameCount >= expectedRestoreFrames;
public readonly List<string> Frames = new();
}
private sealed class ContinueInstalledLoadHost(InstalledContinuationState state) : ClickFirstSlotHost
{
public override void WaitForInput(
int layoutSlot, Func<bool> serviceInputCallback, Func<AdvAutoWaitState> autoWaitState)
{
if (state.TerminalRestoreEntered) throw new InstalledGameplayPollReachedException();
base.WaitForInput(layoutSlot, serviceInputCallback, autoWaitState);
}
public override void Sleep(long duration)
{
base.Sleep(duration);
if (state.TerminalRestoreEntered) throw new InstalledGameplayPollReachedException();
}
}
private sealed class InstalledContinuationSink(InstalledContinuationState state) : ITraceSink
{
public bool TracingSteps => false;
public void Emit(in TraceEvent item)
{
if (item.Kind == TraceEventKind.FrameEnter)
{
state.Frames.Add($"+{item.Name}:{item.Cause}");
if (item.Cause == FrameCause.SaveRestore
&& !string.Equals(item.Name, "CALLBACK_LOAD.BIN",
StringComparison.OrdinalIgnoreCase))
state.RestoreFrameCount++;
if (item.Cause == FrameCause.RootReload)
throw new InstalledRootReloadReachedException(
string.Join(", ", state.Frames));
}
else if (item.Kind == TraceEventKind.FrameExit)
{
state.Frames.Add($"-{item.Name}:{item.Text}");
}
}
}
private sealed class SaveUiProvider(
Sys4ScriptProvider native,
Script? resumed,
@@ -224,12 +273,50 @@ public class SaveUiIntegrationTests
sharedProfile: sharedProfile,
nativeDatStore: store);
host.Vm = vm;
vm.Globals[0x4] = unchecked((uint)sharedProfile.LoadInteger(0x5c3));
vm.Globals[0x6241b] = 1;
vm.Globals[0x696] = 0;
Assert.Throws<InstalledResumeReachedException>(() => vm.Run());
}
[Fact]
public void InstalledSave00ContinuesToRestoredGameplayPollWhenPresent()
{
string root = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Eushully", "姫狩りダンジョンマイスター", "SAVE");
if (!File.Exists(Path.Combine(root, "SAVE00.DAT"))) return;
var scripts = Sys4ScriptProvider.Load(Table);
var store = new DirectoryNativeDatStore(root, Identity);
NativeNumberedSaveState numbered = NativeNumberedSaveCodec.Decode(
store.LoadNumberedFile(0)!.Document.Payload);
var state = new InstalledContinuationState(numbered.Frames.Count);
var host = new ContinueInstalledLoadHost(state);
var sharedProfile = new SharedProfile();
Assert.True(sharedProfile.Load(store));
var vm = new VirtualMachine(
scripts.RequireByName("SAVE.BIN"), Table, host,
new VmOptions(MaxSteps: 1_000_000), scripts,
new InstalledContinuationSink(state),
sharedProfile: sharedProfile,
nativeDatStore: store);
host.Vm = vm;
vm.Globals[0x4] = unchecked((uint)sharedProfile.LoadInteger(0x5c3));
vm.Globals[0x6241b] = 1;
vm.Globals[0x696] = 0;
Exception? outcome = Record.Exception(() => vm.Run());
Assert.True(outcome is InstalledGameplayPollReachedException,
$"outcome={outcome?.GetType().Name ?? "<none>"}:{outcome?.Message}; " +
$"halt={vm.HaltReason}; steps={vm.Steps}; waits={host.Waits}; " +
$"movies={string.Join(",", host.ModalMovies.Select(item => item.Resource))}; " +
$"saved={string.Join(", ", numbered.Frames.Select((frame, index) =>
$"{index}:0x{frame.ScriptId:x}/resume={frame.ResumeIndex}/call={frame.CallTargetIndex}"))}; " +
$"frames={string.Join(", ", state.Frames)}");
}
private static Script WithPackedId(Script source, uint packedId)
=> new()
{

View File

@@ -41,6 +41,7 @@ internal class RecordingHost : IHost
public readonly List<int> SfxStarts = new();
public readonly List<(int Channel, int StartMode, long DelayMs)> ScheduledSfxStarts = new();
public readonly List<int> SfxReleases = new();
public readonly List<long> BgmTracks = new();
public readonly List<(int Target, long Duration)> BgmFades = new();
public readonly List<(long Resource, int Surface, long Flags, long SyncMask)> Movies = new();
public System.Action? OnPlayMovie;
@@ -149,7 +150,7 @@ internal class RecordingHost : IHost
public void DrawTexture(int slot, int sx, int sy, int w, int h, int dx, int dy)
=> TextureDraws.Add((slot, sx, sy, w, h, dx, dy));
public (int Width, int Height) GetTextureSize(int slot) => (0, 0);
public void PlayBgm(long id) { }
public void PlayBgm(long id) => BgmTracks.Add(id);
public void PlayVoice(long id) => Voices.Add(id);
public void PlayVoice(long id, int playbackVariant)
{