using System.Buffers.Binary; using System.Collections.ObjectModel; using System.Globalization; using System.Text; namespace Age.Engine.Persistence; /// /// 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. /// public sealed class SharedProfilePayload { public IReadOnlyList CatalogCompatibilityValues { get; } public IReadOnlyDictionary IntegerCells { get; } public IReadOnlyDictionary StringCells { get; } public IReadOnlyList ExtendedSelectorCounts { get; } public IReadOnlyList ExtendedValues { get; } public IReadOnlyList ReservedTail { get; } public SharedProfilePayload( IEnumerable? catalogCompatibilityValues = null, IEnumerable>? integerCells = null, IEnumerable>? stringCells = null, IEnumerable? extendedSelectorCounts = null, IEnumerable? extendedValues = null, IEnumerable? reservedTail = null) { uint[] selectors = extendedSelectorCounts?.ToArray() ?? Array.Empty(); 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()); IntegerCells = new ReadOnlyDictionary( integerCells?.ToDictionary() ?? new Dictionary()); StringCells = new ReadOnlyDictionary( stringCells?.ToDictionary() ?? new Dictionary()); ExtendedSelectorCounts = Array.AsReadOnly(selectors); ExtendedValues = Array.AsReadOnly(extendedValues?.ToArray() ?? Array.Empty()); ReservedTail = Array.AsReadOnly(tail); } } /// /// 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. /// 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[] integers = payload.IntegerCells .OrderBy(entry => entry.Key) .ToArray(); writer.Write(checked((uint)integers.Length)); Span 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[] 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 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 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(integerCount); for (int i = 0; i < integerCount; i++) { ReadOnlySpan 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 stringBlob = reader.ReadBytes(stringBlobLength, "string blob"); var strings = new Dictionary(stringCount); int stringPosition = 0; for (int i = 0; i < stringCount; i++) { ReadOnlySpan key = ReadCString(stringBlob, ref stringPosition, "string key"); int address = ReadTypedKey(key, StringTypeTag); ReadOnlySpan 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[] extended = Array.Empty(); 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> entries) { using var stream = new MemoryStream(); Span 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 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 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 ReadCString( ReadOnlySpan 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 value = source.Slice(position, relativeEnd); position = checked(position + relativeEnd + 1); return value; } private static void WriteDwordArray(BinaryWriter writer, IReadOnlyList 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 _source; private int _position; internal PayloadReader(ReadOnlySpan source) { _source = source; _position = 0; } internal bool AtEnd => _position == _source.Length; internal int Remaining => _source.Length - _position; internal uint ReadUInt32(string field) { ReadOnlySpan 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 ReadBytes(int count, string field) { if (count < 0 || count > Remaining) throw new InvalidDataException($"Shared SAVE.DAT {field} is truncated."); ReadOnlySpan result = _source.Slice(_position, count); _position += count; return result; } } } /// /// 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. /// public sealed class SharedProfile { private uint[] _catalogCompatibilityValues = Array.Empty(); private readonly Dictionary _integerCells = new(); private readonly Dictionary _stringCells = new(); private uint[] _extendedSelectorCounts = Array.Empty(); private uint[] _extendedValues = Array.Empty(); private uint[] _reservedTail = new uint[SharedProfilePayloadCodec.ReservedTailDwordCount]; public IReadOnlyDictionary IntegerCells => _integerCells; public IReadOnlyDictionary 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(); _integerCells.Clear(); _stringCells.Clear(); _extendedSelectorCounts = Array.Empty(); _extendedValues = Array.Empty(); _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."); } }