From bda586aa287bd430a5153f99ed2af3e4e993a024 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 24 Jul 2026 15:11:30 -0400 Subject: [PATCH] Implement native RT.DAT read history --- docs/PROJECT-STRUCTURE.md | 4 +- docs/engine-re.md | 29 +- docs/opcode-reference.md | 10 + docs/phase-a-slice-plan.md | 29 ++ docs/remake-architecture-and-roadmap.md | 6 +- docs/sys4-format-notes.md | 10 +- .../Age.Engine.Tests/ReadTextDatabaseTests.cs | 189 +++++++++++++ engine/Age.Engine/Hosting/IHost.cs | 2 + engine/Age.Engine/Model/Script.cs | 4 + .../Age.Engine/Persistence/NativeDatStore.cs | 28 ++ .../Persistence/ReadTextDatabase.cs | 258 ++++++++++++++++++ .../Age.Engine/Persistence/SharedProfile.cs | 27 +- engine/Age.Engine/Sys4/ScriptAssembler.cs | 15 +- engine/Age.Engine/Sys4/Sys4Loader.cs | 11 +- engine/Age.Engine/Sys4/Sys4ScriptProvider.cs | 2 +- engine/Age.Engine/Vm/ExecFrame.cs | 1 + engine/Age.Engine/Vm/GameSession.cs | 2 +- engine/Age.Engine/Vm/VirtualMachine.cs | 42 ++- vm-map/opcodes.toml | 5 + 19 files changed, 646 insertions(+), 28 deletions(-) create mode 100644 engine/Age.Engine.Tests/ReadTextDatabaseTests.cs create mode 100644 engine/Age.Engine/Persistence/ReadTextDatabase.cs diff --git a/docs/PROJECT-STRUCTURE.md b/docs/PROJECT-STRUCTURE.md index fa97ac1..ed8ee9e 100644 --- a/docs/PROJECT-STRUCTURE.md +++ b/docs/PROJECT-STRUCTURE.md @@ -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/) diff --git a/docs/engine-re.md b/docs/engine-re.md index a95ff3b..7a0fe14 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -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. diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index d428312..9024587 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -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) diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index 411b4ac..3bf76d4 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -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. diff --git a/docs/remake-architecture-and-roadmap.md b/docs/remake-architecture-and-roadmap.md index e0bf9e9..5ef38ce 100644 --- a/docs/remake-architecture-and-roadmap.md +++ b/docs/remake-architecture-and-roadmap.md @@ -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 diff --git a/docs/sys4-format-notes.md b/docs/sys4-format-notes.md index eb315f7..1a33f3e 100644 --- a/docs/sys4-format-notes.md +++ b/docs/sys4-format-notes.md @@ -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. diff --git a/engine/Age.Engine.Tests/ReadTextDatabaseTests.cs b/engine/Age.Engine.Tests/ReadTextDatabaseTests.cs new file mode 100644 index 0000000..9448d95 --- /dev/null +++ b/engine/Age.Engine.Tests/ReadTextDatabaseTests.cs @@ -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( + () => ReadTextDatabaseCodec.Decode(encoded, wrongIdentity)).Message); + + Assert.Contains("truncated", Assert.Throws( + () => ReadTextDatabaseCodec.Decode(encoded.AsSpan(0, encoded.Length - 4), Identity)).Message); + + byte[] trailing = [.. encoded, 0, 0, 0, 0]; + Assert.Contains("trailing", Assert.Throws( + () => 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()), + ], ["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); + } +} diff --git a/engine/Age.Engine/Hosting/IHost.cs b/engine/Age.Engine/Hosting/IHost.cs index 60fbd83..234b4ca 100644 --- a/engine/Age.Engine/Hosting/IHost.cs +++ b/engine/Age.Engine/Hosting/IHost.cs @@ -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. diff --git a/engine/Age.Engine/Model/Script.cs b/engine/Age.Engine/Model/Script.cs index c1157bb..13ecead 100644 --- a/engine/Age.Engine/Model/Script.cs +++ b/engine/Age.Engine/Model/Script.cs @@ -2,11 +2,15 @@ namespace Age.Engine.Model; public sealed class Script { public string Name { get; init; } = ""; + /// Raw packed SYS4/AAI resource id used as this script's native ReadTextDB key. + public uint PackedId { get; init; } public required ScriptHeader Header { get; init; } public required IReadOnlyList Instructions { get; init; } public required IReadOnlyDictionary IndexByOffset { get; init; } public required IReadOnlyDictionary Strings { get; init; } /// Unmodified SYS4 body dwords, retained for inline data operands such as opcode 0x64. public IReadOnlyList BodyDwords { get; init; } = Array.Empty(); + /// T1 entries: code DWORD offsets of op-0x71 message boundaries. + public IReadOnlyList ReadMessageOffsets { get; init; } = Array.Empty(); public string GetString(int offset) => Strings.TryGetValue(offset, out var s) ? s : ""; } diff --git a/engine/Age.Engine/Persistence/NativeDatStore.cs b/engine/Age.Engine/Persistence/NativeDatStore.cs index 394cfb2..97c1d63 100644 --- a/engine/Age.Engine/Persistence/NativeDatStore.cs +++ b/engine/Age.Engine/Persistence/NativeDatStore.cs @@ -39,6 +39,8 @@ public interface INativeDatStore NativeSaveIdentity Identity { get; } NativeSaveDocument? LoadShared(); void SaveShared(ReadOnlySpan payload, NativeSystemTime timestamp, uint accumulatedPlaySeconds); + ReadTextDatabaseSnapshot? LoadReadText(); + void SaveReadText(ReadTextDatabaseSnapshot snapshot); NativeSaveDocument? LoadNumbered(int slot); void SaveNumbered(int slot, ReadOnlySpan 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)); diff --git a/engine/Age.Engine/Persistence/ReadTextDatabase.cs b/engine/Age.Engine/Persistence/ReadTextDatabase.cs new file mode 100644 index 0000000..276f1dd --- /dev/null +++ b/engine/Age.Engine/Persistence/ReadTextDatabase.cs @@ -0,0 +1,258 @@ +using System.Buffers.Binary; +using System.Collections.ObjectModel; +using System.Text; + +namespace Age.Engine.Persistence; + +/// One native RT.DAT script record and its per-message read flags. +public sealed record ReadTextScriptRecord(uint ScriptId, IReadOnlyList Flags); + +/// +/// Logical contents of AGE's shared RT.DAT. Records are keyed by the raw packed SYS4/AAI script id. +/// +public sealed class ReadTextDatabaseSnapshot +{ + public IReadOnlyDictionary> Records { get; } + + public ReadTextDatabaseSnapshot(IEnumerable? records = null) + { + var values = new Dictionary>(); + foreach (ReadTextScriptRecord record in records ?? Array.Empty()) + { + 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>(values); + } +} + +/// +/// 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. +/// +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>[] 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 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(count); + var seen = new HashSet(); + 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 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 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); + } +} + +/// +/// Profile-lifetime ReadTextDB with AGE's queue-at-advance and commit-at-op-0x71 lifecycle. +/// +public sealed class ReadTextDatabase +{ + private readonly Dictionary _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(); + } +} diff --git a/engine/Age.Engine/Persistence/SharedProfile.cs b/engine/Age.Engine/Persistence/SharedProfile.cs index 7b1b069..3c3a209 100644 --- a/engine/Age.Engine/Persistence/SharedProfile.cs +++ b/engine/Age.Engine/Persistence/SharedProfile.cs @@ -337,8 +337,8 @@ public static class SharedProfilePayloadCodec } /// -/// 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. /// public sealed class SharedProfile { @@ -351,6 +351,9 @@ public sealed class SharedProfile public IReadOnlyDictionary IntegerCells => _integerCells; public IReadOnlyDictionary StringCells => _stringCells; + public ReadTextDatabase ReadText { get; } = new(); + /// The engine setting manipulated by opcodes 0x1ca/0x1cb. + 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(); _integerCells.Clear(); diff --git a/engine/Age.Engine/Sys4/ScriptAssembler.cs b/engine/Age.Engine/Sys4/ScriptAssembler.cs index 8a3698b..c039f6b 100644 --- a/engine/Age.Engine/Sys4/ScriptAssembler.cs +++ b/engine/Age.Engine/Sys4/ScriptAssembler.cs @@ -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(); + 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(); 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); diff --git a/engine/Age.Engine/Sys4/Sys4Loader.cs b/engine/Age.Engine/Sys4/Sys4Loader.cs index 8b25632..1c4962e 100644 --- a/engine/Age.Engine/Sys4/Sys4Loader.cs +++ b/engine/Age.Engine/Sys4/Sys4Loader.cs @@ -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, }; } diff --git a/engine/Age.Engine/Sys4/Sys4ScriptProvider.cs b/engine/Age.Engine/Sys4/Sys4ScriptProvider.cs index 100324d..f38ba95 100644 --- a/engine/Age.Engine/Sys4/Sys4ScriptProvider.cs +++ b/engine/Age.Engine/Sys4/Sys4ScriptProvider.cs @@ -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)); } diff --git a/engine/Age.Engine/Vm/ExecFrame.cs b/engine/Age.Engine/Vm/ExecFrame.cs index 7991041..c0e40a1 100644 --- a/engine/Age.Engine/Vm/ExecFrame.cs +++ b/engine/Age.Engine/Vm/ExecFrame.cs @@ -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 CallStack = new(); // intra-script `call` (op 0x8f) returns public readonly Dictionary EmitSeen = new(); diff --git a/engine/Age.Engine/Vm/GameSession.cs b/engine/Age.Engine/Vm/GameSession.cs index b36d142..8ff74f3 100644 --- a/engine/Age.Engine/Vm/GameSession.cs +++ b/engine/Age.Engine/Vm/GameSession.cs @@ -21,7 +21,7 @@ public sealed class GameSession { public Dictionary Globals { get; } = new(); public Dictionary GlobalStrings { get; } = new(); - /// AGE's selected profile-wide cells and native shared SAVE.DAT lifecycle. + /// AGE's selected profile-wide cells plus native shared SAVE.DAT/RT.DAT lifecycle. public SharedProfile SharedProfile { get; } /// The live retained ADV backlog shared by every VM run in this session. public AdvTextHistory TextHistory { get; } = new(); diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 07625de..89a57c1 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -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 _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 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 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; diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 552f6a2..44b945b 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -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"