using Age.Engine.Model; using Age.Engine.Persistence; using Age.Engine.Sys4; using Age.Engine.Vm; using System.Buffers.Binary; public class NumberedSaveVmTests { private const int Immediate = 0; private const int GlobalInt = 3; private const int LocalInt = 9; private static readonly OpcodeTable Table = OpcodeTableJson.Load(Paths.OpcodesJson); private static readonly NativeSaveIdentity Identity = new(NativeSaveMagic.S4SD, 0x4a343234, "numbered-vm-test", 3, 10, 0x42323234); [Fact] public void SaveOpcodeWritesLayoutThreeStateHistoryAndRetainedGfx() { string root = NewTemporaryDirectory(); try { 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()), (0x19e, [new Operand(GlobalInt, 0x20), new Operand(Immediate, 2)]), (0x2, Array.Empty()), ], []), 0x77); var history = new AdvTextHistory(); history.DefineLayout(1, 400, 120, 75, 340); history.AppendText(1, 0x40, "保存", AdvTextStyle.Default); var vm = new VirtualMachine( script, Table, new RecordingHost(), textHistory: history, nativeDatStore: store); vm.Globals[0x123] = 456; vm.GlobalFloats[0] = BitConverter.SingleToInt32Bits(2.5f); vm.GlobalStrings[4] = "姫狩り"; vm.Gfx.SetSurface(3, 0x1234, 0xff00ff); vm.Gfx.BindDraw(100, 3, 1, 2, 30, 40, 50, 60); 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); var restoredHistory = new AdvTextHistory(); NativeTextHistoryCodec.DecodeInto(file.HistoryTail, restoredHistory); Assert.Equal("保存", restoredHistory.Records.Single().Text); Assert.NotNull(store.LoadShared()); } finally { Directory.Delete(root, recursive: true); } } [Fact] public void FullLoadRestoresBanksThenResumesSavedScriptThroughOpcodeAe() { string root = NewTemporaryDirectory(); try { var store = new DirectoryNativeDatStore(root, Identity); Script resumed = WithTables(WithPackedId(ScriptAssembler.Assemble(Table, "RESUMED.BIN", [ (0xae, Array.Empty()), (0x3, [new Operand(Immediate, 0x89)]), (Table.ByLabel("mov")!.Value, [new Operand(GlobalInt, 0x500), new Operand(GlobalInt, 0x123)]), (0x2, Array.Empty()), ], []), 0x88), scriptCallOffsets: [1]); Script child = WithPackedId(ScriptAssembler.Assemble(Table, "CHILD.BIN", [ (0xae, Array.Empty()), (Table.ByLabel("mov")!.Value, [new Operand(GlobalInt, 0x501), new Operand(GlobalInt, 0x123)]), (0x2, Array.Empty()), ], []), 0x89); Script loader = WithPackedId(ScriptAssembler.Assemble(Table, "LOADER.BIN", [ (0x1a1, [new Operand(LocalInt, 0), new Operand(Immediate, 1)]), (0x2, Array.Empty()), ], []), 0x99); Script callback = WithPackedId(ScriptAssembler.Assemble(Table, "CALLBACK_LOAD.BIN", [ (Table.ByLabel("mov")!.Value, [new Operand(GlobalInt, 0x502), new Operand(Immediate, 1)]), (0x2, Array.Empty()), ], []), 0x90); NativeNumberedSaveState state = NativeNumberedSaveCodec.Empty( [ new NativeSavedScriptFrame(-1, 0x88, Array.Empty(), -1, 0), new NativeSavedScriptFrame(0, 0x89, Array.Empty(), -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 = ["復帰"], PointerGlobals = [0x123], PointerStrings = [0], LocalPointerScratch = [0], SurfaceRecords = NativeSurfaceRecords(3, 0x1234, 0xff00ff), GfxObjects = [new NativeSavedGfxObject(100, NativeGfxRecord(3))], }; var history = new AdvTextHistory(); history.DefineLayout(1, 100, 50, 0, 0); history.AppendText(1, 0, "履歴復帰", AdvTextStyle.Default); store.SaveNumberedFile( 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, host, provider: new MapProvider( new() { [0x88] = resumed, [0x89] = child, }, new(StringComparer.OrdinalIgnoreCase) { ["CALLBACK_LOAD.BIN"] = callback, }), 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 { Directory.Delete(root, recursive: true); } } 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 Script WithTables( Script source, IReadOnlyList? scriptCallOffsets = null, IReadOnlyList? localCallOffsets = null) => new() { Name = source.Name, PackedId = source.PackedId, Header = source.Header, Instructions = source.Instructions, IndexByOffset = source.IndexByOffset, Strings = source.Strings, BodyDwords = source.BodyDwords, ReadMessageOffsets = source.ReadMessageOffsets, ScriptCallOffsets = scriptCallOffsets ?? source.ScriptCallOffsets, LocalCallOffsets = localCallOffsets ?? source.LocalCallOffsets, }; private static int[] DenseIntBank(int count, params (int Index, int Value)[] values) { var result = new int[count]; foreach (var (index, value) in values) result[index] = value; return result; } private static byte[] NativeSurfaceRecords(int slot, int resourceId, int colorKey) { byte[] result = new byte[NativeNumberedSaveState.SurfaceRecordsSize]; int at = slot * 20; BinaryPrimitives.WriteInt32LittleEndian(result.AsSpan(at), resourceId); BinaryPrimitives.WriteInt32LittleEndian(result.AsSpan(at + 4), unchecked((int)(0xff000000u | (uint)colorKey))); BinaryPrimitives.WriteInt32LittleEndian(result.AsSpan(at + 8), 1); return result; } private static byte[] NativeGfxRecord(int sourceSlot) { byte[] result = new byte[NativeNumberedSaveState.GfxRecordSize]; void Write(int offset, int value) => BinaryPrimitives.WriteInt32LittleEndian(result.AsSpan(offset), value); Write(0, 1); Write(4, sourceSlot); Write(8, 1); Write(0x0c, 2); Write(0x10, 31); Write(0x14, 42); Write(0x24, 50); Write(0x28, 60); Write(0x60, -1); Write(0x238, 1); Write(0x23c, 1); return result; } private static string NewTemporaryDirectory() { string path = Path.Combine(Path.GetTempPath(), "age-save-vm-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(path); return path; } }