Files
OpenMaidEngine/engine/Age.Engine.Tests/SaveUiIntegrationTests.cs
2026-07-24 17:05:13 -04:00

256 lines
10 KiB
C#

using Age.Engine.Diagnostics;
using Age.Engine.Hosting;
using Age.Engine.Model;
using Age.Engine.Persistence;
using Age.Engine.Sys4;
using Age.Engine.Vm;
public class SaveUiIntegrationTests
{
private sealed class MenuReadyException : Exception;
private sealed class InstalledResumeReachedException : Exception;
private sealed class StopAtFirstMenuPollHost : RecordingHost
{
public override void Sleep(long duration)
{
base.Sleep(duration);
throw new MenuReadyException();
}
}
private sealed class ClickFirstSlotHost : RecordingHost
{
private long _now;
private bool _pressed;
private bool _released;
public VirtualMachine Vm { get; set; } = null!;
public override long InputClockMilliseconds => _now;
public override void Sleep(long duration)
{
base.Sleep(duration);
_now += Math.Max(1, duration);
if (!_pressed)
{
Vm.UpdatePointer(400, 70);
Vm.UpdateMouseButtonState(0x1, true);
_pressed = true;
}
if (!_released && _now >= 32)
{
Vm.UpdateMouseButtonState(0x1, false);
_released = true;
}
}
}
private sealed class SaveUiProvider(
Sys4ScriptProvider native,
Script? resumed,
Script loadCallback) : IScriptProvider
{
public Script? GetById(long id)
=> resumed != null && id == resumed.PackedId ? resumed : native.GetById(id);
public Script? GetByName(string name)
=> name.Equals("CALLBACK_LOAD.BIN", StringComparison.OrdinalIgnoreCase)
? loadCallback
: native.GetByName(name);
public IReadOnlyList<int> MountedAppendSelectors => native.MountedAppendSelectors;
}
private sealed class StopAtInstalledResumeSink : ITraceSink
{
public bool TracingSteps => false;
public void Emit(in TraceEvent item)
{
if (item.Kind == TraceEventKind.FrameEnter
&& item.Cause == FrameCause.SaveRestore
&& !string.Equals(item.Name, "CALLBACK_LOAD.BIN",
StringComparison.OrdinalIgnoreCase))
throw new InstalledResumeReachedException();
}
}
private static readonly OpcodeTable Table = OpcodeTableJson.Load(Paths.OpcodesJson);
private static readonly NativeSaveIdentity Identity =
new(NativeSaveMagic.S4SD, 0x4a343234, "姫狩りダンジョンマイスター",
3, 10, 0x42323234);
[Fact]
public void RealSaveBinListsNativeSlotMetadataAndThumbnail()
{
string root = NewTemporaryDirectory();
try
{
var store = new DirectoryNativeDatStore(root, Identity);
store.SaveNumbered(
0, [1, 2, 3, 4],
new NativeSystemTime(2026, 7, 5, 24, 13, 42, 17, 321),
7_445);
var pixels = new byte[112 * 84 * 4];
for (int i = 0; i < pixels.Length; i += 4)
{
pixels[i] = 0x12;
pixels[i + 1] = 0x34;
pixels[i + 2] = 0x56;
pixels[i + 3] = 0xff;
}
store.SaveNumberedThumbnail(
0, NumberedThumbnailCodec.Encode(new(112, 84, pixels)));
var authoredProfile = new SharedProfile();
authoredProfile.StoreString(0x000, "序章 封印、そして");
authoredProfile.StoreString(0x0d2, "base part-time job");
authoredProfile.StoreString(0x1a4, "Lily");
authoredProfile.StoreInteger(0x0d7, 80);
authoredProfile.StoreInteger(0x27b, 98);
authoredProfile.StoreInteger(0x34d, 69);
authoredProfile.StoreInteger(0x41f, 0);
authoredProfile.StoreInteger(0x4f1, 0b1011);
authoredProfile.Save(
store, new NativeSystemTime(2026, 7, 5, 24, 13, 42, 17, 321), 7_445);
var loadedProfile = new SharedProfile();
Assert.True(loadedProfile.Load(store));
var scripts = Sys4ScriptProvider.Load(Table);
var host = new StopAtFirstMenuPollHost();
var vm = new VirtualMachine(
scripts.RequireByName("SAVE.BIN"), Table, host,
new VmOptions(MaxSteps: 500_000), scripts,
sharedProfile: loadedProfile,
nativeDatStore: store);
Assert.Throws<MenuReadyException>(() => vm.Run());
RgbaImage thumbnail = Assert.Single(host.SurfacePixels.Values);
Assert.Equal((112, 84), (thumbnail.Width, thumbnail.Height));
Assert.Equal(new byte[] { 0x12, 0x34, 0x56, 0xff }, thumbnail.Pixels[..4]);
Assert.Contains(host.SurfaceStrings, item => item.Text == "2026");
Assert.Contains(host.SurfaceStrings, item => item.Text == "07");
Assert.Contains(host.SurfaceStrings, item => item.Text == "24");
Assert.Contains(host.SurfaceStrings, item => item.Text == "13");
Assert.Contains(host.SurfaceStrings, item => item.Text == "序章 封印、そして");
Assert.Contains(host.SurfaceStrings, item => item.Text == "base part-time job");
Assert.Contains(host.SurfaceStrings, item => item.Text == "Lily");
var objects = vm.Gfx.SnapshotVisibleObjects().ToDictionary(item => item.Handle);
Assert.Equal((0, 96), (objects[0x13137].SrcX, objects[0x13138].SrcX)); // level 80
Assert.Equal((96, 108), (objects[0x13146].SrcX, objects[0x13147].SrcX)); // growth 98
Assert.Equal((108, 72), (objects[0x13155].SrcX, objects[0x13156].SrcX)); // personality 69
Assert.Equal(3, host.TextureDraws.Count(
item => item.Slot == 193 && item.Width == 17 && item.Height == 17));
}
finally
{
Directory.Delete(root, recursive: true);
}
}
[Fact]
public void RealSaveBinClickLoadsNativeSlotAndEntersRestoreRendezvous()
{
const uint resumedId = 0x00fefefe;
string root = NewTemporaryDirectory();
try
{
var store = new DirectoryNativeDatStore(root, Identity);
Script resumed = WithPackedId(ScriptAssembler.Assemble(Table, "SAVE_UI_RESUME.BIN",
[
(0xae, Array.Empty<Operand>()),
(Table.ByLabel("mov")!.Value,
[new Operand(3, 0x500), new Operand(0, 1)]),
(0x2, Array.Empty<Operand>()),
], []), resumedId);
Script loadCallback = WithPackedId(ScriptAssembler.Assemble(
Table, "CALLBACK_LOAD.BIN",
[(0x2, Array.Empty<Operand>())], []), 0x00fefefd);
NativeNumberedSaveState state = NativeNumberedSaveCodec.Empty(
[new NativeSavedScriptFrame(-1, resumedId, [], -1, -1)]);
store.SaveNumberedFile(
0, NativeNumberedSaveCodec.Encode(state), [],
new NativeSystemTime(2026, 7, 5, 24, 13, 42, 17, 321),
7_445);
var nativeScripts = Sys4ScriptProvider.Load(Table);
var provider = new SaveUiProvider(nativeScripts, resumed, loadCallback);
var host = new ClickFirstSlotHost();
var trace = new RecordingTraceSink();
var vm = new VirtualMachine(
nativeScripts.RequireByName("SAVE.BIN"), Table, host,
new VmOptions(MaxSteps: 1_000_000), provider, trace,
nativeDatStore: store);
host.Vm = vm;
vm.Globals[0x6241b] = 1; // LOAD mode
vm.Globals[0x696] = 0; // caller allows direct load without the optional confirmation
vm.Run();
string entered = string.Join(",", trace.Events
.Where(item => item.Kind == Age.Engine.Diagnostics.TraceEventKind.FrameEnter)
.Select(item => item.Name));
Assert.True(vm.Globals.GetValueOrDefault(0x500) == 1,
$"halt={vm.HaltReason}; steps={vm.Steps}; sleeps={host.SleptDurations.Count}; entered={entered}");
Assert.Equal("exit", vm.HaltReason);
}
finally
{
Directory.Delete(root, recursive: true);
}
}
[Fact]
public void InstalledSave00DecodesAndResolvesThroughRealSaveBinWhenPresent()
{
string root = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Eushully", "姫狩りダンジョンマイスター", "SAVE");
if (!File.Exists(Path.Combine(root, "SAVE00.DAT"))) return;
var nativeScripts = Sys4ScriptProvider.Load(Table);
Script loadCallback = WithPackedId(ScriptAssembler.Assemble(
Table, "CALLBACK_LOAD.BIN",
[(0x2, Array.Empty<Operand>())], []), 0x00fefefd);
var provider = new SaveUiProvider(nativeScripts, null, loadCallback);
var host = new ClickFirstSlotHost();
var store = new DirectoryNativeDatStore(root, Identity);
var sharedProfile = new SharedProfile();
Assert.True(sharedProfile.Load(store));
var vm = new VirtualMachine(
nativeScripts.RequireByName("SAVE.BIN"), Table, host,
new VmOptions(MaxSteps: 1_000_000), provider,
new StopAtInstalledResumeSink(),
sharedProfile: sharedProfile,
nativeDatStore: store);
host.Vm = vm;
vm.Globals[0x6241b] = 1;
vm.Globals[0x696] = 0;
Assert.Throws<InstalledResumeReachedException>(() => vm.Run());
}
private static Script WithPackedId(Script source, uint packedId)
=> new()
{
Name = source.Name,
PackedId = packedId,
Header = source.Header,
Instructions = source.Instructions,
IndexByOffset = source.IndexByOffset,
Strings = source.Strings,
BodyDwords = source.BodyDwords,
ReadMessageOffsets = source.ReadMessageOffsets,
ScriptCallOffsets = source.ScriptCallOffsets,
LocalCallOffsets = source.LocalCallOffsets,
};
private static string NewTemporaryDirectory()
{
string path = Path.Combine(
Path.GetTempPath(), "age-save-ui-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
return path;
}
}