Implement native shared profile persistence
This commit is contained in:
193
engine/Age.Engine.Tests/SharedProfileTests.cs
Normal file
193
engine/Age.Engine.Tests/SharedProfileTests.cs
Normal file
@@ -0,0 +1,193 @@
|
||||
using System.Buffers.Binary;
|
||||
using Age.Engine.Model;
|
||||
using Age.Engine.Persistence;
|
||||
using Age.Engine.Sys4;
|
||||
using Age.Engine.Vm;
|
||||
|
||||
public class SharedProfileTests
|
||||
{
|
||||
private const int Immediate = 0, InlineString = 2, GlobalInt = 3, GlobalString = 5,
|
||||
LocalPointer = 12, LocalStringPointer = 14;
|
||||
private static readonly NativeSystemTime Timestamp =
|
||||
new(2026, 7, 5, 24, 13, 42, 17, 321);
|
||||
|
||||
[Fact]
|
||||
public void SharedPayloadRoundTripsNativeTypedKeysAndOpaqueSections()
|
||||
{
|
||||
var selectors = new uint[SharedProfilePayloadCodec.ExtendedSelectorCount];
|
||||
selectors[1] = 81;
|
||||
var payload = new SharedProfilePayload(
|
||||
catalogCompatibilityValues: [0x11223344, 0xaabbccdd],
|
||||
integerCells: new Dictionary<int, uint> { [0x1234] = 0xffffffff },
|
||||
stringCells: new Dictionary<int, string> { [0x5678] = "姫狩り\0ignored" },
|
||||
extendedSelectorCounts: selectors,
|
||||
extendedValues: [7, 8, 9]);
|
||||
var metadata = new NativeSaveMetadata(
|
||||
NativeSaveMagic.S4SD, 123, "himegari-test", Timestamp, 42, 3, 10);
|
||||
|
||||
byte[] logical = SharedProfilePayloadCodec.Encode(payload, metadata);
|
||||
|
||||
Assert.Equal(2u, BinaryPrimitives.ReadUInt32LittleEndian(logical));
|
||||
Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(logical.AsSpan(12)));
|
||||
Assert.Equal(0x03, logical[16]);
|
||||
Assert.Equal("00001234", System.Text.Encoding.ASCII.GetString(logical, 17, 8));
|
||||
Assert.Equal(0, logical[25]);
|
||||
Assert.Equal(0xffffffffu, BinaryPrimitives.ReadUInt32LittleEndian(logical.AsSpan(28)));
|
||||
|
||||
byte[] file = NativeSaveContainerCodec.Encode(
|
||||
logical, metadata, new NativeSaveEncodingOptions(0x12345678, 3));
|
||||
NativeSaveDocument document = NativeSaveContainerCodec.Decode(file);
|
||||
SharedProfilePayload decoded =
|
||||
SharedProfilePayloadCodec.Decode(document.Payload, document.Metadata);
|
||||
|
||||
Assert.Equal(payload.CatalogCompatibilityValues, decoded.CatalogCompatibilityValues);
|
||||
Assert.Equal(0xffffffffu, decoded.IntegerCells[0x1234]);
|
||||
Assert.Equal("姫狩り", decoded.StringCells[0x5678]);
|
||||
Assert.Equal(81u, decoded.ExtendedSelectorCounts[1]);
|
||||
Assert.Equal(new uint[] { 7, 8, 9 }, decoded.ExtendedValues);
|
||||
Assert.Equal(9, decoded.ReservedTail.Count);
|
||||
Assert.All(decoded.ReservedTail, value => Assert.Equal(0u, value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SharedPayloadRejectsMalformedTypedKeysAndTrailingData()
|
||||
{
|
||||
var metadata = new NativeSaveMetadata(
|
||||
NativeSaveMagic.S4SD, 1, "test", Timestamp, 0, 1, 1);
|
||||
byte[] logical = SharedProfilePayloadCodec.Encode(
|
||||
new SharedProfilePayload(integerCells: new Dictionary<int, uint> { [1] = 2 }),
|
||||
metadata);
|
||||
|
||||
byte[] badType = logical.ToArray();
|
||||
badType[8] = 0x05;
|
||||
Assert.Contains("type tag", Assert.Throws<InvalidDataException>(
|
||||
() => SharedProfilePayloadCodec.Decode(badType, metadata)).Message);
|
||||
|
||||
byte[] trailing = [.. logical, 0, 0, 0, 0];
|
||||
Assert.Contains("trailing", Assert.Throws<InvalidDataException>(
|
||||
() => SharedProfilePayloadCodec.Decode(trailing, metadata)).Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SharedProfilePersistsSelectedCellsThroughNativeDirectoryStore()
|
||||
{
|
||||
string root = Path.Combine(Path.GetTempPath(), "age-shared-profile-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var identity = new NativeSaveIdentity(
|
||||
NativeSaveMagic.S4SD, 123, "himegari-test", 3, 10);
|
||||
var store = new DirectoryNativeDatStore(root, identity);
|
||||
var profile = new SharedProfile();
|
||||
var selectors = new uint[SharedProfilePayloadCodec.ExtendedSelectorCount];
|
||||
selectors[1] = 81;
|
||||
profile.Replace(new SharedProfilePayload(
|
||||
catalogCompatibilityValues: [10, 20, 30],
|
||||
extendedSelectorCounts: selectors,
|
||||
extendedValues: [99]));
|
||||
profile.StoreInteger(0x100, -7);
|
||||
profile.StoreString(0x200, "リリィ");
|
||||
|
||||
profile.Save(store, Timestamp, 456);
|
||||
|
||||
var loaded = new SharedProfile();
|
||||
Assert.True(loaded.Load(store));
|
||||
Assert.Equal(-7, loaded.LoadInteger(0x100));
|
||||
Assert.Equal("リリィ", loaded.LoadString(0x200));
|
||||
Assert.Equal(0, loaded.LoadInteger(0x101));
|
||||
Assert.Equal(string.Empty, loaded.LoadString(0x201));
|
||||
Assert.Equal(new uint[] { 10, 20, 30 },
|
||||
loaded.Snapshot().CatalogCompatibilityValues);
|
||||
Assert.Equal(81u, loaded.Snapshot().ExtendedSelectorCounts[1]);
|
||||
Assert.Equal(456u, store.LoadShared()!.Metadata.AccumulatedPlaySeconds);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectedCellOpcodesUseResolvedGlobalAddressesAndNativeMissDefaults()
|
||||
{
|
||||
OpcodeTable table = OpcodeTableJson.Load(Paths.OpcodesJson);
|
||||
int move = table.ByLabel("mov")!.Value;
|
||||
int lookup = table.ByLabel("lookup-array")!.Value;
|
||||
Script script = ScriptAssembler.Assemble(table, "SHARED_PROFILE_OPS", new List<(int, Operand[])>
|
||||
{
|
||||
(move, [new Operand(GlobalInt, 0x700), new Operand(Immediate, 123)]),
|
||||
(0x1a2, [new Operand(GlobalInt, 0x700)]),
|
||||
(move, [new Operand(GlobalInt, 0x700), new Operand(Immediate, 0)]),
|
||||
(0x1a3, [new Operand(GlobalInt, 0x700)]),
|
||||
(0x1a3, [new Operand(GlobalInt, 0x701)]),
|
||||
|
||||
(lookup, [
|
||||
new Operand(LocalPointer, 0), new Operand(GlobalInt, 0x710), new Operand(Immediate, 2),
|
||||
]),
|
||||
(move, [new Operand(LocalPointer, 0), new Operand(Immediate, 77)]),
|
||||
(0x1a2, [new Operand(LocalPointer, 0)]),
|
||||
(move, [new Operand(LocalPointer, 0), new Operand(Immediate, 0)]),
|
||||
(0x1a3, [new Operand(LocalPointer, 0)]),
|
||||
|
||||
(move, [new Operand(GlobalString, 0x800), new Operand(InlineString, 0)]),
|
||||
(0x1a9, [new Operand(GlobalString, 0x800)]),
|
||||
(move, [new Operand(GlobalString, 0x800), new Operand(InlineString, 1)]),
|
||||
(0x1aa, [new Operand(GlobalString, 0x800)]),
|
||||
(0x1aa, [new Operand(GlobalString, 0x801)]),
|
||||
|
||||
(0x63, [new Operand(LocalStringPointer, 0), new Operand(GlobalString, 0x810)]),
|
||||
(move, [new Operand(LocalStringPointer, 0), new Operand(InlineString, 2)]),
|
||||
(0x1a9, [new Operand(LocalStringPointer, 0)]),
|
||||
(move, [new Operand(LocalStringPointer, 0), new Operand(InlineString, 1)]),
|
||||
(0x1aa, [new Operand(LocalStringPointer, 0)]),
|
||||
(0x2, []),
|
||||
}, ["リリィ", "changed", "使い魔"]);
|
||||
var profile = new SharedProfile();
|
||||
var vm = new VirtualMachine(
|
||||
script, table, new RecordingHost(), sharedProfile: profile);
|
||||
vm.Globals[0x701] = 999;
|
||||
vm.GlobalStrings[0x801] = "not empty";
|
||||
|
||||
vm.Run();
|
||||
|
||||
Assert.Equal("exit", vm.HaltReason);
|
||||
Assert.Equal(123, vm.Globals[0x700]);
|
||||
Assert.Equal(0, vm.Globals[0x701]);
|
||||
Assert.Equal(77, vm.Globals[0x712]);
|
||||
Assert.Equal("リリィ", vm.GlobalStrings[0x800]);
|
||||
Assert.Equal(string.Empty, vm.GlobalStrings[0x801]);
|
||||
Assert.Equal("使い魔", vm.GlobalStrings[0x810]);
|
||||
Assert.Equal(123, profile.LoadInteger(0x700));
|
||||
Assert.Equal(77, profile.LoadInteger(0x712));
|
||||
Assert.Equal("リリィ", profile.LoadString(0x800));
|
||||
Assert.Equal("使い魔", profile.LoadString(0x810));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GameSessionCarriesSharedProfileAcrossFreshSceneVmsButJsonDoesNotAliasIt()
|
||||
{
|
||||
OpcodeTable table = OpcodeTableJson.Load(Paths.OpcodesJson);
|
||||
int move = table.ByLabel("mov")!.Value;
|
||||
Script store = ScriptAssembler.Assemble(table, "PROFILE_STORE", new List<(int, Operand[])>
|
||||
{
|
||||
(move, [new Operand(GlobalInt, 0x900), new Operand(Immediate, 42)]),
|
||||
(0x1a2, [new Operand(GlobalInt, 0x900)]),
|
||||
(0x2, []),
|
||||
}, []);
|
||||
Script load = ScriptAssembler.Assemble(table, "PROFILE_LOAD", new List<(int, Operand[])>
|
||||
{
|
||||
(move, [new Operand(GlobalInt, 0x900), new Operand(Immediate, 0)]),
|
||||
(0x1a3, [new Operand(GlobalInt, 0x900)]),
|
||||
(0x2, []),
|
||||
}, []);
|
||||
var session = new GameSession();
|
||||
|
||||
session.RunScene(store, table, new RecordingHost());
|
||||
session.RunScene(load, table, new RecordingHost());
|
||||
|
||||
Assert.Equal(42, session.Globals[0x900]);
|
||||
Assert.Equal(42, session.SharedProfile.LoadInteger(0x900));
|
||||
GameSession jsonClone = GameSession.FromJson(session.ToJson());
|
||||
Assert.Equal(42, jsonClone.Globals[0x900]);
|
||||
Assert.Equal(0, jsonClone.SharedProfile.LoadInteger(0x900));
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ public sealed record NativeSaveIdentity(
|
||||
|
||||
public interface INativeDatStore
|
||||
{
|
||||
NativeSaveIdentity Identity { get; }
|
||||
NativeSaveDocument? LoadShared();
|
||||
void SaveShared(ReadOnlySpan<byte> payload, NativeSystemTime timestamp, uint accumulatedPlaySeconds);
|
||||
NativeSaveDocument? LoadNumbered(int slot);
|
||||
@@ -55,6 +56,7 @@ public sealed class DirectoryNativeDatStore : INativeDatStore
|
||||
|
||||
private readonly string _root;
|
||||
private readonly NativeSaveIdentity _identity;
|
||||
public NativeSaveIdentity Identity => _identity;
|
||||
|
||||
public DirectoryNativeDatStore(string root, NativeSaveIdentity identity)
|
||||
{
|
||||
|
||||
443
engine/Age.Engine/Persistence/SharedProfile.cs
Normal file
443
engine/Age.Engine/Persistence/SharedProfile.cs
Normal file
@@ -0,0 +1,443 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace Age.Engine.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Typed logical contents of AGE's shared SAVE.DAT payload. The catalog and extended arrays are
|
||||
/// intentionally opaque: the selected-cell service owns only the integer and string maps, while
|
||||
/// native import/export must preserve the other engine-owned sections losslessly.
|
||||
/// </summary>
|
||||
public sealed class SharedProfilePayload
|
||||
{
|
||||
public IReadOnlyList<uint> CatalogCompatibilityValues { get; }
|
||||
public IReadOnlyDictionary<int, uint> IntegerCells { get; }
|
||||
public IReadOnlyDictionary<int, string> StringCells { get; }
|
||||
public IReadOnlyList<uint> ExtendedSelectorCounts { get; }
|
||||
public IReadOnlyList<uint> ExtendedValues { get; }
|
||||
public IReadOnlyList<uint> ReservedTail { get; }
|
||||
|
||||
public SharedProfilePayload(
|
||||
IEnumerable<uint>? catalogCompatibilityValues = null,
|
||||
IEnumerable<KeyValuePair<int, uint>>? integerCells = null,
|
||||
IEnumerable<KeyValuePair<int, string>>? stringCells = null,
|
||||
IEnumerable<uint>? extendedSelectorCounts = null,
|
||||
IEnumerable<uint>? extendedValues = null,
|
||||
IEnumerable<uint>? reservedTail = null)
|
||||
{
|
||||
uint[] selectors = extendedSelectorCounts?.ToArray() ?? Array.Empty<uint>();
|
||||
if (selectors.Length is not 0 and not SharedProfilePayloadCodec.ExtendedSelectorCount)
|
||||
throw new ArgumentException(
|
||||
$"Extended selector table must contain exactly {SharedProfilePayloadCodec.ExtendedSelectorCount} values.",
|
||||
nameof(extendedSelectorCounts));
|
||||
|
||||
uint[] tail = reservedTail?.ToArray() ?? new uint[SharedProfilePayloadCodec.ReservedTailDwordCount];
|
||||
if (tail.Length != SharedProfilePayloadCodec.ReservedTailDwordCount)
|
||||
throw new ArgumentException(
|
||||
$"Reserved tail must contain exactly {SharedProfilePayloadCodec.ReservedTailDwordCount} DWORDs.",
|
||||
nameof(reservedTail));
|
||||
|
||||
CatalogCompatibilityValues = Array.AsReadOnly(
|
||||
catalogCompatibilityValues?.ToArray() ?? Array.Empty<uint>());
|
||||
IntegerCells = new ReadOnlyDictionary<int, uint>(
|
||||
integerCells?.ToDictionary() ?? new Dictionary<int, uint>());
|
||||
StringCells = new ReadOnlyDictionary<int, string>(
|
||||
stringCells?.ToDictionary() ?? new Dictionary<int, string>());
|
||||
ExtendedSelectorCounts = Array.AsReadOnly(selectors);
|
||||
ExtendedValues = Array.AsReadOnly(extendedValues?.ToArray() ?? Array.Empty<uint>());
|
||||
ReservedTail = Array.AsReadOnly(tail);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Codec for the logical payload inside shared SAVE.DAT's common S3SD/S4SD container.
|
||||
/// Keys use a raw one-byte AGE type tag followed by eight lowercase ASCII hex digits.
|
||||
/// </summary>
|
||||
public static class SharedProfilePayloadCodec
|
||||
{
|
||||
public const int ExtendedSelectorCount = 0x100;
|
||||
// Native writer emits one explicit zero terminator and its historical allocation formula
|
||||
// leaves eight additional DWORDs at the end of the logical payload.
|
||||
public const int ReservedTailDwordCount = 9;
|
||||
|
||||
private const byte IntegerTypeTag = 0x03;
|
||||
private const byte StringTypeTag = 0x05;
|
||||
private const int FixedIntegerEntrySize = 0x10;
|
||||
private const int FixedKeyFieldSize = 0x0c;
|
||||
private static readonly Encoding ShiftJis = CreateShiftJis();
|
||||
|
||||
public static byte[] Encode(SharedProfilePayload payload, NativeSaveMetadata metadata)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(payload);
|
||||
ArgumentNullException.ThrowIfNull(metadata);
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
using var writer = new BinaryWriter(stream, Encoding.ASCII, leaveOpen: true);
|
||||
|
||||
WriteDwordArray(writer, payload.CatalogCompatibilityValues);
|
||||
|
||||
KeyValuePair<int, uint>[] integers = payload.IntegerCells
|
||||
.OrderBy(entry => entry.Key)
|
||||
.ToArray();
|
||||
writer.Write(checked((uint)integers.Length));
|
||||
Span<byte> entryBuffer = stackalloc byte[FixedIntegerEntrySize];
|
||||
foreach (var (address, value) in integers)
|
||||
{
|
||||
entryBuffer.Clear();
|
||||
WriteTypedKey(entryBuffer[..FixedKeyFieldSize], IntegerTypeTag, address);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(entryBuffer[FixedKeyFieldSize..], value);
|
||||
writer.Write(entryBuffer);
|
||||
}
|
||||
|
||||
KeyValuePair<int, string>[] strings = payload.StringCells
|
||||
.OrderBy(entry => entry.Key)
|
||||
.ToArray();
|
||||
writer.Write(checked((uint)strings.Length));
|
||||
byte[] stringBlob = BuildStringBlob(strings);
|
||||
writer.Write(checked((uint)(stringBlob.Length / 4)));
|
||||
writer.Write(stringBlob);
|
||||
|
||||
if (HasExtendedSections(metadata))
|
||||
{
|
||||
IReadOnlyList<uint> selectors = payload.ExtendedSelectorCounts.Count == 0
|
||||
? new uint[ExtendedSelectorCount]
|
||||
: payload.ExtendedSelectorCounts;
|
||||
foreach (uint value in selectors) writer.Write(value);
|
||||
WriteDwordArray(writer, payload.ExtendedValues);
|
||||
}
|
||||
else if (payload.ExtendedSelectorCounts.Count != 0 || payload.ExtendedValues.Count != 0)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Shared profile version {metadata.SaveVersion1}.{metadata.SaveVersion2} " +
|
||||
"cannot encode version-3.10 extended sections.");
|
||||
}
|
||||
|
||||
foreach (uint value in payload.ReservedTail) writer.Write(value);
|
||||
writer.Flush();
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
public static SharedProfilePayload Decode(ReadOnlySpan<byte> source, NativeSaveMetadata metadata)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(metadata);
|
||||
var reader = new PayloadReader(source);
|
||||
|
||||
uint[] catalog = reader.ReadDwordArray("catalog compatibility");
|
||||
|
||||
int integerCount = reader.ReadCount("integer entry", FixedIntegerEntrySize);
|
||||
var integers = new Dictionary<int, uint>(integerCount);
|
||||
for (int i = 0; i < integerCount; i++)
|
||||
{
|
||||
ReadOnlySpan<byte> entry = reader.ReadBytes(FixedIntegerEntrySize, "integer entry");
|
||||
int address = ReadTypedKey(entry[..FixedKeyFieldSize], IntegerTypeTag);
|
||||
uint value = BinaryPrimitives.ReadUInt32LittleEndian(entry[FixedKeyFieldSize..]);
|
||||
if (!integers.TryAdd(address, value))
|
||||
throw new InvalidDataException($"Shared SAVE.DAT repeats integer cell 0x{address:x8}.");
|
||||
}
|
||||
|
||||
int stringCount = reader.ReadCount("string entry", minimumBytesPerEntry: 2);
|
||||
uint stringBlobDwords = reader.ReadUInt32("string blob DWORD count");
|
||||
if (stringBlobDwords > int.MaxValue / 4)
|
||||
throw new InvalidDataException("Shared SAVE.DAT string blob is too large.");
|
||||
int stringBlobLength = checked((int)stringBlobDwords * 4);
|
||||
ReadOnlySpan<byte> stringBlob = reader.ReadBytes(stringBlobLength, "string blob");
|
||||
var strings = new Dictionary<int, string>(stringCount);
|
||||
int stringPosition = 0;
|
||||
for (int i = 0; i < stringCount; i++)
|
||||
{
|
||||
ReadOnlySpan<byte> key = ReadCString(stringBlob, ref stringPosition, "string key");
|
||||
int address = ReadTypedKey(key, StringTypeTag);
|
||||
ReadOnlySpan<byte> value = ReadCString(stringBlob, ref stringPosition, "string value");
|
||||
string decoded;
|
||||
try
|
||||
{
|
||||
decoded = ShiftJis.GetString(value);
|
||||
}
|
||||
catch (DecoderFallbackException error)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Shared SAVE.DAT string cell 0x{address:x8} is not valid CP932.", error);
|
||||
}
|
||||
if (!strings.TryAdd(address, decoded))
|
||||
throw new InvalidDataException($"Shared SAVE.DAT repeats string cell 0x{address:x8}.");
|
||||
}
|
||||
if (stringBlob[stringPosition..].IndexOfAnyExcept((byte)0) >= 0)
|
||||
throw new InvalidDataException("Shared SAVE.DAT string blob has nonzero padding.");
|
||||
|
||||
uint[] selectors = Array.Empty<uint>();
|
||||
uint[] extended = Array.Empty<uint>();
|
||||
if (HasExtendedSections(metadata))
|
||||
{
|
||||
selectors = reader.ReadDwords(ExtendedSelectorCount, "extended selector table");
|
||||
extended = reader.ReadDwordArray("extended");
|
||||
}
|
||||
|
||||
uint[] tail = reader.ReadDwords(ReservedTailDwordCount, "reserved tail");
|
||||
if (!reader.AtEnd)
|
||||
throw new InvalidDataException(
|
||||
$"Shared SAVE.DAT has {reader.Remaining} unexpected trailing byte(s).");
|
||||
|
||||
return new SharedProfilePayload(catalog, integers, strings, selectors, extended, tail);
|
||||
}
|
||||
|
||||
private static byte[] BuildStringBlob(IReadOnlyList<KeyValuePair<int, string>> entries)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
Span<byte> key = stackalloc byte[10];
|
||||
foreach (var (address, rawValue) in entries)
|
||||
{
|
||||
WriteTypedKey(key, StringTypeTag, address);
|
||||
stream.Write(key);
|
||||
|
||||
string value = TruncateAtNul(rawValue ?? string.Empty);
|
||||
byte[] encodedValue = ShiftJis.GetBytes(value);
|
||||
stream.Write(encodedValue);
|
||||
stream.WriteByte(0);
|
||||
}
|
||||
|
||||
// Native stores a DWORD count and rounds up with `(byte_length / 4) + 1`, which deliberately
|
||||
// adds a whole zero DWORD when the strings already end on a DWORD boundary.
|
||||
int paddedLength = checked((int)((stream.Length / 4 + 1) * 4));
|
||||
stream.SetLength(paddedLength);
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteTypedKey(Span<byte> destination, byte typeTag, int address)
|
||||
{
|
||||
if (address < 0)
|
||||
throw new InvalidDataException("Shared profile cell addresses cannot be negative.");
|
||||
if (destination.Length < 10)
|
||||
throw new ArgumentException("Typed-key destination is too short.", nameof(destination));
|
||||
|
||||
destination[0] = typeTag;
|
||||
bool written = address.TryFormat(
|
||||
destination[1..9], out int charsWritten, "x8", CultureInfo.InvariantCulture);
|
||||
if (!written || charsWritten != 8)
|
||||
throw new InvalidDataException($"Shared profile cell address 0x{address:x} does not fit its key.");
|
||||
destination[9] = 0;
|
||||
}
|
||||
|
||||
private static int ReadTypedKey(ReadOnlySpan<byte> source, byte expectedTypeTag)
|
||||
{
|
||||
bool hasTerminator = source.Length >= 10;
|
||||
if (source.Length < 9 || source[0] != expectedTypeTag
|
||||
|| hasTerminator && source[9] != 0)
|
||||
throw new InvalidDataException(
|
||||
$"Shared SAVE.DAT key does not have type tag 0x{expectedTypeTag:x2} and eight hex digits.");
|
||||
uint address = 0;
|
||||
for (int i = 1; i <= 8; i++)
|
||||
{
|
||||
int digit = source[i] switch
|
||||
{
|
||||
>= (byte)'0' and <= (byte)'9' => source[i] - '0',
|
||||
>= (byte)'a' and <= (byte)'f' => source[i] - 'a' + 10,
|
||||
>= (byte)'A' and <= (byte)'F' => source[i] - 'A' + 10,
|
||||
_ => -1,
|
||||
};
|
||||
if (digit < 0)
|
||||
throw new InvalidDataException("Shared SAVE.DAT typed key contains a non-hex digit.");
|
||||
address = address * 16 + (uint)digit;
|
||||
}
|
||||
if (address > int.MaxValue)
|
||||
throw new InvalidDataException("Shared SAVE.DAT cell address exceeds the VM global-bank range.");
|
||||
return (int)address;
|
||||
}
|
||||
|
||||
private static ReadOnlySpan<byte> ReadCString(
|
||||
ReadOnlySpan<byte> source, ref int position, string field)
|
||||
{
|
||||
if ((uint)position > (uint)source.Length)
|
||||
throw new InvalidDataException($"Shared SAVE.DAT {field} starts outside its string blob.");
|
||||
int relativeEnd = source[position..].IndexOf((byte)0);
|
||||
if (relativeEnd < 0)
|
||||
throw new InvalidDataException($"Shared SAVE.DAT {field} is not NUL-terminated.");
|
||||
ReadOnlySpan<byte> value = source.Slice(position, relativeEnd);
|
||||
position = checked(position + relativeEnd + 1);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static void WriteDwordArray(BinaryWriter writer, IReadOnlyList<uint> values)
|
||||
{
|
||||
writer.Write(checked((uint)values.Count));
|
||||
foreach (uint value in values) writer.Write(value);
|
||||
}
|
||||
|
||||
private static bool HasExtendedSections(NativeSaveMetadata metadata)
|
||||
=> metadata.SaveVersion1 > 3
|
||||
|| metadata.SaveVersion1 == 3 && metadata.SaveVersion2 >= 10;
|
||||
|
||||
private static string TruncateAtNul(string value)
|
||||
{
|
||||
int nul = value.IndexOf('\0');
|
||||
return nul < 0 ? value : value[..nul];
|
||||
}
|
||||
|
||||
private static Encoding CreateShiftJis()
|
||||
{
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
return Encoding.GetEncoding(
|
||||
932, EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback);
|
||||
}
|
||||
|
||||
private ref struct PayloadReader
|
||||
{
|
||||
private readonly ReadOnlySpan<byte> _source;
|
||||
private int _position;
|
||||
|
||||
internal PayloadReader(ReadOnlySpan<byte> source)
|
||||
{
|
||||
_source = source;
|
||||
_position = 0;
|
||||
}
|
||||
|
||||
internal bool AtEnd => _position == _source.Length;
|
||||
internal int Remaining => _source.Length - _position;
|
||||
|
||||
internal uint ReadUInt32(string field)
|
||||
{
|
||||
ReadOnlySpan<byte> bytes = ReadBytes(4, field);
|
||||
return BinaryPrimitives.ReadUInt32LittleEndian(bytes);
|
||||
}
|
||||
|
||||
internal int ReadCount(string field, int minimumBytesPerEntry)
|
||||
{
|
||||
uint count = ReadUInt32(field + " count");
|
||||
if (count > int.MaxValue
|
||||
|| minimumBytesPerEntry > 0 && count > (uint)(Remaining / minimumBytesPerEntry))
|
||||
throw new InvalidDataException($"Shared SAVE.DAT {field} count is too large.");
|
||||
return (int)count;
|
||||
}
|
||||
|
||||
internal uint[] ReadDwordArray(string field)
|
||||
{
|
||||
int count = ReadCount(field, 4);
|
||||
return ReadDwords(count, field);
|
||||
}
|
||||
|
||||
internal uint[] ReadDwords(int count, string field)
|
||||
{
|
||||
if (count < 0 || count > Remaining / 4)
|
||||
throw new InvalidDataException($"Shared SAVE.DAT {field} is truncated.");
|
||||
var values = new uint[count];
|
||||
for (int i = 0; i < count; i++) values[i] = ReadUInt32(field);
|
||||
return values;
|
||||
}
|
||||
|
||||
internal ReadOnlySpan<byte> ReadBytes(int count, string field)
|
||||
{
|
||||
if (count < 0 || count > Remaining)
|
||||
throw new InvalidDataException($"Shared SAVE.DAT {field} is truncated.");
|
||||
ReadOnlySpan<byte> result = _source.Slice(_position, count);
|
||||
_position += count;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Profile-lifetime selected cells plus the opaque native sections required to round-trip SAVE.DAT.
|
||||
/// VM opcodes mutate this object; explicit Load/Save calls own filesystem lifecycle.
|
||||
/// </summary>
|
||||
public sealed class SharedProfile
|
||||
{
|
||||
private uint[] _catalogCompatibilityValues = Array.Empty<uint>();
|
||||
private readonly Dictionary<int, uint> _integerCells = new();
|
||||
private readonly Dictionary<int, string> _stringCells = new();
|
||||
private uint[] _extendedSelectorCounts = Array.Empty<uint>();
|
||||
private uint[] _extendedValues = Array.Empty<uint>();
|
||||
private uint[] _reservedTail = new uint[SharedProfilePayloadCodec.ReservedTailDwordCount];
|
||||
|
||||
public IReadOnlyDictionary<int, uint> IntegerCells => _integerCells;
|
||||
public IReadOnlyDictionary<int, string> StringCells => _stringCells;
|
||||
|
||||
public void StoreInteger(int address, long value)
|
||||
{
|
||||
ValidateAddress(address);
|
||||
_integerCells[address] = unchecked((uint)value);
|
||||
}
|
||||
|
||||
public int LoadInteger(int address)
|
||||
{
|
||||
ValidateAddress(address);
|
||||
return _integerCells.TryGetValue(address, out uint value) ? unchecked((int)value) : 0;
|
||||
}
|
||||
|
||||
public void StoreString(int address, string value)
|
||||
{
|
||||
ValidateAddress(address);
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
int nul = value.IndexOf('\0');
|
||||
_stringCells[address] = nul < 0 ? value : value[..nul];
|
||||
}
|
||||
|
||||
public string LoadString(int address)
|
||||
{
|
||||
ValidateAddress(address);
|
||||
return _stringCells.TryGetValue(address, out string? value) ? value : string.Empty;
|
||||
}
|
||||
|
||||
public bool Load(INativeDatStore store)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(store);
|
||||
NativeSaveDocument? document = store.LoadShared();
|
||||
if (document is null)
|
||||
{
|
||||
Clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
Replace(SharedProfilePayloadCodec.Decode(document.Payload, document.Metadata));
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Save(
|
||||
INativeDatStore store,
|
||||
NativeSystemTime timestamp,
|
||||
uint accumulatedPlaySeconds)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(store);
|
||||
NativeSaveMetadata metadata = store.Identity.CreateMetadata(timestamp, accumulatedPlaySeconds);
|
||||
byte[] payload = SharedProfilePayloadCodec.Encode(Snapshot(), metadata);
|
||||
store.SaveShared(payload, timestamp, accumulatedPlaySeconds);
|
||||
}
|
||||
|
||||
public SharedProfilePayload Snapshot()
|
||||
=> new(
|
||||
_catalogCompatibilityValues,
|
||||
_integerCells,
|
||||
_stringCells,
|
||||
_extendedSelectorCounts,
|
||||
_extendedValues,
|
||||
_reservedTail);
|
||||
|
||||
public void Replace(SharedProfilePayload payload)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(payload);
|
||||
_catalogCompatibilityValues = payload.CatalogCompatibilityValues.ToArray();
|
||||
_integerCells.Clear();
|
||||
foreach (var entry in payload.IntegerCells) _integerCells.Add(entry.Key, entry.Value);
|
||||
_stringCells.Clear();
|
||||
foreach (var entry in payload.StringCells) _stringCells.Add(entry.Key, entry.Value);
|
||||
_extendedSelectorCounts = payload.ExtendedSelectorCounts.ToArray();
|
||||
_extendedValues = payload.ExtendedValues.ToArray();
|
||||
_reservedTail = payload.ReservedTail.ToArray();
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_catalogCompatibilityValues = Array.Empty<uint>();
|
||||
_integerCells.Clear();
|
||||
_stringCells.Clear();
|
||||
_extendedSelectorCounts = Array.Empty<uint>();
|
||||
_extendedValues = Array.Empty<uint>();
|
||||
_reservedTail = new uint[SharedProfilePayloadCodec.ReservedTailDwordCount];
|
||||
}
|
||||
|
||||
private static void ValidateAddress(int address)
|
||||
{
|
||||
if (address < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(address), "Shared profile cell address cannot be negative.");
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
||||
using Age.Engine.Diagnostics;
|
||||
using Age.Engine.Hosting;
|
||||
using Age.Engine.Model;
|
||||
using Age.Engine.Persistence;
|
||||
|
||||
namespace Age.Engine.Vm;
|
||||
|
||||
@@ -20,9 +21,14 @@ public sealed class GameSession
|
||||
{
|
||||
public Dictionary<int, long> Globals { get; } = new();
|
||||
public Dictionary<int, string> GlobalStrings { get; } = new();
|
||||
/// <summary>AGE's selected profile-wide cells and native shared SAVE.DAT lifecycle.</summary>
|
||||
public SharedProfile SharedProfile { 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 void Seed(int addr, long value) => Globals[addr] = value;
|
||||
public void SeedString(int addr, string value) => GlobalStrings[addr] = value;
|
||||
|
||||
@@ -31,7 +37,8 @@ public sealed class GameSession
|
||||
VmOptions? options = null, IScriptProvider? provider = null,
|
||||
ITraceSink? sink = null)
|
||||
{
|
||||
var vm = new VirtualMachine(script, table, host, options, provider, sink, TextHistory);
|
||||
var vm = new VirtualMachine(
|
||||
script, table, host, options, provider, sink, TextHistory, SharedProfile);
|
||||
foreach (var kv in Globals) vm.Globals[kv.Key] = kv.Value;
|
||||
foreach (var kv in GlobalStrings) vm.GlobalStrings[kv.Key] = kv.Value;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Age.Engine.Diagnostics;
|
||||
using Age.Engine.Hosting;
|
||||
using Age.Engine.Model;
|
||||
using Age.Engine.Persistence;
|
||||
using System.Text;
|
||||
namespace Age.Engine.Vm;
|
||||
|
||||
@@ -25,6 +26,7 @@ public sealed class VirtualMachine
|
||||
private readonly VmOptions _o;
|
||||
private readonly Encoding _nativeStringEncoding;
|
||||
private readonly IScriptProvider? _provider;
|
||||
private readonly SharedProfile _sharedProfile;
|
||||
private static readonly bool _diagSetTexture = System.Environment.GetEnvironmentVariable("AGE_DIAG_SETTEX") == "1";
|
||||
private ExecFrame _cur = null!;
|
||||
private int _depth;
|
||||
@@ -94,12 +96,13 @@ public sealed class VirtualMachine
|
||||
|
||||
public VirtualMachine(Script s, OpcodeTable t, IHost host, VmOptions? o = null,
|
||||
IScriptProvider? provider = null, ITraceSink? sink = null,
|
||||
AdvTextHistory? textHistory = null)
|
||||
AdvTextHistory? textHistory = null, SharedProfile? sharedProfile = 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();
|
||||
}
|
||||
|
||||
/// <summary>Queue global writes and return only the identified active frame at its next opcode boundary.
|
||||
@@ -429,6 +432,21 @@ public sealed class VirtualMachine
|
||||
_ => VmAddress.Global((int)op.Value),
|
||||
};
|
||||
|
||||
private bool TryResolveSharedProfileCell(Operand operand, bool isString, out int address)
|
||||
{
|
||||
bool acceptedType = isString
|
||||
? operand.Type is T_GSTR or T_GSTRPTR or T_LSTRPTR
|
||||
: operand.Type is T_GINT or T_GPTR or T_LPTR;
|
||||
VmAddress resolved = acceptedType ? BaseAddr(operand) : default;
|
||||
if (!acceptedType || resolved.Space != VmAddressSpace.Global || resolved.Address < 0)
|
||||
{
|
||||
address = 0;
|
||||
return false;
|
||||
}
|
||||
address = resolved.Address;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryStoreAddress(Operand destination, VmAddress address)
|
||||
{
|
||||
switch (destination.Type)
|
||||
@@ -756,6 +774,46 @@ 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 "store-shared-profile-int": // 0x1a2
|
||||
{
|
||||
if (!TryResolveSharedProfileCell(a[0], isString: false, out int address))
|
||||
{
|
||||
HaltReason ??= $"shared-profile-int-lvalue-type:{a[0].Type}";
|
||||
return HALT;
|
||||
}
|
||||
_sharedProfile.StoreInteger(address, Read(a[0]));
|
||||
return pc + 1;
|
||||
}
|
||||
case "load-shared-profile-int": // 0x1a3
|
||||
{
|
||||
if (!TryResolveSharedProfileCell(a[0], isString: false, out int address))
|
||||
{
|
||||
HaltReason ??= $"shared-profile-int-lvalue-type:{a[0].Type}";
|
||||
return HALT;
|
||||
}
|
||||
Write(a[0], _sharedProfile.LoadInteger(address));
|
||||
return pc + 1;
|
||||
}
|
||||
case "store-shared-profile-string": // 0x1a9
|
||||
{
|
||||
if (!TryResolveSharedProfileCell(a[0], isString: true, out int address))
|
||||
{
|
||||
HaltReason ??= $"shared-profile-string-lvalue-type:{a[0].Type}";
|
||||
return HALT;
|
||||
}
|
||||
_sharedProfile.StoreString(address, ReadStr(a[0]));
|
||||
return pc + 1;
|
||||
}
|
||||
case "load-shared-profile-string": // 0x1aa
|
||||
{
|
||||
if (!TryResolveSharedProfileCell(a[0], isString: true, out int address))
|
||||
{
|
||||
HaltReason ??= $"shared-profile-string-lvalue-type:{a[0].Type}";
|
||||
return HALT;
|
||||
}
|
||||
WriteStr(a[0], _sharedProfile.LoadString(address));
|
||||
return pc + 1;
|
||||
}
|
||||
case "strlen": // 0x2c5: raw strlen(native encoded bytes)
|
||||
Write(a[0], NativeStringByteLength(ReadStr(a[1])));
|
||||
return pc + 1;
|
||||
|
||||
Reference in New Issue
Block a user