Implement native layout-3 save restoration
This commit is contained in:
146
engine/Age.Engine.Tests/NativeNumberedSaveCodecTests.cs
Normal file
146
engine/Age.Engine.Tests/NativeNumberedSaveCodecTests.cs
Normal file
@@ -0,0 +1,146 @@
|
||||
using Age.Engine.Model;
|
||||
using Age.Engine.Persistence;
|
||||
|
||||
public class NativeNumberedSaveCodecTests
|
||||
{
|
||||
[Fact]
|
||||
public void LayoutThreeRoundTripsNativeBanksFramesAndGfxRecords()
|
||||
{
|
||||
var frames = new[]
|
||||
{
|
||||
new NativeSavedScriptFrame(-1, 0, new[] { 2, 4 }, 8, 7),
|
||||
new NativeSavedScriptFrame(0, 0x3389, Array.Empty<int>(), 3, -1),
|
||||
};
|
||||
NativeNumberedSaveState state = NativeNumberedSaveCodec.Empty(frames) with
|
||||
{
|
||||
SavedFrameOwner = 7,
|
||||
EngineState = 9,
|
||||
StateWords = Enumerable.Range(10, 10).ToArray(),
|
||||
IntegerGlobals = new[] { 12, -3, 0x12345678 },
|
||||
FloatGlobals = new[] { BitConverter.SingleToInt32Bits(1.25f) },
|
||||
StringGlobals = new[] { "姫狩り", "", "save" },
|
||||
PointerGlobals = new[] { 2 },
|
||||
PointerStrings = new[] { 0 },
|
||||
LocalPointerScratch = new[] { -1 },
|
||||
GfxObjects =
|
||||
[
|
||||
new NativeSavedGfxObject(0xcf08,
|
||||
Enumerable.Range(0, NativeNumberedSaveState.GfxRecordSize)
|
||||
.Select(i => unchecked((byte)i)).ToArray()),
|
||||
],
|
||||
RangeTransformFirst = 100,
|
||||
RangeTransformCount = 4,
|
||||
RangeTransformRecord = Enumerable.Repeat((byte)0x5a,
|
||||
NativeNumberedSaveState.GfxRecordSize).ToArray(),
|
||||
};
|
||||
|
||||
byte[] encoded = NativeNumberedSaveCodec.Encode(state);
|
||||
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.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);
|
||||
Assert.Equal(state.Frames[0].ResumeIndex, decoded.Frames[0].ResumeIndex);
|
||||
Assert.Equal(state.Frames[0].CallTargetIndex, decoded.Frames[0].CallTargetIndex);
|
||||
Assert.Equal(-1, decoded.Frames[1].CallTargetIndex);
|
||||
Assert.Equal(state.IntegerGlobals, decoded.IntegerGlobals);
|
||||
Assert.Equal(state.FloatGlobals, decoded.FloatGlobals);
|
||||
Assert.Equal(state.StringGlobals, decoded.StringGlobals);
|
||||
Assert.Equal(state.PointerGlobals, decoded.PointerGlobals);
|
||||
Assert.Equal(state.GfxObjects[0].Handle, decoded.GfxObjects[0].Handle);
|
||||
Assert.Equal(state.GfxObjects[0].Record, decoded.GfxObjects[0].Record);
|
||||
Assert.Equal(state.RangeTransformRecord, decoded.RangeTransformRecord);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HistoryTailRoundTripsLogicalEntriesRecordsAndCp932Text()
|
||||
{
|
||||
var history = new AdvTextHistory();
|
||||
history.DefineLayout(1, 400, 120, 75, 340);
|
||||
history.SetCursor(1, 8, 12);
|
||||
history.AppendText(1, 0x123, "セーブ履歴", AdvTextStyle.Default with
|
||||
{
|
||||
PrimaryFontSize = 24,
|
||||
TextColor = 0xff112233,
|
||||
});
|
||||
history.AppendMetadata(42, 7, AdvTextStyle.Default);
|
||||
|
||||
byte[] encoded = NativeTextHistoryCodec.Encode(history);
|
||||
var restored = new AdvTextHistory();
|
||||
NativeTextHistoryCodec.DecodeInto(encoded, restored);
|
||||
|
||||
Assert.Equal(history.Entries, restored.Entries);
|
||||
Assert.Equal(2, restored.Records.Count);
|
||||
Assert.Equal("セーブ履歴", restored.Records[0].Text);
|
||||
Assert.Equal(42, restored.Records[1].Value);
|
||||
Assert.Equal(7, restored.Records[1].AuxValue);
|
||||
Assert.Equal(AdvTextHistoryRecordKind.Metadata, restored.Records[1].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectoryStorePreservesHistoryTailAfterNativeContainer()
|
||||
{
|
||||
string root = Path.Combine(Path.GetTempPath(), "age-numbered-tail-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var identity = new NativeSaveIdentity(
|
||||
NativeSaveMagic.S4SD, 1, "test", 3, 10, NumberedCompatibilityId: 2);
|
||||
var store = new DirectoryNativeDatStore(root, identity);
|
||||
byte[] payload = NativeNumberedSaveCodec.Encode(NativeNumberedSaveCodec.Empty(
|
||||
[new NativeSavedScriptFrame(-1, 0, Array.Empty<int>(), -1, -1)]));
|
||||
byte[] tail = [1, 2, 3, 4, 5];
|
||||
|
||||
store.SaveNumberedFile(
|
||||
3, payload, tail,
|
||||
new NativeSystemTime(2026, 7, 5, 24, 12, 30, 0, 0), 99);
|
||||
NativeNumberedSaveFile loaded = store.LoadNumberedFile(3)!;
|
||||
|
||||
Assert.Equal(payload, loaded.Document.Payload);
|
||||
Assert.Equal(tail, loaded.HistoryTail);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstalledHimegariLayoutThreeAndHistoryTailDecodeWhenPresent()
|
||||
{
|
||||
string eushullyRoot = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Eushully");
|
||||
if (!Directory.Exists(eushullyRoot)) return;
|
||||
string? path = Directory.EnumerateFiles(
|
||||
eushullyRoot, "SAVE00.DAT", SearchOption.AllDirectories)
|
||||
.FirstOrDefault(candidate =>
|
||||
{
|
||||
try
|
||||
{
|
||||
NativeSaveMetadata metadata = NativeSaveContainerCodec.ReadMetadata(
|
||||
File.ReadAllBytes(candidate));
|
||||
return metadata.CompatibilityId == 0x42323234
|
||||
&& metadata.SaveVersion1 == 3 && metadata.SaveVersion2 == 10;
|
||||
}
|
||||
catch (Exception error) when (
|
||||
error is IOException or UnauthorizedAccessException or InvalidDataException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (path == null) return;
|
||||
|
||||
byte[] source = File.ReadAllBytes(path);
|
||||
NativeSaveDocument document = NativeSaveContainerCodec.Decode(source);
|
||||
NativeNumberedSaveState state = NativeNumberedSaveCodec.Decode(document.Payload);
|
||||
var history = new AdvTextHistory();
|
||||
NativeTextHistoryCodec.DecodeInto(source.AsSpan(document.BytesConsumed), history);
|
||||
|
||||
Assert.NotEmpty(state.Frames);
|
||||
Assert.Equal(402459, state.IntegerGlobals.Count);
|
||||
Assert.Equal(789, state.StringGlobals.Count);
|
||||
Assert.Equal(NativeNumberedSaveState.GfxRecordSize, state.RangeTransformRecord.Length);
|
||||
}
|
||||
}
|
||||
226
engine/Age.Engine.Tests/NumberedSaveVmTests.cs
Normal file
226
engine/Age.Engine.Tests/NumberedSaveVmTests.cs
Normal file
@@ -0,0 +1,226 @@
|
||||
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",
|
||||
[
|
||||
(0x1ad, Array.Empty<Operand>()),
|
||||
(0x19e, [new Operand(GlobalInt, 0x20), new Operand(Immediate, 2)]),
|
||||
(0x2, Array.Empty<Operand>()),
|
||||
], []), 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]);
|
||||
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("姫狩り", 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<Operand>()),
|
||||
(0x3, [new Operand(Immediate, 0x89)]),
|
||||
(Table.ByLabel("mov")!.Value,
|
||||
[new Operand(GlobalInt, 0x500), new Operand(GlobalInt, 0x123)]),
|
||||
(0x2, Array.Empty<Operand>()),
|
||||
], []), 0x88), scriptCallOffsets: [1]);
|
||||
Script child = WithPackedId(ScriptAssembler.Assemble(Table, "CHILD.BIN",
|
||||
[
|
||||
(0xae, Array.Empty<Operand>()),
|
||||
(Table.ByLabel("mov")!.Value,
|
||||
[new Operand(GlobalInt, 0x501), new Operand(GlobalInt, 0x123)]),
|
||||
(0x2, Array.Empty<Operand>()),
|
||||
], []), 0x89);
|
||||
Script loader = WithPackedId(ScriptAssembler.Assemble(Table, "LOADER.BIN",
|
||||
[
|
||||
(0x1a1, [new Operand(LocalInt, 0), new Operand(Immediate, 1)]),
|
||||
(0x2, Array.Empty<Operand>()),
|
||||
], []), 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<Operand>()),
|
||||
], []), 0x90);
|
||||
NativeNumberedSaveState state = NativeNumberedSaveCodec.Empty(
|
||||
[
|
||||
new NativeSavedScriptFrame(-1, 0x88, Array.Empty<int>(), -1, 0),
|
||||
new NativeSavedScriptFrame(0, 0x89, Array.Empty<int>(), -1, -1),
|
||||
]) with
|
||||
{
|
||||
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 vm = new VirtualMachine(
|
||||
loader, Table, new RecordingHost(), provider: new MapProvider(
|
||||
new()
|
||||
{
|
||||
[0x88] = resumed,
|
||||
[0x89] = child,
|
||||
},
|
||||
new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["CALLBACK_LOAD.BIN"] = callback,
|
||||
}),
|
||||
textHistory: liveHistory, nativeDatStore: store);
|
||||
vm.Globals[0x123] = 999;
|
||||
|
||||
vm.Run();
|
||||
|
||||
Assert.Equal(456, vm.Globals[0x123]);
|
||||
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("履歴復帰", 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.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<int>? scriptCallOffsets = null,
|
||||
IReadOnlyList<int>? 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;
|
||||
}
|
||||
}
|
||||
@@ -176,8 +176,14 @@ internal class RecordingHost : IHost
|
||||
internal sealed class MapProvider : IScriptProvider
|
||||
{
|
||||
private readonly Dictionary<long, Script> _m;
|
||||
public MapProvider(Dictionary<long, Script> m) => _m = m;
|
||||
private readonly Dictionary<string, Script> _byName;
|
||||
public MapProvider(Dictionary<long, Script> m, Dictionary<string, Script>? byName = null)
|
||||
{
|
||||
_m = m;
|
||||
_byName = byName ?? new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
public Script? GetById(long id) => _m.TryGetValue(id, out var s) ? s : null;
|
||||
public Script? GetByName(string name) => _byName.TryGetValue(name, out var s) ? s : null;
|
||||
}
|
||||
|
||||
/// <summary>A controlled test double: every call-script id resolves to the same script (typically a
|
||||
|
||||
Reference in New Issue
Block a user