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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Age.Engine.Model;
|
||||
using Age.Engine.Sys4;
|
||||
|
||||
namespace Age.Engine.Hosting;
|
||||
|
||||
@@ -91,6 +92,10 @@ public interface IHost
|
||||
// numbered surfaces, then block while the engine alpha-composites target over source.
|
||||
void CrossfadeSurfaces(GfxState gfx, int sourceSurface, int targetSurface, long intervalArgument) { }
|
||||
void CreateTexture(int slot, int width, int height);
|
||||
/// <summary>Return a stable RGBA snapshot of one numbered surface, or null when unavailable.</summary>
|
||||
RgbaImage? CaptureSurfacePixels(int slot) => null;
|
||||
/// <summary>Replace one numbered surface from decoded RGBA pixels. False means unsupported.</summary>
|
||||
bool ReplaceSurfacePixels(int slot, RgbaImage image) => false;
|
||||
void SetTexture(long resourceId, int slot);
|
||||
void SetTexture(long resourceId, int slot, long colorKey) => SetTexture(resourceId, slot);
|
||||
void ReleaseSurface(int slot) { }
|
||||
|
||||
@@ -7,16 +7,25 @@ public sealed record NativeSaveIdentity(
|
||||
uint CompatibilityId,
|
||||
string GameId,
|
||||
int SaveVersion1,
|
||||
int SaveVersion2)
|
||||
int SaveVersion2,
|
||||
uint? NumberedCompatibilityId = null)
|
||||
{
|
||||
public NativeSaveMetadata CreateMetadata(NativeSystemTime timestamp, uint accumulatedPlaySeconds)
|
||||
=> new(Magic, CompatibilityId, GameId, timestamp, accumulatedPlaySeconds, SaveVersion1, SaveVersion2);
|
||||
public uint EffectiveNumberedCompatibilityId => NumberedCompatibilityId ?? CompatibilityId;
|
||||
|
||||
public void Validate(NativeSaveMetadata metadata)
|
||||
public NativeSaveMetadata CreateMetadata(
|
||||
NativeSystemTime timestamp,
|
||||
uint accumulatedPlaySeconds,
|
||||
bool numbered = false)
|
||||
=> new(
|
||||
Magic, numbered ? EffectiveNumberedCompatibilityId : CompatibilityId, GameId,
|
||||
timestamp, accumulatedPlaySeconds, SaveVersion1, SaveVersion2);
|
||||
|
||||
public void Validate(NativeSaveMetadata metadata, bool numbered = false)
|
||||
{
|
||||
if (metadata.Magic != Magic)
|
||||
throw new InvalidDataException($"Native save generation mismatch: expected {Magic}, got {metadata.Magic}.");
|
||||
if (metadata.CompatibilityId != CompatibilityId)
|
||||
uint expectedCompatibilityId = numbered ? EffectiveNumberedCompatibilityId : CompatibilityId;
|
||||
if (metadata.CompatibilityId != expectedCompatibilityId)
|
||||
throw new InvalidDataException("Native save compatibility id mismatch.");
|
||||
if (!StringComparer.Ordinal.Equals(metadata.GameId, GameId))
|
||||
throw new InvalidDataException("Native save game id mismatch.");
|
||||
@@ -41,8 +50,13 @@ public interface INativeDatStore
|
||||
void SaveShared(ReadOnlySpan<byte> payload, NativeSystemTime timestamp, uint accumulatedPlaySeconds);
|
||||
ReadTextDatabaseSnapshot? LoadReadText();
|
||||
void SaveReadText(ReadTextDatabaseSnapshot snapshot);
|
||||
NativeSaveMetadata? QueryNumberedMetadata(int slot);
|
||||
NativeSaveDocument? LoadNumbered(int slot);
|
||||
void SaveNumbered(int slot, ReadOnlySpan<byte> payload, NativeSystemTime timestamp, uint accumulatedPlaySeconds);
|
||||
int DeleteNumberedPair(int slot);
|
||||
int CopyNumberedPair(int sourceSlot, int destinationSlot);
|
||||
byte[]? LoadNumberedThumbnail(int slot);
|
||||
void SaveNumberedThumbnail(int slot, ReadOnlySpan<byte> data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -143,7 +157,21 @@ public sealed class DirectoryNativeDatStore : INativeDatStore
|
||||
public NativeSaveDocument? LoadNumbered(int slot)
|
||||
{
|
||||
string path = Path.Combine(_root, NumberedFileName(slot));
|
||||
return File.Exists(path) ? LoadAndValidate(path) : null;
|
||||
return File.Exists(path) ? LoadAndValidate(path, numbered: true) : null;
|
||||
}
|
||||
|
||||
public NativeSaveMetadata? QueryNumberedMetadata(int slot)
|
||||
{
|
||||
string path = Path.Combine(_root, NumberedFileName(slot));
|
||||
if (!File.Exists(path)) return null;
|
||||
using var stream = new FileStream(
|
||||
path, FileMode.Open, FileAccess.Read, FileShare.Read, NativeSaveContainerCodec.HeaderSize,
|
||||
FileOptions.SequentialScan);
|
||||
byte[] header = new byte[NativeSaveContainerCodec.HeaderSize];
|
||||
stream.ReadExactly(header);
|
||||
NativeSaveMetadata metadata = NativeSaveContainerCodec.ReadMetadata(header);
|
||||
_identity.Validate(metadata, numbered: true);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
public void SaveNumbered(
|
||||
@@ -153,24 +181,84 @@ public sealed class DirectoryNativeDatStore : INativeDatStore
|
||||
uint accumulatedPlaySeconds)
|
||||
{
|
||||
byte[] encoded = NativeSaveContainerCodec.Encode(
|
||||
payload, _identity.CreateMetadata(timestamp, accumulatedPlaySeconds));
|
||||
payload, _identity.CreateMetadata(timestamp, accumulatedPlaySeconds, numbered: true));
|
||||
Directory.CreateDirectory(_root);
|
||||
WriteThrough(Path.Combine(_root, NumberedFileName(slot)), encoded);
|
||||
}
|
||||
|
||||
public int DeleteNumberedPair(int slot)
|
||||
{
|
||||
bool dataDeleted = TryDelete(Path.Combine(_root, NumberedFileName(slot)));
|
||||
bool thumbnailDeleted = TryDelete(Path.Combine(_root, NumberedThumbnailFileName(slot)));
|
||||
return !thumbnailDeleted ? 2 : !dataDeleted ? 1 : 0;
|
||||
}
|
||||
|
||||
public int CopyNumberedPair(int sourceSlot, int destinationSlot)
|
||||
{
|
||||
Directory.CreateDirectory(_root);
|
||||
bool dataCopied = TryCopy(
|
||||
Path.Combine(_root, NumberedFileName(sourceSlot)),
|
||||
Path.Combine(_root, NumberedFileName(destinationSlot)));
|
||||
bool thumbnailCopied = TryCopy(
|
||||
Path.Combine(_root, NumberedThumbnailFileName(sourceSlot)),
|
||||
Path.Combine(_root, NumberedThumbnailFileName(destinationSlot)));
|
||||
return !thumbnailCopied ? 2 : !dataCopied ? 1 : 0;
|
||||
}
|
||||
|
||||
public byte[]? LoadNumberedThumbnail(int slot)
|
||||
{
|
||||
string path = Path.Combine(_root, NumberedThumbnailFileName(slot));
|
||||
return File.Exists(path) ? File.ReadAllBytes(path) : null;
|
||||
}
|
||||
|
||||
public void SaveNumberedThumbnail(int slot, ReadOnlySpan<byte> data)
|
||||
{
|
||||
Directory.CreateDirectory(_root);
|
||||
WriteThrough(Path.Combine(_root, NumberedThumbnailFileName(slot)), data);
|
||||
}
|
||||
|
||||
public static string NumberedFileName(int slot)
|
||||
{
|
||||
if (slot < 0) throw new ArgumentOutOfRangeException(nameof(slot));
|
||||
return "SAVE" + slot.ToString("00", CultureInfo.InvariantCulture) + ".DAT";
|
||||
}
|
||||
|
||||
private NativeSaveDocument LoadAndValidate(string path)
|
||||
public static string NumberedThumbnailFileName(int slot)
|
||||
{
|
||||
if (slot < 0) throw new ArgumentOutOfRangeException(nameof(slot));
|
||||
return "SAVE" + slot.ToString("00", CultureInfo.InvariantCulture) + ".STH";
|
||||
}
|
||||
|
||||
private NativeSaveDocument LoadAndValidate(string path, bool numbered = false)
|
||||
{
|
||||
NativeSaveDocument document = NativeSaveContainerCodec.Decode(File.ReadAllBytes(path));
|
||||
_identity.Validate(document.Metadata);
|
||||
_identity.Validate(document.Metadata, numbered);
|
||||
return document;
|
||||
}
|
||||
|
||||
private static bool TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path)) return false;
|
||||
File.Delete(path);
|
||||
return true;
|
||||
}
|
||||
catch (IOException) { return false; }
|
||||
catch (UnauthorizedAccessException) { return false; }
|
||||
}
|
||||
|
||||
private static bool TryCopy(string source, string destination)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Copy(source, destination, overwrite: true);
|
||||
return true;
|
||||
}
|
||||
catch (IOException) { return false; }
|
||||
catch (UnauthorizedAccessException) { return false; }
|
||||
}
|
||||
|
||||
private static void WriteThrough(string path, ReadOnlySpan<byte> data)
|
||||
{
|
||||
using var stream = new FileStream(
|
||||
|
||||
105
engine/Age.Engine/Persistence/NumberedThumbnailCodec.cs
Normal file
105
engine/Age.Engine/Persistence/NumberedThumbnailCodec.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
using System.Buffers.Binary;
|
||||
using Age.Engine.Sys4;
|
||||
|
||||
namespace Age.Engine.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// AGE numbered-save thumbnails are ordinary uncompressed bottom-up 24-bit BMPs under the .STH
|
||||
/// extension. The native writer's bfSize omits the 14-byte BITMAPFILEHEADER even though the file
|
||||
/// and pixel offset include it; this codec reproduces that harmless historical quirk.
|
||||
/// </summary>
|
||||
public static class NumberedThumbnailCodec
|
||||
{
|
||||
public const int FileHeaderSize = 14;
|
||||
public const int DibHeaderSize = 40;
|
||||
public const int PixelOffset = FileHeaderSize + DibHeaderSize;
|
||||
|
||||
public static byte[] Encode(RgbaImage image)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(image);
|
||||
ValidateRgba(image);
|
||||
if (image.Width <= 0 || image.Height <= 0)
|
||||
throw new InvalidDataException("Numbered thumbnail dimensions must be positive.");
|
||||
|
||||
int rowBytes = checked(image.Width * 3);
|
||||
int rowStride = checked((rowBytes + 3) & ~3);
|
||||
int pixelBytes = checked(rowStride * image.Height);
|
||||
byte[] result = new byte[checked(PixelOffset + pixelBytes)];
|
||||
Span<byte> header = result.AsSpan(0, PixelOffset);
|
||||
header[0] = (byte)'B';
|
||||
header[1] = (byte)'M';
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(
|
||||
header[2..], checked((uint)(DibHeaderSize + pixelBytes)));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(header[10..], (uint)PixelOffset);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(header[14..], (uint)DibHeaderSize);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(header[18..], image.Width);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(header[22..], image.Height);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(header[26..], 1);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(header[28..], 24);
|
||||
|
||||
for (int destinationRow = 0; destinationRow < image.Height; destinationRow++)
|
||||
{
|
||||
int sourceY = image.Height - 1 - destinationRow;
|
||||
int source = sourceY * image.Width * 4;
|
||||
int destination = PixelOffset + destinationRow * rowStride;
|
||||
for (int x = 0; x < image.Width; x++, source += 4, destination += 3)
|
||||
{
|
||||
result[destination] = image.Pixels[source + 2];
|
||||
result[destination + 1] = image.Pixels[source + 1];
|
||||
result[destination + 2] = image.Pixels[source];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static RgbaImage Decode(ReadOnlySpan<byte> source)
|
||||
{
|
||||
if (source.Length < PixelOffset
|
||||
|| source[0] != (byte)'B' || source[1] != (byte)'M')
|
||||
throw new InvalidDataException("Numbered thumbnail is not a BMP file.");
|
||||
uint rawOffset = BinaryPrimitives.ReadUInt32LittleEndian(source[10..]);
|
||||
uint dibSize = BinaryPrimitives.ReadUInt32LittleEndian(source[14..]);
|
||||
int width = BinaryPrimitives.ReadInt32LittleEndian(source[18..]);
|
||||
int storedHeight = BinaryPrimitives.ReadInt32LittleEndian(source[22..]);
|
||||
ushort planes = BinaryPrimitives.ReadUInt16LittleEndian(source[26..]);
|
||||
ushort bitsPerPixel = BinaryPrimitives.ReadUInt16LittleEndian(source[28..]);
|
||||
uint compression = BinaryPrimitives.ReadUInt32LittleEndian(source[30..]);
|
||||
if (dibSize < DibHeaderSize
|
||||
|| (ulong)rawOffset < (ulong)FileHeaderSize + dibSize
|
||||
|| rawOffset > int.MaxValue || width <= 0 || storedHeight == 0
|
||||
|| storedHeight == int.MinValue || planes != 1 || bitsPerPixel != 24 || compression != 0)
|
||||
throw new InvalidDataException("Numbered thumbnail has an unsupported BMP layout.");
|
||||
|
||||
bool bottomUp = storedHeight > 0;
|
||||
int height = Math.Abs(storedHeight);
|
||||
int rowBytes = checked(width * 3);
|
||||
int rowStride = checked((rowBytes + 3) & ~3);
|
||||
int pixelOffset = (int)rawOffset;
|
||||
int pixelBytes = checked(rowStride * height);
|
||||
if (pixelOffset > source.Length || pixelBytes > source.Length - pixelOffset)
|
||||
throw new InvalidDataException("Numbered thumbnail pixel data is truncated.");
|
||||
|
||||
byte[] rgba = new byte[checked(width * height * 4)];
|
||||
for (int storedRow = 0; storedRow < height; storedRow++)
|
||||
{
|
||||
int destinationY = bottomUp ? height - 1 - storedRow : storedRow;
|
||||
int sourcePosition = pixelOffset + storedRow * rowStride;
|
||||
int destination = destinationY * width * 4;
|
||||
for (int x = 0; x < width; x++, sourcePosition += 3, destination += 4)
|
||||
{
|
||||
rgba[destination] = source[sourcePosition + 2];
|
||||
rgba[destination + 1] = source[sourcePosition + 1];
|
||||
rgba[destination + 2] = source[sourcePosition];
|
||||
rgba[destination + 3] = 255;
|
||||
}
|
||||
}
|
||||
return new RgbaImage(width, height, rgba);
|
||||
}
|
||||
|
||||
private static void ValidateRgba(RgbaImage image)
|
||||
{
|
||||
if (image.Width < 0 || image.Height < 0
|
||||
|| image.Pixels.Length != checked(image.Width * image.Height * 4))
|
||||
throw new InvalidDataException("Numbered thumbnail RGBA buffer has invalid dimensions.");
|
||||
}
|
||||
}
|
||||
@@ -23,11 +23,16 @@ public sealed class GameSession
|
||||
public Dictionary<int, string> GlobalStrings { get; } = new();
|
||||
/// <summary>AGE's selected profile-wide cells plus native shared SAVE.DAT/RT.DAT lifecycle.</summary>
|
||||
public SharedProfile SharedProfile { get; }
|
||||
/// <summary>Native shared/numbered save directory service used by persistence opcodes.</summary>
|
||||
public INativeDatStore? NativeDatStore { get; }
|
||||
/// <summary>The live retained ADV backlog shared by every VM run in this session.</summary>
|
||||
public AdvTextHistory TextHistory { get; } = new();
|
||||
|
||||
public GameSession(SharedProfile? sharedProfile = null)
|
||||
=> SharedProfile = sharedProfile ?? new SharedProfile();
|
||||
public GameSession(SharedProfile? sharedProfile = null, INativeDatStore? nativeDatStore = null)
|
||||
{
|
||||
SharedProfile = sharedProfile ?? new SharedProfile();
|
||||
NativeDatStore = nativeDatStore;
|
||||
}
|
||||
|
||||
public void Seed(int addr, long value) => Globals[addr] = value;
|
||||
public void SeedString(int addr, string value) => GlobalStrings[addr] = value;
|
||||
@@ -38,7 +43,7 @@ public sealed class GameSession
|
||||
ITraceSink? sink = null)
|
||||
{
|
||||
var vm = new VirtualMachine(
|
||||
script, table, host, options, provider, sink, TextHistory, SharedProfile);
|
||||
script, table, host, options, provider, sink, TextHistory, SharedProfile, NativeDatStore);
|
||||
foreach (var kv in Globals) vm.Globals[kv.Key] = kv.Value;
|
||||
foreach (var kv in GlobalStrings) vm.GlobalStrings[kv.Key] = kv.Value;
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ public sealed class VirtualMachine
|
||||
private readonly Encoding _nativeStringEncoding;
|
||||
private readonly IScriptProvider? _provider;
|
||||
private readonly SharedProfile _sharedProfile;
|
||||
private readonly INativeDatStore? _nativeDatStore;
|
||||
private static readonly bool _diagSetTexture = System.Environment.GetEnvironmentVariable("AGE_DIAG_SETTEX") == "1";
|
||||
private ExecFrame _cur = null!;
|
||||
private int _depth;
|
||||
@@ -34,6 +35,8 @@ public sealed class VirtualMachine
|
||||
private readonly object _interactiveLock = new();
|
||||
private readonly object _debugControlLock = new();
|
||||
private readonly List<string> _activeFrameNames = new();
|
||||
private readonly List<ExecFrame> _activeExecutionFrames = new();
|
||||
private ExecFrame? _saveResumeFrame;
|
||||
private ExecFrame? _debugActiveFrame;
|
||||
private long _debugActiveFrameId;
|
||||
private long _debugNextFrameId;
|
||||
@@ -75,6 +78,21 @@ public sealed class VirtualMachine
|
||||
public long Steps { get; private set; }
|
||||
public bool AutoMessageEnabled => _autoMessageEnabled;
|
||||
public bool MessageSkipEnabled => _messageSkipEnabled;
|
||||
/// <summary>
|
||||
/// Zero-based active-frame cutoff selected by opcode 0x1ad, or null when no surviving marker
|
||||
/// exists. A numbered-save serializer consumes this boundary in the full payload slice.
|
||||
/// </summary>
|
||||
public int? SaveResumeFrameDepth
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_debugControlLock)
|
||||
{
|
||||
int index = _saveResumeFrame == null ? -1 : _activeExecutionFrames.IndexOf(_saveResumeFrame);
|
||||
return index >= 0 ? index : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// <summary>True while a script-owned timed mouse/input callback loop (HISTORY/HIDEWIN family) owns input.</summary>
|
||||
public bool IsRawInputCallbackActive
|
||||
{
|
||||
@@ -97,13 +115,15 @@ public sealed class VirtualMachine
|
||||
|
||||
public VirtualMachine(Script s, OpcodeTable t, IHost host, VmOptions? o = null,
|
||||
IScriptProvider? provider = null, ITraceSink? sink = null,
|
||||
AdvTextHistory? textHistory = null, SharedProfile? sharedProfile = null)
|
||||
AdvTextHistory? textHistory = null, SharedProfile? sharedProfile = null,
|
||||
INativeDatStore? nativeDatStore = null)
|
||||
{
|
||||
_s = s; _t = t; _host = host; _o = o ?? new VmOptions(); _provider = provider;
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
_nativeStringEncoding = Encoding.GetEncoding(_o.NativeStringCodePage);
|
||||
_sink = sink ?? NullTraceSink.Instance; TextHistory = textHistory ?? new AdvTextHistory();
|
||||
_sharedProfile = sharedProfile ?? new SharedProfile();
|
||||
_nativeDatStore = nativeDatStore;
|
||||
}
|
||||
|
||||
/// <summary>Queue global writes and return only the identified active frame at its next opcode boundary.
|
||||
@@ -596,6 +616,7 @@ public sealed class VirtualMachine
|
||||
_debugActiveFrame = null;
|
||||
_debugActiveFrameId = 0;
|
||||
_debugFrameReturnRequest = null;
|
||||
_saveResumeFrame = null;
|
||||
}
|
||||
_autoMessageEnabled = false;
|
||||
_autoVoicePending = false;
|
||||
@@ -629,6 +650,7 @@ public sealed class VirtualMachine
|
||||
_debugActiveFrame = frame;
|
||||
_debugActiveFrameId = ++_debugNextFrameId;
|
||||
_activeFrameNames.Add(frame.Script.Name);
|
||||
_activeExecutionFrames.Add(frame);
|
||||
}
|
||||
bool hostContextEntered = false;
|
||||
try
|
||||
@@ -664,7 +686,10 @@ public sealed class VirtualMachine
|
||||
lock (_debugControlLock)
|
||||
{
|
||||
if (ReferenceEquals(_debugFrameReturnRequest?.Frame, frame)) _debugFrameReturnRequest = null;
|
||||
if (ReferenceEquals(_saveResumeFrame, frame)) _saveResumeFrame = null;
|
||||
if (_activeFrameNames.Count > 0) _activeFrameNames.RemoveAt(_activeFrameNames.Count - 1);
|
||||
if (_activeExecutionFrames.Count > 0)
|
||||
_activeExecutionFrames.RemoveAt(_activeExecutionFrames.Count - 1);
|
||||
_debugActiveFrame = previousDebugActiveFrame;
|
||||
_debugActiveFrameId = previousDebugActiveFrameId;
|
||||
}
|
||||
@@ -794,6 +819,110 @@ public sealed class VirtualMachine
|
||||
case "halve-strlen": // 0x1a6: strlen(native encoded bytes) >> 1
|
||||
Write(a[0], NativeStringByteLength(ReadStr(a[1])) >> 1);
|
||||
return pc + 1;
|
||||
case "query-numbered-save-metadata": // 0x1a0
|
||||
{
|
||||
if (_nativeDatStore == null)
|
||||
{
|
||||
Write(a[0], 1);
|
||||
return pc + 1;
|
||||
}
|
||||
try
|
||||
{
|
||||
NativeSaveMetadata? metadata =
|
||||
_nativeDatStore.QueryNumberedMetadata(unchecked((int)Read(a[1])));
|
||||
if (metadata == null)
|
||||
{
|
||||
Write(a[0], 1);
|
||||
return pc + 1;
|
||||
}
|
||||
Write(a[2], metadata.Timestamp.Year);
|
||||
Write(a[3], metadata.Timestamp.Month);
|
||||
Write(a[4], metadata.Timestamp.Day);
|
||||
Write(a[5], metadata.Timestamp.Hour);
|
||||
Write(a[6], metadata.Timestamp.Minute);
|
||||
Write(a[7], metadata.Timestamp.Second);
|
||||
Write(a[8], unchecked((int)metadata.AccumulatedPlaySeconds));
|
||||
Write(a[0], 0);
|
||||
}
|
||||
catch (EndOfStreamException) { Write(a[0], 2); }
|
||||
catch (InvalidDataException) { Write(a[0], 2); }
|
||||
catch (ArgumentOutOfRangeException) { Write(a[0], 2); }
|
||||
catch (IOException) { Write(a[0], 1); }
|
||||
catch (UnauthorizedAccessException) { Write(a[0], 1); }
|
||||
return pc + 1;
|
||||
}
|
||||
case "delete-numbered-save": // 0x1ab
|
||||
try
|
||||
{
|
||||
Write(a[0], _nativeDatStore?.DeleteNumberedPair(unchecked((int)Read(a[1]))) ?? 2);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException) { Write(a[0], 2); }
|
||||
return pc + 1;
|
||||
case "copy-numbered-save": // 0x1ac
|
||||
try
|
||||
{
|
||||
Write(a[0], _nativeDatStore?.CopyNumberedPair(
|
||||
unchecked((int)Read(a[1])), unchecked((int)Read(a[2]))) ?? 2);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException) { Write(a[0], 2); }
|
||||
catch (IOException) { Write(a[0], 2); }
|
||||
catch (UnauthorizedAccessException) { Write(a[0], 2); }
|
||||
return pc + 1;
|
||||
case "mark-save-resume-frame": // 0x1ad
|
||||
lock (_debugControlLock) _saveResumeFrame = _cur;
|
||||
return pc + 1;
|
||||
case "write-numbered-save-thumbnail": // 0x1ae
|
||||
{
|
||||
if (_nativeDatStore == null)
|
||||
{
|
||||
Write(a[0], 1);
|
||||
return pc + 1;
|
||||
}
|
||||
try
|
||||
{
|
||||
var image = _host.CaptureSurfacePixels(unchecked((int)Read(a[2])));
|
||||
if (image == null)
|
||||
{
|
||||
Write(a[0], 2);
|
||||
return pc + 1;
|
||||
}
|
||||
byte[] encoded = NumberedThumbnailCodec.Encode(image);
|
||||
_nativeDatStore.SaveNumberedThumbnail(unchecked((int)Read(a[1])), encoded);
|
||||
Write(a[0], 0);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException) { Write(a[0], 2); }
|
||||
catch (InvalidDataException) { Write(a[0], 2); }
|
||||
catch (OverflowException) { Write(a[0], 2); }
|
||||
catch (IOException) { Write(a[0], 1); }
|
||||
catch (UnauthorizedAccessException) { Write(a[0], 1); }
|
||||
return pc + 1;
|
||||
}
|
||||
case "load-numbered-save-thumbnail": // 0x1af
|
||||
{
|
||||
if (_nativeDatStore == null)
|
||||
{
|
||||
Write(a[0], 1);
|
||||
return pc + 1;
|
||||
}
|
||||
try
|
||||
{
|
||||
byte[]? encoded =
|
||||
_nativeDatStore.LoadNumberedThumbnail(unchecked((int)Read(a[1])));
|
||||
if (encoded == null)
|
||||
{
|
||||
Write(a[0], 1);
|
||||
return pc + 1;
|
||||
}
|
||||
var image = NumberedThumbnailCodec.Decode(encoded);
|
||||
Write(a[0], _host.ReplaceSurfacePixels(unchecked((int)Read(a[2])), image) ? 0 : 2);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException) { Write(a[0], 2); }
|
||||
catch (InvalidDataException) { Write(a[0], 2); }
|
||||
catch (OverflowException) { Write(a[0], 2); }
|
||||
catch (IOException) { Write(a[0], 1); }
|
||||
catch (UnauthorizedAccessException) { Write(a[0], 1); }
|
||||
return pc + 1;
|
||||
}
|
||||
case "store-shared-profile-int": // 0x1a2
|
||||
{
|
||||
if (!TryResolveSharedProfileCell(a[0], isString: false, out int address))
|
||||
|
||||
Reference in New Issue
Block a user