Implement native RT.DAT read history
This commit is contained in:
@@ -103,8 +103,8 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings)
|
||||
├── engine/ DELIVERABLE — the .NET VM core (AgeEngine.sln: Age.Engine / Age.Cli / tests)
|
||||
│ ├── Age.Engine/Sys4/ runtime catalog parser, loose-first bounded ALF asset store,
|
||||
│ script provider, AGF/LZSS and Windows CUR decoders, and resource facade
|
||||
│ └── Age.Engine/Persistence/ native S3SD/S4SD container + shared-payload codecs,
|
||||
│ profile-owned selected cells, and shared/numbered DAT lifecycle seam
|
||||
│ └── Age.Engine/Persistence/ native S3SD/S4SD + S3RT codecs, shared payload/ReadTextDB,
|
||||
│ profile-owned state, and shared/numbered DAT lifecycle seam
|
||||
├── native/ authored native runtime boundaries
|
||||
│ └── age_movie_ffmpeg/ project-owned FFmpeg C ABI, immutable Windows dependency manifest,
|
||||
│ and bootstrap/build scripts (outputs stay under disposable build/)
|
||||
|
||||
@@ -1786,8 +1786,8 @@ DWORD transform. `Sys4.LzssEncoder` emits the same 4 KiB-ring token dialect alre
|
||||
lossless opaque-section preservation. `SharedProfile` owns selected integer/string maps and explicit
|
||||
load/save lifecycle; `GameSession` injects it into every fresh VM. Opcodes `0x1a2`/`0x1a3` and
|
||||
`0x1a9`/`0x1aa` now implement native upsert and missing-value defaults for direct cells and resolved
|
||||
global pointers. `RT.DAT`, thumbnails, and numbered active-frame payloads remain later layers; the existing
|
||||
`GameSession` JSON snapshot is unchanged.
|
||||
global pointers. `RT.DAT` is now implemented as the separate S3RT layer described below. Thumbnails and
|
||||
numbered active-frame payloads remain later layers; the existing `GameSession` JSON snapshot is unchanged.
|
||||
|
||||
### Opcode `0xae` continues numbered-save stack restoration (2026-07-20)
|
||||
|
||||
@@ -1844,11 +1844,26 @@ documented above. It is independent of the `RT.DAT` read-message database even t
|
||||
writer updates both files in one lifecycle.
|
||||
|
||||
The `RT.DAT` header is `0x114` bytes: magic `0x54523353` (bytes `S3RT`), a compatibility id, a 256-byte
|
||||
game id, version pair `1,0`, and script-record count. It is followed by 12-byte script records containing
|
||||
`{script_id, message_count, pointer_placeholder}` and the corresponding `message_count` dword flag arrays.
|
||||
The loader validates the header compatibility fields, allocates fresh arrays, and rebuilds the in-memory
|
||||
hashtable. The port should own an equivalent profile-level model; matching the original raw pointer-bearing
|
||||
file layout is optional compatibility work, not a prerequisite for native runtime semantics.
|
||||
game id, version pair `1,0`, and script-record count. It is followed by all 12-byte script records containing
|
||||
`{script_id, message_count, serialized_flags_pointer}` and then, in the same record order, the corresponding
|
||||
`message_count` DWORD flag arrays. The native writer copies its live heap pointer into the third record word.
|
||||
The loader ignores that address, allocates a fresh array, overwrites the word, and rebuilds the in-memory
|
||||
hashtable; a portable structurally compatible writer can therefore emit zero without inventing an address.
|
||||
The installed 76,752-byte native file validates the formula exactly: 192 records and 18,543 flag DWORDs.
|
||||
Its SC0000 record (`script_id=0x22`) has 320 messages, exactly matching SC0000's F7/T1 count.
|
||||
|
||||
**Port implementation (2026-07-24):** `ReadTextDatabaseCodec` imports and emits the exact S3RT header,
|
||||
record table, and ordered flag arrays, validates compatibility/game/version identity, accepts native nonzero
|
||||
pointer residue, and writes zero in that ignored field. `DirectoryNativeDatStore` owns
|
||||
`$$RT.DAT` → `RT.DAT` / `RT.BAK` replacement; load follows native behavior and reads `RT.DAT` directly
|
||||
rather than treating `RT.BAK` as a fallback. `SharedProfile.ReadText` owns records and the pending queue
|
||||
across fresh VMs, and shared-profile Save/Load updates SAVE.DAT and RT.DAT as separate native domains.
|
||||
|
||||
Scripts now retain their packed resource id and decoded F7/T1 table. Opcode `0x71` commits pending tuples
|
||||
and snapshots its T1 coordinate; `0x6e`/`0x71`/`0x72` refresh read eligibility; wait completion queues
|
||||
`{script_id,message_index,message_count}`. Opcodes `0x1ca`/`0x1cb` share the profile-lifetime
|
||||
`message:ReadTextSkip` setting, while `0x1cc` reports the current message state. This implements native
|
||||
read-message behavior without scene-offset special cases and leaves JSON/export tooling as extended mode.
|
||||
|
||||
The `/v2` Ghidra image now names/comments the lookup, queue, commit, mark, file read/write, and shared-profile
|
||||
save/load chain and corrects the relevant function prototypes; saved 2026-07-18.
|
||||
|
||||
@@ -17,11 +17,15 @@
|
||||
- **depended on by:** 0x79, 0x1c1
|
||||
- **evidence:** Corpus: T1 targets op-0x71 records, but the operand is a layout slot (SC0000 uses 1; HISTORY computes 2..6), not an anchor id. Ghidra /v2: op_0x71_handler@0x41e540 calls adv_text_layout_reset@0x455210 with ctx+0x55110; the worker clears the selected layout and appends {slot,current_record_count}/arms group-start unless suppressed. The handler also snapshots (frame_pc-frame_codebase)/4 and text state and calls read_text_db_commit_pending@0x46ae20.
|
||||
|
||||
Port status (2026-07-24): commits the profile-owned ReadTextDB queue, records the current T1 coordinate, and refreshes per-message read-skip state in addition to the retained-layout reset.
|
||||
|
||||
### 0x72 `wait-for-input` (wait-for-input, argc 1)
|
||||
- **summary:** (layout_slot) - arm the ADV input wait after text reveal completes; activate the configured wait indicator and, while Auto is enabled, arm the appropriate Auto-message timer.
|
||||
- **grounding:** source=investigation, confidence=high
|
||||
- **evidence:** Ghidra /v2: op_0x72_handler@0x41e690 fetches operand 1 and calls FUN_00453120(text_manager, layout_slot, -1, &state), then sets the input-wait run-state flags. FUN_00453120 resolves layout slot 0 as current and consumes the indicator descriptor at layout+0x3c configured by op 0x73. SYSTEM4 layout 1 uses SO000's bat strip; the click that completes show-text is consumed before this opcode is reached. The handler also checks ctx+0x55104 (Auto enabled): when ctx+0x6dbe4 has no pending voice it arms the timer with message:AutoMessageTime1, substituting 100 ms for configuration value zero. adv_input_service_poll@0x411230 waits for an active voice to finish and then arms AutoMessageTime0, likewise with a 100-ms zero fallback. The same click/Auto completion path calls read_text_db_queue_message@0x469340 with the current script id, resolved per-script message index, and message count; an already-skipped wait queues it directly in op 0x72. Op 0x71 later commits the pending records.
|
||||
|
||||
Port status (2026-07-24): after the blocking host releases this wait, the VM queues the current packed script id, T1 message index, and T1 count; the next 0x71 commits it. Already-read waits use the same queue path after immediate host release.
|
||||
|
||||
### 0x73 `configure-adv-wait-indicator` (configure-adv-wait-indicator, argc 10)
|
||||
- **summary:** (layout_slot)(dst_x)(dst_y)(surface_slot)(src_x)(src_y)(cell_w)(cell_h)(terminal_frame)(frame_period_ms) - configure the animated marker shown while the selected ADV layout waits for input.
|
||||
- **grounding:** source=investigation, confidence=high
|
||||
@@ -548,6 +552,8 @@ Both copies are attempted with overwrite allowed. Status is 0 when both succeed,
|
||||
- **depended on by:** 0x20c, 0x20d, 0x21c, 0x223
|
||||
- **evidence:** Ghidra handler 0x427330 calls vm_operand_write(1, ctx+0x6dbd4). adv_refresh_read_skip_state@0x406cd0 and op 0x6e/0x71/0x72 maintain the field from message:ReadTextSkip plus read_text_db_find_message_index@0x468f50 and read_text_db_is_message_read@0x469930. The database is engine-owned shared RT.DAT state keyed by raw packed script resource id and per-script message index, not VM globals or slot-local SAVE##.DAT data. adv_interpreter_tick consumes the result in click/read-skip control; it is not op 0x223 surface-transition progress.
|
||||
|
||||
Port status (2026-07-24): the VM refreshes this state from message:ReadTextSkip plus the current packed script id/T1 message index at 0x6e, 0x71, and 0x72. It is shared-profile read history, not a host-only flag.
|
||||
|
||||
### 0x21c `mark-frame-yield` (mark-frame-yield, argc 0)
|
||||
- **summary:** Set native run-state bit 0x400; in normal ADV playback this is the retained-presentation render/wait/resume boundary.
|
||||
- **grounding:** source=investigation, confidence=high
|
||||
@@ -1094,11 +1100,15 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it "
|
||||
- **grounding:** source=investigation, confidence=high
|
||||
- **evidence:** Ghidra /v2: op_0x1ca_set_read_message_skip@0x41f880 calls the engine setting interface's setter for `message:ReadTextSkip` with operand 1. SC0000's x=750 Read-message-skip button toggles the value read by op 0x1cb.
|
||||
|
||||
Port status (2026-07-24): implemented as profile-lifetime engine setting state. Changing it immediately refreshes the current T1 message against the shared RT.DAT-backed ReadTextDB.
|
||||
|
||||
### 0x1cb `get-read-message-skip` (u00414FD0, argc 1)
|
||||
- **summary:** (out) - read the engine setting `message:ReadTextSkip`.
|
||||
- **grounding:** source=investigation, confidence=high
|
||||
- **evidence:** Ghidra /v2: op_0x1cb_get_read_message_skip@0x4272f0 calls the engine setting interface's getter for `message:ReadTextSkip` and writes the result to operand 1. The shared ADV redraw routine uses it for the active Read-message-skip icon.
|
||||
|
||||
Port status (2026-07-24): implemented through the same profile-lifetime setting used by 0x1ca and the ADV ReadTextDB query path.
|
||||
|
||||
## marker
|
||||
|
||||
### 0x1a8 `instruction-marker-noop` (dev_ukn, argc 0)
|
||||
|
||||
@@ -3405,6 +3405,35 @@ separation.
|
||||
**Next persistence step:** add native `RT.DAT` and connect the already-mapped ReadTextDB queue/commit/query
|
||||
lifecycle. Numbered active-frame saves and `.STH` remain the later, larger payload slice.
|
||||
|
||||
### Persistence implementation step 3 — native RT.DAT and ReadTextDB lifecycle (2026-07-24)
|
||||
|
||||
The profile-wide read-message domain is now implemented without folding it into shared SAVE.DAT or the
|
||||
port's JSON session snapshot. `ReadTextDatabaseCodec` reads/writes the native `0x114`-byte S3RT header,
|
||||
12-byte `{script_id,message_count,serialized_pointer}` table, and ordered DWORD flag arrays. Installed
|
||||
native RT.DAT validation proves 192 records plus 18,543 flag DWORDs account for all 76,752 bytes; its
|
||||
SC0000 id `0x22` carries 320 messages, exactly matching that script's F7/T1 table. Native heap-pointer
|
||||
words are accepted on import and emitted as zero because AGE allocates and overwrites them on load.
|
||||
|
||||
`DirectoryNativeDatStore` now performs the native `$$RT.DAT` → `RT.DAT` / `RT.BAK` replacement alongside
|
||||
the existing shared transaction. `SharedProfile` owns both ReadTextDB records and its pending queue, loads
|
||||
RT.DAT independently, and writes it after SAVE.DAT. Script parsing retains the packed SYS4/AAI id plus T1
|
||||
message-boundary offsets; the synthetic assembler emits the same table for behavioral tests.
|
||||
|
||||
The VM implements the native seam: wait completion queues the current `{packed_script_id,T1_index,T1_count}`;
|
||||
the next op `0x71` commits it, and ops `0x6e`/`0x71`/`0x72` refresh previously-read eligibility.
|
||||
`0x1ca`/`0x1cb` now set/get the profile-lifetime `message:ReadTextSkip` setting and `0x1cc` exposes the
|
||||
current per-message state. Six focused tests cover exact bytes, malformed files, native pointer tolerance,
|
||||
the installed native oracle, queue/growth behavior, paired filesystem transactions, and fresh-VM opcode
|
||||
continuity.
|
||||
|
||||
**Next persistence step:** implement numbered active-frame SAVE##.DAT layouts and paired BMP `.STH`
|
||||
thumbnail lifecycle. JSON inspection/export and mod-owned namespaced state remain extended-mode work.
|
||||
|
||||
Validation: all 370 engine tests pass, including the installed read-only RT.DAT round-trip oracle;
|
||||
opcode generator tests/lint and the Godot C# build are clean. SC0000 is now 127/129 distinct opcodes
|
||||
handled (98.4%); its only remaining effectful gaps are numbered-save restore boundaries `0x1ad`×6 and
|
||||
`0xae`×1.
|
||||
|
||||
## Data-semantics sidebar: focused append EBINIT inspection (2026-07-24)
|
||||
|
||||
The static INIT surface now accepts a universal packed script id for focused append inspection.
|
||||
|
||||
@@ -468,8 +468,10 @@ JSON inspection/export, migrations, and namespaced mod state are additive extend
|
||||
replacement for compatibility-mode import/export. The recovered native contract lives in
|
||||
`docs/engine-re.md`. The common container codec/store and typed shared `SAVE.DAT` payload landed on
|
||||
2026-07-24. Profile-owned selected integer/string cells now survive across scene VMs and are wired to
|
||||
their four native opcodes while opaque catalog/version sections round-trip unchanged. `RT.DAT` is the
|
||||
next profile domain; numbered active-frame state and thumbnails remain later Phase B work.
|
||||
their four native opcodes while opaque catalog/version sections round-trip unchanged. Native `RT.DAT`
|
||||
import/export and the packed-script/T1 ReadTextDB queue/commit/query lifecycle are also implemented,
|
||||
including `message:ReadTextSkip` ops `0x1ca`/`0x1cb` and state query `0x1cc`. Numbered active-frame state
|
||||
and thumbnails remain later Phase B work.
|
||||
|
||||
### Phase C — Externalize & modding foundation
|
||||
- Add **editable named data overlays** mapped explicitly onto the VM's `*INIT`-produced state; external
|
||||
|
||||
@@ -64,7 +64,7 @@ consistent with the SYS4/SYS5 family.
|
||||
|
||||
```
|
||||
body[0 .. F8) CODE bytecode instruction stream
|
||||
body[F8 .. F10) TABLE-1 (F7 entries, 1 dword each) -> targets of type 0x71
|
||||
body[F8 .. F10) TABLE-1 (F7 entries, 1 dword each) -> read-message boundaries (op 0x71)
|
||||
body[F10 .. F12) TABLE-2 (F9 entries, 1 dword each) -> targets of type 0x03
|
||||
body[F12 .. EOF) TABLE-3 (F11 entries, 1 dword each) -> targets of type 0x8F
|
||||
```
|
||||
@@ -80,11 +80,15 @@ tag identifying the pointed-to construct:
|
||||
|
||||
| Table | count/off | Target dword tag | Hits | Meaning (inferred) |
|
||||
|---|---|---|---|---|
|
||||
| T1 | F7 / F8 | **0x71** | 26,445/26,445 | labels / call targets (operand at +2 is small: mostly 1) |
|
||||
| T1 | F7 / F8 | **0x71** | 26,445/26,445 | per-script read-message boundary index |
|
||||
| T2 | F9 / F10 | **0x03** | 3,018/3,018 | data/variable entries (operand at +2 large, e.g. addresses) |
|
||||
| T3 | F11 / F12| **0x8F** | 72,941/72,941 | instruction/line entries (largest table; operand at +2 huge) |
|
||||
|
||||
100% type purity — not a single target had a different tag. T3 is the big one
|
||||
100% type purity — not a single target had a different tag. Native
|
||||
`read_text_db_find_message_index@0x468f50` searches T1 for the code DWORD coordinate most recently
|
||||
snapshotted by op `0x71`; its zero-based entry index and F7 count are the message index/count stored in
|
||||
shared `RT.DAT`. Thus T1 is not a generic label table even though every entry is a control-structure
|
||||
site. T3 is the big one
|
||||
(~73k entries corpus-wide), consistent with it being a per-instruction or
|
||||
per-source-line index (a debug/line table). T1 ≈ labels, T2 ≈ a smaller symbol set.
|
||||
|
||||
|
||||
189
engine/Age.Engine.Tests/ReadTextDatabaseTests.cs
Normal file
189
engine/Age.Engine.Tests/ReadTextDatabaseTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 : "";
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
258
engine/Age.Engine/Persistence/ReadTextDatabase.cs
Normal file
258
engine/Age.Engine/Persistence/ReadTextDatabase.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1002,6 +1002,7 @@ abi_source = "kelebek+decode-validated"
|
||||
name = "reset-adv-text-layout"
|
||||
category = "adv"
|
||||
summary = "(layout_slot) - clear/reset an ADV text layout, append a retained-history boundary when recording is enabled, snapshot the current code/text position, and commit pending ReadTextDB records. T1 entries target these structural reset sites."
|
||||
details = "Port status (2026-07-24): commits the profile-owned ReadTextDB queue, records the current T1 coordinate, and refreshes per-message read-skip state in addition to the retained-layout reset."
|
||||
noop_headless = false
|
||||
source = "investigation"
|
||||
confidence = "high"
|
||||
@@ -1023,6 +1024,7 @@ abi_source = "kelebek+decode-validated"
|
||||
name = "wait-for-input"
|
||||
category = "adv"
|
||||
summary = "(layout_slot) - arm the ADV input wait after text reveal completes; activate the configured wait indicator and, while Auto is enabled, arm the appropriate Auto-message timer."
|
||||
details = "Port status (2026-07-24): after the blocking host releases this wait, the VM queues the current packed script id, T1 message index, and T1 count; the next 0x71 commits it. Already-read waits use the same queue path after immediate host release."
|
||||
noop_headless = false
|
||||
source = "investigation"
|
||||
confidence = "high"
|
||||
@@ -4240,6 +4242,7 @@ abi_source = "kelebek+decode-validated"
|
||||
name = "set-read-message-skip"
|
||||
category = "input"
|
||||
summary = "(enabled) - set the engine setting `message:ReadTextSkip`, which skips only previously read text."
|
||||
details = "Port status (2026-07-24): implemented as profile-lifetime engine setting state. Changing it immediately refreshes the current T1 message against the shared RT.DAT-backed ReadTextDB."
|
||||
noop_headless = false
|
||||
source = "investigation"
|
||||
confidence = "high"
|
||||
@@ -4261,6 +4264,7 @@ abi_source = "kelebek+decode-validated"
|
||||
name = "get-read-message-skip"
|
||||
category = "input"
|
||||
summary = "(out) - read the engine setting `message:ReadTextSkip`."
|
||||
details = "Port status (2026-07-24): implemented through the same profile-lifetime setting used by 0x1ca and the ADV ReadTextDB query path."
|
||||
noop_headless = false
|
||||
source = "investigation"
|
||||
confidence = "high"
|
||||
@@ -4282,6 +4286,7 @@ abi_source = "kelebek+decode-validated"
|
||||
name = "get-adv-read-skip-state"
|
||||
category = "control"
|
||||
summary = "(out) - copy the current ADV read/click-skip service state from ctx+0x6dbd4. label_1235a ORs it with 0x1c7's Ctrl/message-skip bit: zero takes 0x21c's normal transition/yield path; nonzero resets the animation service and presents the completed endpoint through 0x20c."
|
||||
details = "Port status (2026-07-24): the VM refreshes this state from message:ReadTextSkip plus the current packed script id/T1 message index at 0x6e, 0x71, and 0x72. It is shared-profile read history, not a host-only flag."
|
||||
noop_headless = false
|
||||
source = "investigation"
|
||||
confidence = "high"
|
||||
|
||||
Reference in New Issue
Block a user