Implement numbered save pair lifecycle

This commit is contained in:
gamer147
2026-07-24 15:32:55 -04:00
parent bda586aa28
commit 3b63c42826
16 changed files with 730 additions and 38 deletions

View 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);
}
}

View File

@@ -3,6 +3,7 @@ using System.Linq;
using Age.Engine.Diagnostics;
using Age.Engine.Hosting;
using Age.Engine.Model;
using Age.Engine.Sys4;
/// <summary>Shared test doubles: a host that records observable effects, and an in-memory script
/// provider for synthetic call-script targets.</summary>
@@ -53,10 +54,18 @@ internal class RecordingHost : IHost
public readonly List<string> Warnings = new();
public readonly List<long> CursorResources = new();
public readonly List<bool> AdvPagePresentationSuspended = new();
public readonly Dictionary<int, RgbaImage> SurfacePixels = new();
public int CursorClearCount;
public int SceneContextResets;
public void ReportWarning(string message) => Warnings.Add(message);
public void ShowText(int offset, string text) => Lines.Add((offset, text));
public RgbaImage? CaptureSurfacePixels(int slot)
=> SurfacePixels.TryGetValue(slot, out var image) ? image : null;
public bool ReplaceSurfacePixels(int slot, RgbaImage image)
{
SurfacePixels[slot] = image;
return true;
}
public void SetAdvTextCursor(int layoutSlot, int x, int y) => TextCursors.Add((layoutSlot, x, y));
public void DrawStringToSurface(int surfaceSlot, int x, int y, string text)
=> SurfaceStrings.Add((surfaceSlot, x, y, text));