Implement numbered save pair lifecycle
This commit is contained in:
@@ -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.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user