Implement native shared profile persistence

This commit is contained in:
gamer147
2026-07-24 14:52:05 -04:00
parent 4ebe861b79
commit f17c89ec9a
12 changed files with 818 additions and 44 deletions

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

View File

@@ -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)
{

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

View File

@@ -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;

View File

@@ -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;