Implement numbered save pair lifecycle
This commit is contained in:
256
engine/Age.Engine.Tests/NumberedSavePairTests.cs
Normal file
256
engine/Age.Engine.Tests/NumberedSavePairTests.cs
Normal file
@@ -0,0 +1,256 @@
|
||||
using System.Buffers.Binary;
|
||||
using Age.Engine.Model;
|
||||
using Age.Engine.Persistence;
|
||||
using Age.Engine.Sys4;
|
||||
using Age.Engine.Vm;
|
||||
|
||||
public class NumberedSavePairTests
|
||||
{
|
||||
private const int Immediate = 0;
|
||||
private const int GlobalInt = 3;
|
||||
private static readonly OpcodeTable Table = OpcodeTableJson.Load(Paths.OpcodesJson);
|
||||
private static readonly NativeSystemTime Timestamp =
|
||||
new(2026, 7, 5, 24, 13, 42, 17, 321);
|
||||
private static readonly NativeSaveIdentity Identity =
|
||||
new(NativeSaveMagic.S4SD, 0x4a343234, "numbered-test", 3, 10, 0x42323234);
|
||||
|
||||
[Fact]
|
||||
public void ThumbnailCodecWritesNativeBottomUpBmpAndRoundTrips()
|
||||
{
|
||||
var image = new RgbaImage(2, 2,
|
||||
[
|
||||
255, 0, 0, 255, 0, 255, 0, 255,
|
||||
0, 0, 255, 255, 255, 255, 255, 255,
|
||||
]);
|
||||
|
||||
byte[] encoded = NumberedThumbnailCodec.Encode(image);
|
||||
|
||||
Assert.Equal((byte)'B', encoded[0]);
|
||||
Assert.Equal((byte)'M', encoded[1]);
|
||||
Assert.Equal(56u, BinaryPrimitives.ReadUInt32LittleEndian(encoded.AsSpan(2)));
|
||||
Assert.Equal(70, encoded.Length);
|
||||
Assert.Equal(54u, BinaryPrimitives.ReadUInt32LittleEndian(encoded.AsSpan(10)));
|
||||
Assert.Equal(2, BinaryPrimitives.ReadInt32LittleEndian(encoded.AsSpan(18)));
|
||||
Assert.Equal(2, BinaryPrimitives.ReadInt32LittleEndian(encoded.AsSpan(22)));
|
||||
Assert.Equal((ushort)24, BinaryPrimitives.ReadUInt16LittleEndian(encoded.AsSpan(28)));
|
||||
Assert.Equal(new byte[] { 255, 0, 0, 255, 255, 255 }, encoded[54..60]);
|
||||
|
||||
RgbaImage decoded = NumberedThumbnailCodec.Decode(encoded);
|
||||
Assert.Equal(image.Width, decoded.Width);
|
||||
Assert.Equal(image.Height, decoded.Height);
|
||||
Assert.Equal(image.Pixels, decoded.Pixels);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstalledHimegariThumbnailMatchesNativeBmpDialectWhenPresent()
|
||||
{
|
||||
string eushullyRoot = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Eushully");
|
||||
if (!Directory.Exists(eushullyRoot)) return;
|
||||
|
||||
string? dataPath = Directory.EnumerateFiles(
|
||||
eushullyRoot, "SAVE00.DAT", SearchOption.AllDirectories)
|
||||
.FirstOrDefault(path =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
byte[] header = new byte[NativeSaveContainerCodec.HeaderSize];
|
||||
stream.ReadExactly(header);
|
||||
NativeSaveMetadata metadata = NativeSaveContainerCodec.ReadMetadata(header);
|
||||
return metadata.CompatibilityId == 0x42323234
|
||||
&& metadata.SaveVersion1 == 3 && metadata.SaveVersion2 == 10;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException
|
||||
or UnauthorizedAccessException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (dataPath == null) return;
|
||||
string thumbnailPath = Path.ChangeExtension(dataPath, ".STH");
|
||||
if (!File.Exists(thumbnailPath)) return;
|
||||
|
||||
byte[] native = File.ReadAllBytes(thumbnailPath);
|
||||
RgbaImage decoded = NumberedThumbnailCodec.Decode(native);
|
||||
|
||||
Assert.Equal(112, decoded.Width);
|
||||
Assert.Equal(84, decoded.Height);
|
||||
Assert.Equal(28278, native.Length);
|
||||
Assert.Equal(28264u, BinaryPrimitives.ReadUInt32LittleEndian(native.AsSpan(2)));
|
||||
Assert.Equal(54u, BinaryPrimitives.ReadUInt32LittleEndian(native.AsSpan(10)));
|
||||
Assert.Equal(native.Length, NumberedThumbnailCodec.Encode(decoded).Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NumberedMetadataUsesItsOwnIdentityAndDoesNotDecodePayload()
|
||||
{
|
||||
string root = NewTempRoot();
|
||||
try
|
||||
{
|
||||
var store = new DirectoryNativeDatStore(root, Identity);
|
||||
store.SaveNumbered(4, [1, 2, 3, 4], Timestamp, 54321);
|
||||
string path = Path.Combine(root, "SAVE04.DAT");
|
||||
using (var stream = new FileStream(path, FileMode.Open, FileAccess.Write, FileShare.None))
|
||||
stream.SetLength(NativeSaveContainerCodec.HeaderSize);
|
||||
|
||||
NativeSaveMetadata metadata = store.QueryNumberedMetadata(4)!;
|
||||
|
||||
Assert.Equal(0x42323234u, metadata.CompatibilityId);
|
||||
Assert.Equal(Timestamp, metadata.Timestamp);
|
||||
Assert.Equal(54321u, metadata.AccumulatedPlaySeconds);
|
||||
Assert.Throws<InvalidDataException>(() => store.LoadNumbered(4));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PairOperationsAttemptBothMembersAndPreserveNativeStatusPrecedence()
|
||||
{
|
||||
string root = NewTempRoot();
|
||||
try
|
||||
{
|
||||
var store = new DirectoryNativeDatStore(root, Identity);
|
||||
|
||||
store.SaveNumbered(1, [1, 2, 3, 4], Timestamp, 1);
|
||||
Assert.Equal(2, store.CopyNumberedPair(1, 2));
|
||||
Assert.NotNull(store.LoadNumbered(2));
|
||||
Assert.Null(store.LoadNumberedThumbnail(2));
|
||||
|
||||
store.SaveNumberedThumbnail(3, [7, 8, 9]);
|
||||
Assert.Equal(1, store.CopyNumberedPair(3, 4));
|
||||
Assert.Null(store.LoadNumbered(4));
|
||||
Assert.Equal(new byte[] { 7, 8, 9 }, store.LoadNumberedThumbnail(4));
|
||||
|
||||
Assert.Equal(2, store.DeleteNumberedPair(1));
|
||||
Assert.Equal(1, store.DeleteNumberedPair(3));
|
||||
Assert.Equal(2, store.DeleteNumberedPair(99));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VmMetadataThumbnailCopyAndDeleteOpcodesUseTheNativePairStore()
|
||||
{
|
||||
string root = NewTempRoot();
|
||||
try
|
||||
{
|
||||
var store = new DirectoryNativeDatStore(root, Identity);
|
||||
store.SaveNumbered(4, [1, 2, 3, 4], Timestamp, 54321);
|
||||
var host = new RecordingHost();
|
||||
host.SurfacePixels[7] = new RgbaImage(1, 1, [10, 20, 30, 255]);
|
||||
Script script = ScriptAssembler.Assemble(Table, "NUMBERED_SAVE_OPS",
|
||||
[
|
||||
(0x1a0, [
|
||||
G(100), I(4), G(101), G(102), G(103), G(104), G(105), G(106), G(107),
|
||||
]),
|
||||
(0x1ae, [G(110), I(4), I(7)]),
|
||||
(0x1af, [G(111), I(4), I(8)]),
|
||||
(0x1ac, [G(112), I(4), I(5)]),
|
||||
(0x1ab, [G(113), I(5)]),
|
||||
(0x2, []),
|
||||
], []);
|
||||
var vm = new VirtualMachine(script, Table, host, nativeDatStore: store);
|
||||
|
||||
vm.Run();
|
||||
|
||||
Assert.Equal(0, vm.Globals[100]);
|
||||
Assert.Equal(2026, vm.Globals[101]);
|
||||
Assert.Equal(7, vm.Globals[102]);
|
||||
Assert.Equal(24, vm.Globals[103]);
|
||||
Assert.Equal(13, vm.Globals[104]);
|
||||
Assert.Equal(42, vm.Globals[105]);
|
||||
Assert.Equal(17, vm.Globals[106]);
|
||||
Assert.Equal(54321, vm.Globals[107]);
|
||||
Assert.Equal(0, vm.Globals[110]);
|
||||
Assert.Equal(0, vm.Globals[111]);
|
||||
Assert.Equal(new byte[] { 10, 20, 30, 255 }, host.SurfacePixels[8].Pixels);
|
||||
Assert.Equal(0, vm.Globals[112]);
|
||||
Assert.Equal(0, vm.Globals[113]);
|
||||
Assert.Null(store.LoadNumbered(5));
|
||||
Assert.Null(store.LoadNumberedThumbnail(5));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveResumeMarkerSurvivesNestedReturnUntilItsOwningFrameUnwinds()
|
||||
{
|
||||
int callScript = Table.ByLabel("call-script")!.Value;
|
||||
Script child = ScriptAssembler.Assemble(Table, "CHILD",
|
||||
[
|
||||
(0x1a8, []),
|
||||
(0x2, []),
|
||||
], []);
|
||||
Script root = ScriptAssembler.Assemble(Table, "ROOT",
|
||||
[
|
||||
(0x1ad, []),
|
||||
(callScript, [I(7)]),
|
||||
(0x2, []),
|
||||
], []);
|
||||
var provider = new MapProvider(new Dictionary<long, Script> { [7] = child });
|
||||
var host = new MarkerObservingHost();
|
||||
var vm = new VirtualMachine(root, Table, host, provider: provider);
|
||||
host.Vm = vm;
|
||||
|
||||
vm.Run();
|
||||
|
||||
Assert.NotEmpty(host.ObservedDepths);
|
||||
Assert.All(host.ObservedDepths, depth => Assert.Equal(0, depth));
|
||||
Assert.Null(vm.SaveResumeFrameDepth);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChildMarkerIsClearedWhenTheChildFrameUnwinds()
|
||||
{
|
||||
int callScript = Table.ByLabel("call-script")!.Value;
|
||||
Script child = ScriptAssembler.Assemble(Table, "CHILD",
|
||||
[
|
||||
(0x1ad, []),
|
||||
(0x2, []),
|
||||
], []);
|
||||
Script root = ScriptAssembler.Assemble(Table, "ROOT",
|
||||
[
|
||||
(callScript, [I(7)]),
|
||||
(0x1a8, []),
|
||||
(0x2, []),
|
||||
], []);
|
||||
var provider = new MapProvider(new Dictionary<long, Script> { [7] = child });
|
||||
var host = new MarkerObservingHost();
|
||||
var vm = new VirtualMachine(root, Table, host, provider: provider);
|
||||
host.Vm = vm;
|
||||
|
||||
vm.Run();
|
||||
|
||||
Assert.Contains(1, host.ObservedDepths);
|
||||
int marked = host.ObservedDepths.FindLastIndex(depth => depth == 1);
|
||||
Assert.Contains(host.ObservedDepths.Skip(marked + 1), depth => depth == null);
|
||||
Assert.Null(vm.SaveResumeFrameDepth);
|
||||
}
|
||||
|
||||
private static Operand I(long value) => new(Immediate, value);
|
||||
private static Operand G(long value) => new(GlobalInt, value);
|
||||
|
||||
private static string NewTempRoot()
|
||||
{
|
||||
string root = Path.Combine(Path.GetTempPath(), "age-numbered-save-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
private sealed class MarkerObservingHost : RecordingHost
|
||||
{
|
||||
public VirtualMachine Vm { get; set; } = null!;
|
||||
public List<int?> ObservedDepths { get; } = new();
|
||||
public override void FrameYield() => ObservedDepths.Add(Vm.SaveResumeFrameDepth);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user