Implement native RT.DAT read history

This commit is contained in:
gamer147
2026-07-24 15:11:30 -04:00
parent 4d8812d5d3
commit 70643ceb2b
19 changed files with 646 additions and 28 deletions

View File

@@ -0,0 +1,189 @@
using System.Buffers.Binary;
using System.Text;
using Age.Engine.Model;
using Age.Engine.Persistence;
using Age.Engine.Sys4;
using Age.Engine.Vm;
public class ReadTextDatabaseTests
{
private static readonly NativeSaveIdentity Identity =
new(NativeSaveMagic.S4SD, 0x4a343234, "姫狩りダンジョンマイスター", 3, 10);
private static readonly NativeSystemTime Timestamp =
new(2026, 7, 5, 24, 13, 42, 17, 321);
[Fact]
public void S3rtCodecWritesNativeHeaderRecordTableAndFlagArrays()
{
var snapshot = new ReadTextDatabaseSnapshot(
[
new ReadTextScriptRecord(0x22, new uint[] { 1, 0, 1 }),
new ReadTextScriptRecord(0x01000005, new uint[] { 0, 7 }),
]);
byte[] encoded = ReadTextDatabaseCodec.Encode(snapshot, Identity);
Assert.Equal(ReadTextDatabaseCodec.HeaderSize + 2 * 12 + 5 * 4, encoded.Length);
Assert.Equal("S3RT", Encoding.ASCII.GetString(encoded, 0, 4));
Assert.Equal(Identity.CompatibilityId,
BinaryPrimitives.ReadUInt32LittleEndian(encoded.AsSpan(4)));
Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(encoded.AsSpan(0x108)));
Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(encoded.AsSpan(0x10c)));
Assert.Equal(2u, BinaryPrimitives.ReadUInt32LittleEndian(encoded.AsSpan(0x110)));
Assert.Equal(0x22u, BinaryPrimitives.ReadUInt32LittleEndian(encoded.AsSpan(0x114)));
Assert.Equal(3u, BinaryPrimitives.ReadUInt32LittleEndian(encoded.AsSpan(0x118)));
Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(encoded.AsSpan(0x11c)));
// Native AGE serializes its live heap pointer in word three. The loader ignores that value,
// allocates a new array, and overwrites it, so import must tolerate a nonzero original pointer.
BinaryPrimitives.WriteUInt32LittleEndian(encoded.AsSpan(0x11c), 0x06dbdb78);
ReadTextDatabaseSnapshot decoded = ReadTextDatabaseCodec.Decode(encoded, Identity);
Assert.Equal(new uint[] { 1, 0, 1 }, decoded.Records[0x22]);
Assert.Equal(new uint[] { 0, 7 }, decoded.Records[0x01000005]);
}
[Fact]
public void S3rtCodecRejectsIdentityMismatchTruncationAndTrailingData()
{
byte[] encoded = ReadTextDatabaseCodec.Encode(
new ReadTextDatabaseSnapshot(
[new ReadTextScriptRecord(7, new uint[] { 1, 0 })]),
Identity);
var wrongIdentity = Identity with { CompatibilityId = 1 };
Assert.Contains("compatibility", Assert.Throws<InvalidDataException>(
() => ReadTextDatabaseCodec.Decode(encoded, wrongIdentity)).Message);
Assert.Contains("truncated", Assert.Throws<InvalidDataException>(
() => ReadTextDatabaseCodec.Decode(encoded.AsSpan(0, encoded.Length - 4), Identity)).Message);
byte[] trailing = [.. encoded, 0, 0, 0, 0];
Assert.Contains("trailing", Assert.Throws<InvalidDataException>(
() => ReadTextDatabaseCodec.Decode(trailing, Identity)).Message);
}
[Fact]
public void InstalledNativeRtDatRoundTripsItsLogicalRecordsWhenPresent()
{
string path = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Eushully", "姫狩りダンジョンマイスター", "SAVE", "RT.DAT");
if (!File.Exists(path)) return;
ReadTextDatabaseSnapshot imported =
ReadTextDatabaseCodec.Decode(File.ReadAllBytes(path), Identity);
ReadTextDatabaseSnapshot roundTripped = ReadTextDatabaseCodec.Decode(
ReadTextDatabaseCodec.Encode(imported, Identity), Identity);
OpcodeTable table = OpcodeTableJson.Load(Paths.OpcodesJson);
Script sc0000 = Sys4ScriptProvider.Load(table).RequireByName("SC0000.BIN");
Assert.NotEmpty(imported.Records);
Assert.Equal(0x22u, sc0000.PackedId);
Assert.Equal(sc0000.ReadMessageOffsets.Count, imported.Records[sc0000.PackedId].Count);
Assert.Equal(imported.Records.Keys.Order(), roundTripped.Records.Keys.Order());
foreach (var record in imported.Records)
Assert.Equal(record.Value, roundTripped.Records[record.Key]);
}
[Fact]
public void ReadTextDatabaseQueuesThenCommitsWithNativeGrowthRules()
{
var database = new ReadTextDatabase();
database.QueueMessage(5, 1, 2);
Assert.False(database.IsMessageRead(5, 1));
Assert.Equal(1, database.PendingCount);
database.CommitPending();
Assert.True(database.IsMessageRead(5, 1));
database.QueueMessage(5, 3, 4);
database.QueueMessage(5, -1, 4);
database.CommitPending();
Assert.True(database.IsMessageRead(5, 1));
Assert.True(database.IsMessageRead(5, 3));
Assert.False(database.IsMessageRead(5, 2));
Assert.Equal(4, database.Snapshot().Records[5].Count);
}
[Fact]
public void SharedProfilePersistsSaveDatAndReadTextDatThroughPairedTransactions()
{
string root = Path.Combine(Path.GetTempPath(), "age-read-text-" + Guid.NewGuid().ToString("N"));
try
{
var store = new DirectoryNativeDatStore(root, Identity);
var profile = new SharedProfile();
var selectors = new uint[SharedProfilePayloadCodec.ExtendedSelectorCount];
profile.Replace(new SharedProfilePayload(extendedSelectorCounts: selectors));
profile.StoreInteger(0x123, 9);
profile.ReadText.QueueMessage(0x22, 2, 4);
profile.ReadText.CommitPending();
profile.Save(store, Timestamp, 10);
Assert.True(File.Exists(Path.Combine(root, DirectoryNativeDatStore.SharedFileName)));
Assert.True(File.Exists(Path.Combine(root, DirectoryNativeDatStore.ReadTextFileName)));
var loaded = new SharedProfile();
Assert.True(loaded.Load(store));
Assert.Equal(9, loaded.LoadInteger(0x123));
Assert.True(loaded.ReadText.IsMessageRead(0x22, 2));
loaded.ReadText.QueueMessage(0x22, 3, 4);
loaded.ReadText.CommitPending();
loaded.Save(store, Timestamp, 20);
Assert.True(File.Exists(Path.Combine(root, DirectoryNativeDatStore.ReadTextBackupFileName)));
Assert.True(store.LoadReadText()!.Records[0x22][3] != 0);
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
[Fact]
public void AdvOpcodesUseT1CoordinatesAndShareReadStateAcrossFreshVms()
{
OpcodeTable table = OpcodeTableJson.Load(Paths.OpcodesJson);
static Operand I(int value) => new(0, value);
static Operand S(int value) => new(2, value);
static Operand G(int value) => new(3, value);
Script script = ScriptAssembler.Assemble(table, "READ_TEXT_TEST",
[
(0x1ca, new[] { I(1) }),
(0x1cb, new[] { G(0x100) }),
(0x71, new[] { I(1) }),
(0x6e, new[] { I(1), S(0) }),
(0x1cc, new[] { G(0x101) }),
(0x72, new[] { I(1) }),
(0x71, new[] { I(1) }),
(0x6e, new[] { I(1), S(1) }),
(0x1cc, new[] { G(0x102) }),
(0x72, new[] { I(1) }),
(0x71, new[] { I(1) }),
(0x2, Array.Empty<Operand>()),
], ["first", "second"]);
var profile = new SharedProfile();
var first = new VirtualMachine(
script, table, new RecordingHost(), sharedProfile: profile);
first.Run();
Assert.Equal(1, first.Globals[0x100]);
Assert.Equal(0, first.Globals[0x101]);
Assert.Equal(0, first.Globals[0x102]);
Assert.Equal(3, script.ReadMessageOffsets.Count);
Assert.True(profile.ReadText.IsMessageRead(script.PackedId, 0));
Assert.True(profile.ReadText.IsMessageRead(script.PackedId, 1));
var secondHost = new RecordingHost();
var second = new VirtualMachine(
script, table, secondHost, sharedProfile: profile);
second.Run();
Assert.Equal(1, second.Globals[0x101]);
Assert.Equal(1, second.Globals[0x102]);
Assert.Contains(true, secondHost.MessageSkipChanges);
}
}

View File

@@ -80,6 +80,8 @@ public interface IHost
// persistent op-0x88 channel so releasing the key cannot turn off the user's Skip toggle.
void SetPhysicalMessageSkipActive(bool active) { }
bool IsMessageSkipActive => false;
// Optional diagnostic override. Native ReadTextDB state is VM/profile-owned; interactive hosts
// normally leave this false.
bool IsAdvReadSkipActive => false;
// Normal playback reaches op 0x21c and parks until a queued 0x223 transition completes. The
// read/message-skip branch reaches op 0x20c and presents the completed endpoint immediately.

View File

@@ -2,11 +2,15 @@ namespace Age.Engine.Model;
public sealed class Script
{
public string Name { get; init; } = "";
/// <summary>Raw packed SYS4/AAI resource id used as this script's native ReadTextDB key.</summary>
public uint PackedId { get; init; }
public required ScriptHeader Header { get; init; }
public required IReadOnlyList<Instruction> Instructions { get; init; }
public required IReadOnlyDictionary<int, int> IndexByOffset { get; init; }
public required IReadOnlyDictionary<int, string> Strings { get; init; }
/// <summary>Unmodified SYS4 body dwords, retained for inline data operands such as opcode 0x64.</summary>
public IReadOnlyList<uint> BodyDwords { get; init; } = Array.Empty<uint>();
/// <summary>T1 entries: code DWORD offsets of op-0x71 message boundaries.</summary>
public IReadOnlyList<int> ReadMessageOffsets { get; init; } = Array.Empty<int>();
public string GetString(int offset) => Strings.TryGetValue(offset, out var s) ? s : "";
}

View File

@@ -39,6 +39,8 @@ public interface INativeDatStore
NativeSaveIdentity Identity { get; }
NativeSaveDocument? LoadShared();
void SaveShared(ReadOnlySpan<byte> payload, NativeSystemTime timestamp, uint accumulatedPlaySeconds);
ReadTextDatabaseSnapshot? LoadReadText();
void SaveReadText(ReadTextDatabaseSnapshot snapshot);
NativeSaveDocument? LoadNumbered(int slot);
void SaveNumbered(int slot, ReadOnlySpan<byte> payload, NativeSystemTime timestamp, uint accumulatedPlaySeconds);
}
@@ -53,6 +55,9 @@ public sealed class DirectoryNativeDatStore : INativeDatStore
public const string SharedFileName = "SAVE.DAT";
public const string SharedTemporaryFileName = "$$SAVE.DAT";
public const string SharedBackupFileName = "SAVE.BAK";
public const string ReadTextFileName = "RT.DAT";
public const string ReadTextTemporaryFileName = "$$RT.DAT";
public const string ReadTextBackupFileName = "RT.BAK";
private readonly string _root;
private readonly NativeSaveIdentity _identity;
@@ -112,6 +117,29 @@ public sealed class DirectoryNativeDatStore : INativeDatStore
File.Move(temporary, primary);
}
public ReadTextDatabaseSnapshot? LoadReadText()
{
string path = Path.Combine(_root, ReadTextFileName);
return File.Exists(path)
? ReadTextDatabaseCodec.Decode(File.ReadAllBytes(path), _identity)
: null;
}
public void SaveReadText(ReadTextDatabaseSnapshot snapshot)
{
ArgumentNullException.ThrowIfNull(snapshot);
byte[] encoded = ReadTextDatabaseCodec.Encode(snapshot, _identity);
Directory.CreateDirectory(_root);
string temporary = Path.Combine(_root, ReadTextTemporaryFileName);
string primary = Path.Combine(_root, ReadTextFileName);
string backup = Path.Combine(_root, ReadTextBackupFileName);
WriteThrough(temporary, encoded);
if (File.Exists(backup)) File.Delete(backup);
if (File.Exists(primary)) File.Move(primary, backup);
File.Move(temporary, primary);
}
public NativeSaveDocument? LoadNumbered(int slot)
{
string path = Path.Combine(_root, NumberedFileName(slot));

View File

@@ -0,0 +1,258 @@
using System.Buffers.Binary;
using System.Collections.ObjectModel;
using System.Text;
namespace Age.Engine.Persistence;
/// <summary>One native RT.DAT script record and its per-message read flags.</summary>
public sealed record ReadTextScriptRecord(uint ScriptId, IReadOnlyList<uint> Flags);
/// <summary>
/// Logical contents of AGE's shared RT.DAT. Records are keyed by the raw packed SYS4/AAI script id.
/// </summary>
public sealed class ReadTextDatabaseSnapshot
{
public IReadOnlyDictionary<uint, IReadOnlyList<uint>> Records { get; }
public ReadTextDatabaseSnapshot(IEnumerable<ReadTextScriptRecord>? records = null)
{
var values = new Dictionary<uint, IReadOnlyList<uint>>();
foreach (ReadTextScriptRecord record in records ?? Array.Empty<ReadTextScriptRecord>())
{
if (!values.TryAdd(record.ScriptId, Array.AsReadOnly(record.Flags.ToArray())))
throw new ArgumentException(
$"ReadTextDB repeats script id 0x{record.ScriptId:x8}.", nameof(records));
}
Records = new ReadOnlyDictionary<uint, IReadOnlyList<uint>>(values);
}
}
/// <summary>
/// Codec for AGE's native S3RT file: a fixed 0x114-byte identity header, all 12-byte script
/// records, then each record's DWORD flag array. The record's third word is an ignored serialized
/// process pointer; portable writers emit zero and native AGE replaces it while loading.
/// </summary>
public static class ReadTextDatabaseCodec
{
public const int HeaderSize = 0x114;
public const int RecordSize = 0x0c;
public const int VersionMajor = 1;
public const int VersionMinor = 0;
private const uint Magic = 0x54523353; // S3RT
private static readonly Encoding ShiftJis = CreateShiftJis();
public static byte[] Encode(ReadTextDatabaseSnapshot snapshot, NativeSaveIdentity identity)
{
ArgumentNullException.ThrowIfNull(snapshot);
ArgumentNullException.ThrowIfNull(identity);
KeyValuePair<uint, IReadOnlyList<uint>>[] records =
snapshot.Records.OrderBy(record => record.Key).ToArray();
long length = HeaderSize + checked((long)records.Length * RecordSize);
foreach (var record in records)
length = checked(length + (long)record.Value.Count * sizeof(uint));
if (length > int.MaxValue)
throw new InvalidDataException("RT.DAT is too large.");
byte[] result = new byte[(int)length];
BinaryPrimitives.WriteUInt32LittleEndian(result.AsSpan(0, 4), Magic);
BinaryPrimitives.WriteUInt32LittleEndian(result.AsSpan(4, 4), identity.CompatibilityId);
WriteGameId(result.AsSpan(8, 0x100), identity.GameId);
BinaryPrimitives.WriteUInt32LittleEndian(result.AsSpan(0x108, 4), VersionMajor);
BinaryPrimitives.WriteUInt32LittleEndian(result.AsSpan(0x10c, 4), VersionMinor);
BinaryPrimitives.WriteUInt32LittleEndian(result.AsSpan(0x110, 4), checked((uint)records.Length));
int position = HeaderSize;
foreach (var record in records)
{
BinaryPrimitives.WriteUInt32LittleEndian(result.AsSpan(position, 4), record.Key);
BinaryPrimitives.WriteUInt32LittleEndian(
result.AsSpan(position + 4, 4), checked((uint)record.Value.Count));
BinaryPrimitives.WriteUInt32LittleEndian(result.AsSpan(position + 8, 4), 0);
position += RecordSize;
}
foreach (var record in records)
foreach (uint flag in record.Value)
{
BinaryPrimitives.WriteUInt32LittleEndian(result.AsSpan(position, 4), flag);
position += sizeof(uint);
}
return result;
}
public static ReadTextDatabaseSnapshot Decode(
ReadOnlySpan<byte> source,
NativeSaveIdentity identity)
{
ArgumentNullException.ThrowIfNull(identity);
if (source.Length < HeaderSize)
throw new InvalidDataException("RT.DAT header is truncated.");
if (BinaryPrimitives.ReadUInt32LittleEndian(source) != Magic)
throw new InvalidDataException("RT.DAT magic is not S3RT.");
if (BinaryPrimitives.ReadUInt32LittleEndian(source[4..]) != identity.CompatibilityId)
throw new InvalidDataException("RT.DAT compatibility id mismatch.");
if (!StringComparer.Ordinal.Equals(ReadGameId(source.Slice(8, 0x100)), identity.GameId))
throw new InvalidDataException("RT.DAT game id mismatch.");
uint major = BinaryPrimitives.ReadUInt32LittleEndian(source[0x108..]);
uint minor = BinaryPrimitives.ReadUInt32LittleEndian(source[0x10c..]);
if (major != VersionMajor || minor != VersionMinor)
throw new InvalidDataException(
$"RT.DAT version mismatch: expected {VersionMajor}.{VersionMinor}, got {major}.{minor}.");
uint rawCount = BinaryPrimitives.ReadUInt32LittleEndian(source[0x110..]);
if (rawCount > int.MaxValue || rawCount > (uint)((source.Length - HeaderSize) / RecordSize))
throw new InvalidDataException("RT.DAT script-record count is too large.");
int count = (int)rawCount;
int flagsPosition = checked(HeaderSize + count * RecordSize);
var records = new List<ReadTextScriptRecord>(count);
var seen = new HashSet<uint>();
for (int i = 0; i < count; i++)
{
int recordPosition = HeaderSize + i * RecordSize;
uint scriptId = BinaryPrimitives.ReadUInt32LittleEndian(source[recordPosition..]);
uint rawMessageCount =
BinaryPrimitives.ReadUInt32LittleEndian(source[(recordPosition + 4)..]);
if (!seen.Add(scriptId))
throw new InvalidDataException($"RT.DAT repeats script id 0x{scriptId:x8}.");
if (rawMessageCount > int.MaxValue
|| rawMessageCount > (uint)((source.Length - flagsPosition) / sizeof(uint)))
throw new InvalidDataException(
$"RT.DAT script 0x{scriptId:x8} flag array is truncated.");
int messageCount = (int)rawMessageCount;
var flags = new uint[messageCount];
for (int message = 0; message < messageCount; message++)
{
flags[message] = BinaryPrimitives.ReadUInt32LittleEndian(source[flagsPosition..]);
flagsPosition += sizeof(uint);
}
records.Add(new ReadTextScriptRecord(scriptId, flags));
}
if (flagsPosition != source.Length)
throw new InvalidDataException(
$"RT.DAT has {source.Length - flagsPosition} unexpected trailing byte(s).");
return new ReadTextDatabaseSnapshot(records);
}
private static void WriteGameId(Span<byte> destination, string gameId)
{
destination.Clear();
byte[] encoded = ShiftJis.GetBytes(TruncateAtNul(gameId));
if (encoded.Length >= destination.Length)
throw new InvalidDataException("RT.DAT game id does not fit its 256-byte field.");
encoded.CopyTo(destination);
}
private static string ReadGameId(ReadOnlySpan<byte> source)
{
int terminator = source.IndexOf((byte)0);
if (terminator < 0)
throw new InvalidDataException("RT.DAT game id is not NUL-terminated.");
try
{
return ShiftJis.GetString(source[..terminator]);
}
catch (DecoderFallbackException error)
{
throw new InvalidDataException("RT.DAT game id is not valid CP932.", error);
}
}
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);
}
}
/// <summary>
/// Profile-lifetime ReadTextDB with AGE's queue-at-advance and commit-at-op-0x71 lifecycle.
/// </summary>
public sealed class ReadTextDatabase
{
private readonly Dictionary<uint, uint[]> _records = new();
private readonly List<(uint ScriptId, int MessageIndex, int MessageCount)> _pending = new();
public int RecordCount => _records.Count;
public int PendingCount => _pending.Count;
public bool IsMessageRead(uint scriptId, int messageIndex)
=> messageIndex >= 0
&& _records.TryGetValue(scriptId, out uint[]? flags)
&& messageIndex < flags.Length
&& flags[messageIndex] != 0;
public void QueueMessage(uint scriptId, int messageIndex, int messageCount)
=> _pending.Add((scriptId, messageIndex, messageCount));
public void CommitPending()
{
foreach (var pending in _pending)
{
if (pending.MessageIndex < 0
|| pending.MessageCount <= 0
|| pending.MessageIndex >= pending.MessageCount)
continue;
if (!_records.TryGetValue(pending.ScriptId, out uint[]? flags))
{
flags = new uint[pending.MessageCount];
_records.Add(pending.ScriptId, flags);
}
else if (pending.MessageIndex >= flags.Length)
{
var grown = new uint[pending.MessageCount];
Array.Copy(flags, grown, Math.Min(flags.Length, grown.Length));
flags = grown;
_records[pending.ScriptId] = flags;
}
flags[pending.MessageIndex] = 1;
}
_pending.Clear();
}
public ReadTextDatabaseSnapshot Snapshot()
=> new(_records.Select(record =>
new ReadTextScriptRecord(record.Key, Array.AsReadOnly(record.Value.ToArray()))));
public void Replace(ReadTextDatabaseSnapshot snapshot)
{
ArgumentNullException.ThrowIfNull(snapshot);
_records.Clear();
foreach (var record in snapshot.Records)
_records.Add(record.Key, record.Value.ToArray());
_pending.Clear();
}
public bool Load(INativeDatStore store)
{
ArgumentNullException.ThrowIfNull(store);
ReadTextDatabaseSnapshot? snapshot = store.LoadReadText();
if (snapshot is null)
{
Clear();
return false;
}
Replace(snapshot);
return true;
}
public void Save(INativeDatStore store)
{
ArgumentNullException.ThrowIfNull(store);
store.SaveReadText(Snapshot());
}
public void Clear()
{
_records.Clear();
_pending.Clear();
}
}

View File

@@ -337,8 +337,8 @@ public static class SharedProfilePayloadCodec
}
/// <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.
/// Profile-lifetime selected cells plus the opaque SAVE.DAT sections and independent RT.DAT read
/// history. VM opcodes mutate this object; explicit Load/Save calls own both filesystem lifecycles.
/// </summary>
public sealed class SharedProfile
{
@@ -351,6 +351,9 @@ public sealed class SharedProfile
public IReadOnlyDictionary<int, uint> IntegerCells => _integerCells;
public IReadOnlyDictionary<int, string> StringCells => _stringCells;
public ReadTextDatabase ReadText { get; } = new();
/// <summary>The engine setting manipulated by opcodes 0x1ca/0x1cb.</summary>
public bool ReadMessageSkipEnabled { get; set; }
public void StoreInteger(int address, long value)
{
@@ -384,12 +387,14 @@ public sealed class SharedProfile
NativeSaveDocument? document = store.LoadShared();
if (document is null)
{
Clear();
return false;
ClearSharedPayload();
}
Replace(SharedProfilePayloadCodec.Decode(document.Payload, document.Metadata));
return true;
else
{
Replace(SharedProfilePayloadCodec.Decode(document.Payload, document.Metadata));
}
bool readTextLoaded = ReadText.Load(store);
return document is not null || readTextLoaded;
}
public void Save(
@@ -401,6 +406,7 @@ public sealed class SharedProfile
NativeSaveMetadata metadata = store.Identity.CreateMetadata(timestamp, accumulatedPlaySeconds);
byte[] payload = SharedProfilePayloadCodec.Encode(Snapshot(), metadata);
store.SaveShared(payload, timestamp, accumulatedPlaySeconds);
ReadText.Save(store);
}
public SharedProfilePayload Snapshot()
@@ -426,6 +432,13 @@ public sealed class SharedProfile
}
public void Clear()
{
ClearSharedPayload();
ReadText.Clear();
ReadMessageSkipEnabled = false;
}
private void ClearSharedPayload()
{
_catalogCompatibilityValues = Array.Empty<uint>();
_integerCells.Clear();

View File

@@ -28,13 +28,20 @@ public static class ScriptAssembler
{
int codeLen = 0;
foreach (var ins in instrs) codeLen += 1 + 2 * ins.Args.Length;
var messageOffsets = new List<uint>();
int instructionOffset = 0;
foreach (var ins in instrs)
{
if (ins.Op == 0x71) messageOffsets.Add((uint)instructionOffset);
instructionOffset += 1 + 2 * ins.Args.Length;
}
// Encode strings; record each string's starting dword offset (relative to body start).
var strDwords = new List<uint>();
var strOffset = new int[strings.Count];
for (int i = 0; i < strings.Count; i++)
{
strOffset[i] = codeLen + strDwords.Count;
strOffset[i] = codeLen + messageOffsets.Count + strDwords.Count;
strDwords.AddRange(EncodeString(strings[i]));
}
@@ -49,10 +56,14 @@ public static class ScriptAssembler
body.Add((uint)val);
}
}
body.AddRange(messageOffsets);
body.AddRange(strDwords);
var fields = new int[NumFields];
fields[8] = codeLen; // F8 = code end (strings begin here)
fields[7] = messageOffsets.Count; // F7 = T1/read-message boundary count
fields[8] = codeLen; // F8 = code end / T1 table start
fields[10] = codeLen + messageOffsets.Count;
fields[12] = codeLen + messageOffsets.Count;
var bytes = new byte[HeaderSize + body.Count * 4];
Encoding.ASCII.GetBytes("SYS4422 ").CopyTo(bytes, 0);

View File

@@ -7,7 +7,7 @@ public static class Sys4Loader
public static Script Load(string path, OpcodeTable table)
=> Parse(File.ReadAllBytes(path), table, Path.GetFileName(path));
public static Script Parse(byte[] data, OpcodeTable table, string name = "")
public static Script Parse(byte[] data, OpcodeTable table, string name = "", uint packedId = 0)
{
if (data.Length < HeaderSize) throw new InvalidDataException($"{name}: too small");
if (!(data[0] == (byte)'S' && data[1] == (byte)'Y' && data[2] == (byte)'S' && data[3] == (byte)'4'))
@@ -22,14 +22,23 @@ public static class Sys4Loader
var header = new ScriptHeader(fields[0], fields[1], fields[2], fields[3], fields[4], fields[5]);
var (instrs, idxByOff, strings) = DecodeCode(dw, fields, nbody, table);
int messageCount = fields[7];
int messageTableOffset = fields[8];
if (messageCount < 0 || messageTableOffset < 0
|| messageTableOffset > nbody || messageCount > nbody - messageTableOffset)
throw new InvalidDataException($"{name}: invalid T1 read-message table");
int[] messageOffsets = dw.AsSpan(messageTableOffset, messageCount)
.ToArray().Select(value => checked((int)value)).ToArray();
return new Script
{
Name = name,
PackedId = packedId,
Header = header,
Instructions = instrs,
IndexByOffset = idxByOff,
Strings = strings,
BodyDwords = dw,
ReadMessageOffsets = messageOffsets,
};
}

View File

@@ -51,5 +51,5 @@ public sealed class Sys4ScriptProvider : IScriptProvider
=> GetByName(name) ?? throw new FileNotFoundException($"script is not in SYS4INI: {name}", name);
private Script Parse(AssetEntry entry)
=> Sys4Loader.Parse(_store.ReadAll(entry), _table, entry.Name);
=> Sys4Loader.Parse(_store.ReadAll(entry), _table, entry.Name, unchecked((uint)entry.PackedId));
}

View File

@@ -10,6 +10,7 @@ internal sealed class ExecFrame
public readonly Script Script;
public int Pc; // entry instruction index
public int ReadMessageOffset = -1; // latest op-0x71 code DWORD coordinate
public readonly Frame Locals = new();
public readonly List<int> CallStack = new(); // intra-script `call` (op 0x8f) returns
public readonly Dictionary<int, int> EmitSeen = new();

View File

@@ -21,7 +21,7 @@ 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>
/// <summary>AGE's selected profile-wide cells plus native shared SAVE.DAT/RT.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();

View File

@@ -53,6 +53,7 @@ public sealed class VirtualMachine
private volatile bool _messageSkipEnabled;
private volatile bool _messageSkipServiceActive;
private volatile bool _advSkipServiceEnabled;
private bool _advReadSkipState;
private AdvTextStyle _advTextStyle = AdvTextStyle.Default;
private readonly Dictionary<string, int> _valueSwitchTargets = new(StringComparer.Ordinal);
// Native EngineCtx owns 11 lazily allocated integer FIFOs at +0x55130. ATSEEK/MVSEEK use
@@ -251,6 +252,24 @@ public sealed class VirtualMachine
_host.SetPhysicalMessageSkipActive(active);
}
private int CurrentReadMessageIndex()
{
if (_cur.ReadMessageOffset < 0) return -1;
for (int i = 0; i < _cur.Script.ReadMessageOffsets.Count; i++)
if (_cur.Script.ReadMessageOffsets[i] == _cur.ReadMessageOffset) return i;
return -1;
}
private void RefreshAdvReadSkipState()
{
int messageIndex = CurrentReadMessageIndex();
_advReadSkipState = _sharedProfile.ReadMessageSkipEnabled
&& _sharedProfile.ReadText.IsMessageRead(
_cur.Script.PackedId, messageIndex);
_messageSkipServiceActive = _messageSkipEnabled || _advReadSkipState;
_host.SetMessageSkipActive(_messageSkipServiceActive);
}
private static long Gi(Dictionary<int, long> d, int k) => d.TryGetValue(k, out var v) ? v : 0;
private long ReadGlobal(int k) => ExternalGlobals.TryGetValue(k, out var v) ? v : Gi(Globals, k);
private static string Gs(Dictionary<int, string> d, int k) => d.TryGetValue(k, out var v) ? v : "";
@@ -583,6 +602,7 @@ public sealed class VirtualMachine
_messageSkipEnabled = false;
_messageSkipServiceActive = false;
_advSkipServiceEnabled = false;
_advReadSkipState = false;
_advTextStyle = AdvTextStyle.Default;
TextHistory.SetRecordingEnabled(true);
_host.SetMessageSkipActive(false);
@@ -1212,6 +1232,7 @@ public sealed class VirtualMachine
return pc + 1;
}
case "show-text":
RefreshAdvReadSkipState();
foreach (var o in a)
{
if (o.Type != T_STR) continue;
@@ -1236,6 +1257,9 @@ public sealed class VirtualMachine
var layout = TextHistory.GetLayoutSnapshot(requestedSlot);
_host.SetAdvTextCursor(layout.Slot, layout.CursorX, layout.CursorY);
_host.ClearRenderedAdvTextLayout(layout.Slot);
_cur.ReadMessageOffset = ins.Offset;
_sharedProfile.ReadText.CommitPending();
RefreshAdvReadSkipState();
return pc + 1;
}
case "set-adv-text-reset-cursor": // 0x79: configure cursor restored by a later 0x71
@@ -1275,6 +1299,7 @@ public sealed class VirtualMachine
return pc + 1;
}
case "wait-for-input":
RefreshAdvReadSkipState();
// Faithful headless: no player => halt here rather than plow past every prompt (see VmOptions).
if (_o.HaltAtWaitForInput) { HaltReason ??= "wait-for-input"; return HALT; }
// The native ADV chrome is a coroutine: after an earlier 0x93 cancellation its shared
@@ -1293,6 +1318,9 @@ public sealed class VirtualMachine
_host.WaitForInput((int)Read(a[0]), ServiceHotspotCallback,
() => new AdvAutoWaitState(_autoMessageEnabled, _autoVoicePending,
_autoMessageTime0Ms, _autoMessageTime1Ms));
_sharedProfile.ReadText.QueueMessage(
_cur.Script.PackedId, CurrentReadMessageIndex(),
_cur.Script.ReadMessageOffsets.Count);
return pc + 1;
case "u0041BEB0":
case "register-hotspot-callbacks": // 0x90: inclusive rect + enter/leave/activate local callbacks
@@ -1515,7 +1543,8 @@ public sealed class VirtualMachine
case "u00414EC0":
case "resume-adv-skip-service": // 0x19c: recompute active fast-forward on ADV entry
_advSkipServiceEnabled = true;
_messageSkipServiceActive = _messageSkipEnabled || _host.IsAdvReadSkipActive;
_messageSkipServiceActive =
_messageSkipEnabled || _advReadSkipState || _host.IsAdvReadSkipActive;
_host.SetMessageSkipActive(_messageSkipServiceActive);
RefreshPhysicalMessageSkipState();
return pc + 1;
@@ -1525,7 +1554,16 @@ public sealed class VirtualMachine
Write(a[0], _messageSkipServiceActive || _host.IsMessageSkipActive ? 1 : 0); return pc + 1;
case "get-adv-read-skip-state": // 0x1cc: per-message read/click skip service state
case "get-adv-service-state": // compatibility with pre-recovery generated tables
Write(a[0], _host.IsAdvReadSkipActive ? 1 : 0); return pc + 1;
Write(a[0], _advReadSkipState || _host.IsAdvReadSkipActive ? 1 : 0); return pc + 1;
case "u0041B9B0":
case "set-read-message-skip": // 0x1ca: engine setting message:ReadTextSkip
_sharedProfile.ReadMessageSkipEnabled = Read(a[0]) != 0;
RefreshAdvReadSkipState();
return pc + 1;
case "u00414FD0":
case "get-read-message-skip": // 0x1cb
Write(a[0], _sharedProfile.ReadMessageSkipEnabled ? 1 : 0);
return pc + 1;
case "u00414F60":
case "get-auto-message": // 0x1b6: VM service state used by the ADV redraw callback
Write(a[0], _autoMessageEnabled ? 1 : 0); return pc + 1;