From bf43e90a6314109514d1d6d0db31afdc00b054c1 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 24 Jul 2026 13:40:18 -0400 Subject: [PATCH] Map native persistence formats and opcodes --- docs/engine-re.md | 121 +++++++++++++ docs/opcode-reference.md | 110 +++++++----- docs/phase-a-slice-plan.md | 26 +++ docs/remake-architecture-and-roadmap.md | 7 + vm-map/opcodes.toml | 216 ++++++++++++++---------- 5 files changed, 352 insertions(+), 128 deletions(-) diff --git a/docs/engine-re.md b/docs/engine-re.md index 81f9055..c5ba6ce 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -1601,6 +1601,127 @@ real implementation belongs in the future unified save architecture, where the V `ExecFrame` chain and remember which frame is the resume boundary. This is the same architectural deferral as the already-deferred profile/read-state work, not a reason to invent a seed or offset-specific shortcut. +### Native persistence opcode family and file layouts (resolved 2026-07-24) + +The remaining `SAVE.BIN` family is now mapped. It separates three storage domains rather than exposing one +generic save operation: + +| Opcode | Native operation | +|---|---| +| `0x19e(status,slot)` | write `SAVE%02d.DAT`; truncate/replace after any incompatible-save prompt | +| `0x19f(status,slot)` | load numbered data only, with runtime-frame and history restoration disabled; unused by Himegari's shipped corpus | +| `0x1a0(status,slot,...metadata)` | validate only the fixed header and return date/time plus accumulated playtime | +| `0x1a1(status,slot)` | full numbered load, including active frames and history, then resume through `CALLBACK_LOAD`/`0xae` | +| `0x1a9(cell)` / `0x1aa(cell)` | store/restore a selected string cell in shared `SAVE.DAT` | +| `0x1ab(status,slot)` | delete the numbered `.DAT` and `.STH` pair | +| `0x1ac(status,src,dst)` | copy the numbered `.DAT` and `.STH` pair, replacing destination files | +| `0x1ad()` | select the highest frame included in a numbered save; no file I/O (documented above) | +| `0x1ae(status,slot,surface)` | write `SAVE%02d.STH` from a surface | +| `0x1af(status,slot,surface)` | load `SAVE%02d.STH` into a surface | + +Opcode `0x19d`, adjacent in number and used by CGMODE/ED/HMODE/MMODE, is not persistence: its handler is a +resource/compatibility lookup. It is deliberately excluded rather than named from proximity. The actual +persistence cluster has calls in `SAVE`, `SELSTAGE`, `GAMESTART`, `GAMECLEAR`, `INPUTNAME`, and INIT2. + +**Numbered operation status contracts.** Save/load open failure is `1`; metadata uses `0=valid`, +`1=absent/open failure`, and `2=invalid/incompatible`. Delete/copy attempt both members of the pair and use +`0=both succeeded`, `1=DAT failed but STH succeeded`, `2=STH failed` (the STH result takes precedence). +Thumbnail I/O uses `0=success`, `1=open/create failure`, and `2=codec failure`. Full-load opcode `0x1a1` +expects its caller to pre-seed status zero: on success it starts the resume sequence without rewriting that +operand, while a missing file writes one. + +#### Shared `SAVE.DAT` + +`shared_profile_save@0x40c950` writes `$$SAVE.DAT`, replaces `SAVE.DAT`, and keeps `SAVE.BAK`; it performs +the analogous `$$RT.DAT` → `RT.DAT` / `RT.BAK` update for ReadTextDB. `shared_profile_load@0x40ccd0` +falls back from `SAVE.DAT` to `SAVE.BAK` and loads `RT.DAT` independently. Numbered `.DAT` writes do not +use this backup transaction, but a successful numbered serialization flushes the shared profile too. + +After the common container decode, the shared logical payload is: + +1. a DWORD catalog/compatibility count followed by that many DWORDs; +2. a DWORD integer-entry count, then fixed 16-byte entries `{ascii_key[12], raw_value_u32}`; +3. a DWORD string-entry count and DWORD padded string-blob length, then concatenated + `ascii_key\0value\0` pairs with DWORD padding; +4. for shared save version at least 3.10, a 256-DWORD selector/count table, a DWORD extra-count, and that + many extra DWORDs; +5. a trailing zero DWORD. + +Integer-cell ops `0x1a2`/`0x1a3` use keys `3%08x`; string-cell ops `0x1a9`/`0x1aa` use `5%08x`. In both +cases the hexadecimal portion is the VM lvalue's resolved global-bank index. The string table therefore +is not an incidental settings blob: it is the paired profile-wide selected-cell service for string globals, +with insert-or-assign and empty-on-miss behavior. + +#### Common `.DAT` container + +Both shared and numbered `.DAT` payloads use the same native container. The fixed header is exactly +`0x124` bytes: + +| Offset | Size | Field | +|---:|---:|---| +| `0x000` | 4 | little-endian magic `S3SD` or `S4SD` | +| `0x004` | 4 | compatibility id | +| `0x008` | `0x100` | NUL-terminated game id area | +| `0x108` | `0x10` | Win32 `SYSTEMTIME` | +| `0x118` | 4 | accumulated playtime seconds | +| `0x11c` | 4 | `SaveVersion1` / logical state-layout version | +| `0x120` | 4 | `SaveVersion2` / payload-codec subversion | + +`save_container_read_and_validate_header@0x4306f0` is the metadata-only path used by `0x1a0`. +The full writer/reader are `save_container_encode_and_write@0x42fac0` and +`save_container_read_and_decode@0x42ff80`. Immediately after the header is this exact 20-byte codec frame: + +| Offset | Size | Field | +|---:|---:|---| +| `+0x00` | 4 | encoded DWORD count | +| `+0x04` | 4 | MSB-first CRC-32 of encoded bytes | +| `+0x08` | 4 | reflected CRC-32 of encoded bytes | +| `+0x0c` | 4 | random rolling XOR seed | +| `+0x10` | 4 | random odd multiplier (low 16 bits used) | + +The encoded byte length is `encoded_dword_count * 4`. Each source DWORD is XORed with the current seed; +its high and low 16-bit halves are independently multiplied by the current odd multiplier and stored as two +DWORD products. Per source DWORD the seed advances by `0x0b0b0b0b` and the multiplier by `0x0b02`. +The inverse requires both products to divide exactly, providing another corruption check. The decoded logical +payload itself starts with two more DWORDs: MSB-first and reflected CRC-32 values over all following logical +bytes. + +For `SaveVersion2 < 2`, that checked logical buffer is transformed directly. For version 2 or later, the +buffer is first passed through the native 4 KiB LZSS codec: a zero-filled 4096-byte ring starting at `0xfee`, +groups of eight tokens under an LSB-first flag byte (`1=literal`), and two-byte matches containing a 12-bit +offset plus a four-bit `length-3`. Incompressible data is stored verbatim. The transformed inner buffer begins +with three DWORDs recording original byte length, consumed byte length, and stored byte length, followed by +the compressed/verbatim bytes and native padding. The mapped helpers are `lzss_4k_compress@0x42ed30`, +`lzss_4k_decompress@0x42f050`, `save_payload_expand_multiply_transform@0x42f400`, +`save_payload_inverse_multiply_transform@0x42f4a0`, `crc32_msb_first@0x42f360`, and +`crc32_reflected@0x42f300`. + +#### Numbered logical state and `.STH` + +`context_state_serialize@0x40d320` chooses numbered logical layout 1, 2, or 3 from `SaveVersion1`; +layout 1 retains legacy `SaveVersion2` sublayouts 10 and 20. Layouts 2 and 3 serialize script contexts +`0..save_frame_boundary_index` inclusive (falling back to the current context), clear the terminal frame's +return target, and append `text_history_serialize`. Their frame record is `0x414` bytes (`0x105` DWORDs); +layout 2's frame-zero/fixed prefix is `0x8f8` bytes and layout 3's is `0x5718` bytes. Layout 3's larger +prefix adds a 20,000-byte surface/resource state block and retained graphics-object state; each retained +object record carries its handle plus the native `0xb5`-DWORD object record. The state also carries six +global-bank counts, raw integer banks, packed strings/other banks, and resource registrations (100 in the +modern layouts). The matching `save_data_deserialize_and_begin_restore@0x40fd10` reconstructs those banks, +resources, retained state, history, and—when requested by `0x1a1`—the active frame chain consumed by `0xae`. + +The thumbnail is a separate file, never part of that logical state. Both renderer paths prove `.STH` is an +ordinary bottom-up 24-bit BMP under a nonstandard extension: `BM`, pixel offset `0x36`, a 40-byte info +header, BGR pixels, and four-byte row padding. The handle-based path is +`gfx_surface_write_bmp24_to_handle@0x434bf0`; backend 1 reads back supported D3D surface formats and passes +them to `gfx_surface_write_bmp24_to_path@0x475420`. The load side uses the renderer image decoder. A +compatibility implementation should therefore preserve the paired-file lifecycle and BMP payload rather than +inventing a second save container. + +**1.0 implementation boundary:** reproduce these native binary domains and lifecycle first: shared +`SAVE.DAT`/`SAVE.BAK`, `RT.DAT`/`RT.BAK`, numbered `.DAT`, and paired BMP `.STH`. Keep the ownership behind +a profile/save service so extended mode can later add JSON inspection/export, namespaced mod state, migrations, +or a friendlier editor without changing compatibility-mode opcode semantics or the native import/export path. + ### Opcode `0xae` continues numbered-save stack restoration (2026-07-20) Opcode `0xae` is the load-side rendezvous paired with serialized script-frame state. Its handler, diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 53c387a..8dff8cc 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -1,7 +1,7 @@ # Opcode Reference (generated) -248 opcodes used by Himegari. Source of truth: `vm-map/opcodes.toml`. +249 opcodes used by Himegari. Source of truth: `vm-map/opcodes.toml`. ## adv @@ -467,6 +467,34 @@ record-zero failure. Natural SYSTEM4 boot proves BTANINIT2 -> `$1$AUTORUN.BIN` - - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2: op_0x199_yield_adv_coroutine@0x416440 selects the registered coroutine yield-A or yield-B PC according to ctx+0x6dbc8, saves the current resume offset/state, and redirects the current frame PC. SC0000's x=772 ADV button invokes it; the SO001 tooltip at source x=528 reads Window hide, and the surrounding coroutine calls HIDEWIN.BIN. +### 0x19e `save-numbered-slot` (save-numbered-slot, argc 2) +- **summary:** (status_out)(slot) - write the active native VM/session state to `SAVE%02d.DAT`. The file is truncated/replaced in place after any compatibility-overwrite prompt; successful serialization also flushes shared `SAVE.DAT`/`RT.DAT`. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x19e_save_numbered_slot@0x4278b0 formats SAVE%2.2d.DAT, checks an existing header and prompts before replacing an incompatible file, opens CREATE_ALWAYS, reads set:SaveVersion2 then set:SaveVersion1, and calls context_state_serialize@0x40d320. Corpus: two calls in SAVE.BIN and SELSTAGE.BIN. + +Uses `set:SaveVersion1` and `set:SaveVersion2` to choose the numbered payload layout. Status is 0 on success and 1 on refusal, open/create failure, or serializer failure. Numbered `.DAT` files do not use the shared profile's temp/backup replacement scheme. + +### 0x19f `load-numbered-slot-data-only` (load-numbered-slot-data-only, argc 2) +- **summary:** (status_out)(slot) - decode `SAVE%02d.DAT` without restoring the active script-frame chain or text history. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x19f_load_numbered_slot_data_only@0x427a40 opens SAVE%2.2d.DAT and calls save_data_deserialize_and_begin_restore@0x40fd10(handle, SaveVersion1, SaveVersion2, 0, 0), then decodes protected integer globals. Missing/open failure writes status 1; otherwise the decoder result is returned. Corpus count: 0. + +This is the data-only companion to full-resume opcode 0x1a1. It selects the configured SaveVersion layout and restores serialized state with both runtime/history restore flags clear. Himegari's shipped script corpus does not call it, but it belongs to the shared SYS4 persistence ABI. + +### 0x1a0 `query-numbered-save-metadata` (query-numbered-save-metadata, argc 9) +- **summary:** (status_out)(slot)(year)(month)(day)(hour)(minute)(second)(playtime_seconds) - validate a numbered `.DAT` header and return its timestamp and accumulated playtime. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x1a0_query_numbered_save_metadata@0x427ba0 calls save_container_read_and_validate_header@0x4306f0. It reads SYSTEMTIME WORDs at header +0x108/+0x10a/+0x10e/+0x110/+0x112/+0x114 and DWORD accumulated playtime at +0x118. Corpus: three calls in SAVE.BIN. + +Status 0 means valid metadata was written, 1 means the file could not be opened, and 2 means its native header was invalid or incompatible. Metadata comes from the fixed 0x124-byte S3SD/S4SD container header; no payload decode is needed. + +### 0x1a1 `load-numbered-slot-and-resume` (load-numbered-slot-and-resume, argc 2) +- **summary:** (status_out)(slot) - fully load `SAVE%02d.DAT`, including saved script frames and text history, then resume through `CALLBACK_LOAD.BIN` and opcode 0xae. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x1a1_load_numbered_slot_and_resume@0x427d30 calls save_data_deserialize_and_begin_restore@0x40fd10(handle, SaveVersion1, SaveVersion2, 1, 1), closes the file, and decodes protected integer globals. The loader activates the saved-frame state consumed by op_0xae. Corpus: one call in SAVE.BIN. + +The caller pre-seeds status to zero. A missing/open failure writes 1; success starts the asynchronous native stack-restoration rendezvous and does not overwrite that zero. This is the ordinary load-game path, unlike data-only opcode 0x19f. + ### 0x1a2 `store-shared-profile-int` (store-shared-profile-int, argc 1) - **summary:** 0x1a2 (cell) — snapshot the selected global integer cell into AGE's shared SAVE.DAT profile table. The key is `3%08x`, where the address is resolved through global/local pointer operands; the stored value is the cell's current raw 32-bit value. Insert-or-assign semantics replace an existing entry. - **grounding:** source=investigation, confidence=high @@ -477,10 +505,38 @@ Paired reader 0x1a3 restores the same cell, returning zero when the key is absen ### 0x1a3 `load-shared-profile-int` (load-shared-profile-int, argc 1) - **summary:** 0x1a3 (cell) — restore the selected global integer cell from AGE's shared SAVE.DAT profile table. It resolves the same `3%08x` cell-address key as 0x1a2 and overwrites the operand with the stored raw 32-bit value, or zero when the key is absent. - **grounding:** source=investigation, confidence=high -- **evidence:** Ghidra /v2: op_0x1a3_load_shared_profile_int@0x427e90 resolves operand 1 with vm_operand_lvalue, calls shared_profile_int_lookup@0x4199d0 with the shared-profile object at ctx+0x4d7c, then vm_operand_write stores the result. The wrapper searches the table at object+0x414 = ctx+0x5190 and returns zero on a miss. DATA1 corpus: 73 calls in 12 scripts, 42 local-ptr and 31 global-int. +- **evidence:** Ghidra /v2: op_0x1a3_load_shared_profile_int@0x427e90 resolves operand 1 with vm_operand_lvalue, calls shared_profile_int_lookup@0x4199d0 with the shared-profile object at ctx+0x4d7c, then vm_operand_write stores the result. The wrapper searches the table at object+0x414 = ctx+0x5190 and returns zero on a miss. Current override-aware corpus: 77 calls in 12 scripts. This is the read half of the shared-profile integer service, not a string operation. LOADCONFIG.BIN restores configuration globals with it; GAMESTART/GAMECLEAR and array-pointer call sites restore other selected profile-wide values. Port status (2026-07-20): deliberately unimplemented with 0x1a2 pending the unified shared-profile storage boundary. +### 0x1a9 `store-shared-profile-string` (store-shared-profile-string, argc 1) +- **summary:** (cell) - snapshot the selected global string cell into AGE's shared `SAVE.DAT` profile table under key `5%08x`, replacing any earlier value. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x1a9_store_shared_profile_string@0x42d3e0 fetches operand 1's string, resolves the lvalue cell index, and calls shared_profile_store_string_by_typed_key@0x42d2b0 with type prefix 5. Corpus: 17 calls in GAMECLEAR, INPUTNAME, SAVE, and SELSTAGE. + +This is the string counterpart to integer-store opcode 0x1a2. The value is profile-wide rather than numbered-slot-local and is written by the shared profile lifecycle. + +### 0x1aa `load-shared-profile-string` (load-shared-profile-string, argc 1) +- **summary:** (cell) - restore the selected global string cell from AGE's shared `SAVE.DAT` profile table using key `5%08x`; a missing key yields the native empty-string default. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x1aa_load_shared_profile_string@0x42bd90 resolves operand 1's lvalue index, calls shared_profile_load_string_by_typed_key@0x419ca0 with type prefix 5, and writes the result back through the VM string lvalue. Corpus: seven calls in GAMESTART, INIT2, INPUTNAME, and SAVE. + +This is the paired reader for opcode 0x1a9 and the string counterpart to integer-load opcode 0x1a3. + +### 0x1ab `delete-numbered-save` (delete-numbered-save, argc 2) +- **summary:** (status_out)(slot) - attempt to delete both `SAVE%02d.DAT` and its `SAVE%02d.STH` thumbnail. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x1ab_delete_numbered_save@0x427ed0 formats and calls DeleteFileA for the numbered .DAT and .STH paths with the layered status convention. Corpus: one call in SAVE.BIN. + +Both deletes are attempted. Status is 0 when both succeed, 1 when only the DAT delete fails, and 2 whenever the STH delete fails (taking precedence over a DAT failure). + +### 0x1ac `copy-numbered-save` (copy-numbered-save, argc 3) +- **summary:** (status_out)(source_slot)(destination_slot) - copy both numbered `.DAT` state and `.STH` thumbnail, replacing destination files. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x1ac_copy_numbered_save@0x427fb0 formats source/destination SAVE%2.2d.DAT and SAVE%2.2d.STH paths and invokes CopyFileA with fail-if-exists false. Corpus: two calls in SAVE.BIN. + +Both copies are attempted with overwrite allowed. Status is 0 when both succeed, 1 when only the DAT copy fails, and 2 whenever the STH copy fails (taking precedence over a DAT failure). + ### 0x1ad `mark-save-resume-frame` (mark-save-resume-frame, argc 0) - **summary:** Mark the current script context as the highest frame serialized by numbered-save layouts 2/3. The native serializer saves frames 0 through this boundary and strips the boundary frame's return target so loading resumes it as the top frame. This opcode performs no file I/O itself. - **grounding:** source=investigation, confidence=high @@ -529,6 +585,20 @@ The handler uses an alpha step of 1 and timer interval=argument when argument <= The five-dword definition is stored at EngineCtx+0x55180+style_index*0x14. Opcode 0x23b consumes it to turn an integer into retained draw objects, one atlas cell per decimal digit. An index outside [0,10] raises the engine's script error. +### 0x1ae `write-numbered-save-thumbnail` (write-numbered-save-thumbnail, argc 3) +- **summary:** (status_out)(slot)(surface_slot) - encode the selected surface into the numbered save's separate `SAVE%02d.STH` thumbnail file. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x1ae_write_numbered_save_thumbnail@0x428100 creates SAVE%2.2d.STH with CREATE_ALWAYS and serializes operand 3's surface via the active renderer backend. Corpus: two calls in SAVE.BIN and SELSTAGE.BIN. + +Status is 0 on success, 1 when the file cannot be created/opened, and 2 when surface encoding or writing fails. Renderer backend selects a handle-based or path-based native worker. + +### 0x1af `load-numbered-save-thumbnail` (load-numbered-save-thumbnail, argc 3) +- **summary:** (status_out)(slot)(surface_slot) - decode the numbered save's separate `SAVE%02d.STH` thumbnail into a surface slot. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x1af_load_numbered_save_thumbnail@0x428240 opens SAVE%2.2d.STH and passes it plus operand 3's surface slot to the active renderer decoder. Corpus: one call in SAVE.BIN. + +Status is 0 on success, 1 when the file cannot be opened, and 2 when image decoding fails. The thumbnail format is owned by the renderer codec and is not embedded in the numbered `.DAT` payload. + ### 0x1f6 `clear-retained-gfx-objects` (clear-retained-gfx-objects, argc 0) - **summary:** Clear the complete retained gfx-object registry while preserving allocated surface resources. Subsequent object queries return absent until draw/geometry operations recreate records. - **grounding:** source=investigation, confidence=high @@ -1239,18 +1309,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it " - **summary:** — - **grounding:** source=kelebek, confidence=low -### 0x19e `u0041C6E0` (u0041C6E0, argc 2) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - -### 0x1a0 `u0041C9B0` (u0041C9B0, argc 9) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - -### 0x1a1 `u0041CB40` (u0041CB40, argc 2) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x1a5 `set-font` (set-font, argc 1) - **summary:** — - **grounding:** source=kelebek, confidence=med @@ -1259,30 +1317,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it " - **summary:** — - **grounding:** source=kelebek, confidence=med -### 0x1a9 `u00428090` (u00428090, argc 1) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - -### 0x1aa `u00425920` (u00425920, argc 1) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - -### 0x1ab `u0041CCA0` (u0041CCA0, argc 2) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - -### 0x1ac `u0041CD80` (u0041CD80, argc 3) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - -### 0x1ae `u0041CED0` (u0041CED0, argc 3) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - -### 0x1af `u004245C0` (u004245C0, argc 3) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x1b2 `u00425790` (u00425790, argc 1) - **summary:** — - **grounding:** source=kelebek, confidence=low diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index 6076008..23ab53b 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -3329,6 +3329,32 @@ shared-profile registration. `G[0x3ebe]` (21 references across CALCREVISE, CHMENU, DRAWTIP, GAMECLEAR, GAMESTART, IMPROVE, and TUNE). +## Persistence native-format reconnaissance complete (2026-07-24) + +The deferred save/profile ownership question now has a compatibility-mode answer. The remaining native +opcode family is mapped: `0x19e` numbered save, unused data-only load `0x19f`, metadata query `0x1a0`, +full load/resume `0x1a1`, shared string cells `0x1a9`/`0x1aa`, pair delete/copy +`0x1ab`/`0x1ac`, resume-frame marker `0x1ad`, and thumbnail write/load `0x1ae`/`0x1af`. +Adjacent `0x19d` is unrelated resource/compatibility logic and is not classified by proximity. + +The native persistence domains and their lifecycle are structurally complete enough for implementation: +shared `SAVE.DAT` uses atomic temp/backup replacement and stores both typed integer and string selected-cell +tables; `RT.DAT` remains the independent ReadTextDB file; numbered state uses `SAVE##.DAT` plus a separate +BMP-formatted `SAVE##.STH`. The common `.DAT` codec has a fixed `0x124`-byte S3SD/S4SD header, a mapped +20-byte length/dual-CRC/seed/multiplier frame, reversible per-DWORD expansion, and version-2 4 KiB LZSS. +Numbered logical layouts 1/2/3, active-frame cutoff/restoration, history, global banks, resources, and the +layout-3 retained surface/object state are now bounded in `docs/engine-re.md`. + +**Decision:** implement the native binary formats and paired-file behavior for the 1.0 compatibility pass. +Keep them behind a profile/save service so JSON inspection/export, migrations, and mod-owned namespaced state +can arrive later as extended-mode additions. This supersedes the earlier open choice between native storage +and a port-owned replacement; it does not authorize folding profile-selected cells into the existing +whole-bank JSON session snapshot. + +No runtime persistence code changed in this reconnaissance slice. The canonical opcode source and generated +references now describe the native ABI; the corresponding `/v2` handlers and codec helpers are named, +commented, and saved. + ## 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 947f235..9b8899f 100644 --- a/docs/remake-architecture-and-roadmap.md +++ b/docs/remake-architecture-and-roadmap.md @@ -461,6 +461,13 @@ the entry condition. - Side-tasks absorbed here: `STINIT` parser, remaining data schemas, save-file format (needed for a real playthrough — reversible struct work). +**Persistence compatibility floor (decided 2026-07-24):** the 1.0 path uses AGE's native binary +domains and lifecycle—shared `SAVE.DAT`/`SAVE.BAK`, `RT.DAT`/`RT.BAK`, numbered `SAVE##.DAT`, and +paired BMP `.STH` thumbnails. Keep the codec behind a profile/save-service boundary. Human-readable +JSON inspection/export, migrations, and namespaced mod state are additive extended-mode work, not a +replacement for compatibility-mode import/export. The recovered native contract lives in +`docs/engine-re.md`. + ### Phase C — Externalize & modding foundation - Add **editable named data overlays** mapped explicitly onto the VM's `*INIT`-produced state; external files must not become an unsynchronized second source of truth. diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 5aa4276..26426af 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -3374,115 +3374,145 @@ observed_types = ["imm", "l-ptr"] [[opcode]] op = 0x19e -label = "u0041C6E0" +label = "save-numbered-slot" argc = 2 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u0041C6E0" -category = "unknown" -summary = "" +name = "save-numbered-slot" +category = "control" +summary = "(status_out)(slot) - write the active native VM/session state to `SAVE%02d.DAT`. The file is truncated/replaced in place after any compatibility-overwrite prompt; successful serialization also flushes shared `SAVE.DAT`/`RT.DAT`." +details = "Uses `set:SaveVersion1` and `set:SaveVersion2` to choose the numbered payload layout. Status is 0 on success and 1 on refusal, open/create failure, or serializer failure. Numbered `.DAT` files do not use the shared profile's temp/backup replacement scheme." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x19e_save_numbered_slot@0x4278b0 formats SAVE%2.2d.DAT, checks an existing header and prompts before replacing an incompatible file, opens CREATE_ALWAYS, reads set:SaveVersion2 then set:SaveVersion1, and calls context_state_serialize@0x40d320. Corpus: two calls in SAVE.BIN and SELSTAGE.BIN." [[opcode.semantics.args]] i = 1 -role = "" +role = "status output" observed_types = ["g-int", "l-int"] [[opcode.semantics.args]] i = 2 -role = "" +role = "numbered slot" observed_types = ["g-int", "l-int"] +[[opcode]] +op = 0x19f +label = "load-numbered-slot-data-only" +argc = 2 +abi_source = "kelebek" + +[opcode.semantics] +name = "load-numbered-slot-data-only" +category = "control" +summary = "(status_out)(slot) - decode `SAVE%02d.DAT` without restoring the active script-frame chain or text history." +details = "This is the data-only companion to full-resume opcode 0x1a1. It selects the configured SaveVersion layout and restores serialized state with both runtime/history restore flags clear. Himegari's shipped script corpus does not call it, but it belongs to the shared SYS4 persistence ABI." +noop_headless = false +source = "investigation" +confidence = "high" +depends_on = [] +evidence = "Ghidra /v2: op_0x19f_load_numbered_slot_data_only@0x427a40 opens SAVE%2.2d.DAT and calls save_data_deserialize_and_begin_restore@0x40fd10(handle, SaveVersion1, SaveVersion2, 0, 0), then decodes protected integer globals. Missing/open failure writes status 1; otherwise the decoder result is returned. Corpus count: 0." + +[[opcode.semantics.args]] +i = 1 +role = "status output" +observed_types = ["l-int"] + +[[opcode.semantics.args]] +i = 2 +role = "numbered slot" +observed_types = ["l-int"] + [[opcode]] op = 0x1a0 -label = "u0041C9B0" +label = "query-numbered-save-metadata" argc = 9 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u0041C9B0" -category = "unknown" -summary = "" +name = "query-numbered-save-metadata" +category = "control" +summary = "(status_out)(slot)(year)(month)(day)(hour)(minute)(second)(playtime_seconds) - validate a numbered `.DAT` header and return its timestamp and accumulated playtime." +details = "Status 0 means valid metadata was written, 1 means the file could not be opened, and 2 means its native header was invalid or incompatible. Metadata comes from the fixed 0x124-byte S3SD/S4SD container header; no payload decode is needed." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x1a0_query_numbered_save_metadata@0x427ba0 calls save_container_read_and_validate_header@0x4306f0. It reads SYSTEMTIME WORDs at header +0x108/+0x10a/+0x10e/+0x110/+0x112/+0x114 and DWORD accumulated playtime at +0x118. Corpus: three calls in SAVE.BIN." [[opcode.semantics.args]] i = 1 -role = "" +role = "status output" observed_types = ["l-int"] [[opcode.semantics.args]] i = 2 -role = "" +role = "numbered slot" observed_types = ["l-int"] [[opcode.semantics.args]] i = 3 -role = "" +role = "year output" observed_types = ["l-int"] [[opcode.semantics.args]] i = 4 -role = "" +role = "month output" observed_types = ["l-int"] [[opcode.semantics.args]] i = 5 -role = "" +role = "day output" observed_types = ["l-int"] [[opcode.semantics.args]] i = 6 -role = "" +role = "hour output" observed_types = ["l-int"] [[opcode.semantics.args]] i = 7 -role = "" +role = "minute output" observed_types = ["l-int"] [[opcode.semantics.args]] i = 8 -role = "" +role = "second output" observed_types = ["l-int"] [[opcode.semantics.args]] i = 9 -role = "" +role = "accumulated playtime seconds output" observed_types = ["l-int"] [[opcode]] op = 0x1a1 -label = "u0041CB40" +label = "load-numbered-slot-and-resume" argc = 2 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u0041CB40" -category = "unknown" -summary = "" +name = "load-numbered-slot-and-resume" +category = "control" +summary = "(status_out)(slot) - fully load `SAVE%02d.DAT`, including saved script frames and text history, then resume through `CALLBACK_LOAD.BIN` and opcode 0xae." +details = "The caller pre-seeds status to zero. A missing/open failure writes 1; success starts the asynchronous native stack-restoration rendezvous and does not overwrite that zero. This is the ordinary load-game path, unlike data-only opcode 0x19f." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x1a1_load_numbered_slot_and_resume@0x427d30 calls save_data_deserialize_and_begin_restore@0x40fd10(handle, SaveVersion1, SaveVersion2, 1, 1), closes the file, and decodes protected integer globals. The loader activates the saved-frame state consumed by op_0xae. Corpus: one call in SAVE.BIN." [[opcode.semantics.args]] i = 1 -role = "" +role = "status output" observed_types = ["l-int"] [[opcode.semantics.args]] i = 2 -role = "" +role = "numbered slot" observed_types = ["l-int"] [[opcode]] @@ -3522,7 +3552,7 @@ noop_headless = false source = "investigation" confidence = "high" depends_on = [] -evidence = "Ghidra /v2: op_0x1a3_load_shared_profile_int@0x427e90 resolves operand 1 with vm_operand_lvalue, calls shared_profile_int_lookup@0x4199d0 with the shared-profile object at ctx+0x4d7c, then vm_operand_write stores the result. The wrapper searches the table at object+0x414 = ctx+0x5190 and returns zero on a miss. DATA1 corpus: 73 calls in 12 scripts, 42 local-ptr and 31 global-int." +evidence = "Ghidra /v2: op_0x1a3_load_shared_profile_int@0x427e90 resolves operand 1 with vm_operand_lvalue, calls shared_profile_int_lookup@0x4199d0 with the shared-profile object at ctx+0x4d7c, then vm_operand_write stores the result. The wrapper searches the table at object+0x414 = ctx+0x5190 and returns zero on a miss. Current override-aware corpus: 77 calls in 12 scripts." [[opcode.semantics.args]] i = 1 @@ -3642,101 +3672,105 @@ evidence = "Ghidra /v2: op_0xaf_0x1a8_handler@0x416650 writes frame_instruction_ [[opcode]] op = 0x1a9 -label = "u00428090" +label = "store-shared-profile-string" argc = 1 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00428090" -category = "unknown" -summary = "" +name = "store-shared-profile-string" +category = "control" +summary = "(cell) - snapshot the selected global string cell into AGE's shared `SAVE.DAT` profile table under key `5%08x`, replacing any earlier value." +details = "This is the string counterpart to integer-store opcode 0x1a2. The value is profile-wide rather than numbered-slot-local and is written by the shared profile lifecycle." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x1a9_store_shared_profile_string@0x42d3e0 fetches operand 1's string, resolves the lvalue cell index, and calls shared_profile_store_string_by_typed_key@0x42d2b0 with type prefix 5. Corpus: 17 calls in GAMECLEAR, INPUTNAME, SAVE, and SELSTAGE." [[opcode.semantics.args]] i = 1 -role = "" +role = "global string cell to snapshot" observed_types = ["g-str", "l-str-ptr"] [[opcode]] op = 0x1aa -label = "u00425920" +label = "load-shared-profile-string" argc = 1 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00425920" -category = "unknown" -summary = "" +name = "load-shared-profile-string" +category = "control" +summary = "(cell) - restore the selected global string cell from AGE's shared `SAVE.DAT` profile table using key `5%08x`; a missing key yields the native empty-string default." +details = "This is the paired reader for opcode 0x1a9 and the string counterpart to integer-load opcode 0x1a3." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x1aa_load_shared_profile_string@0x42bd90 resolves operand 1's lvalue index, calls shared_profile_load_string_by_typed_key@0x419ca0 with type prefix 5, and writes the result back through the VM string lvalue. Corpus: seven calls in GAMESTART, INIT2, INPUTNAME, and SAVE." [[opcode.semantics.args]] i = 1 -role = "" +role = "global string cell to restore" observed_types = ["g-str", "l-str-ptr"] [[opcode]] op = 0x1ab -label = "u0041CCA0" +label = "delete-numbered-save" argc = 2 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u0041CCA0" -category = "unknown" -summary = "" +name = "delete-numbered-save" +category = "control" +summary = "(status_out)(slot) - attempt to delete both `SAVE%02d.DAT` and its `SAVE%02d.STH` thumbnail." +details = "Both deletes are attempted. Status is 0 when both succeed, 1 when only the DAT delete fails, and 2 whenever the STH delete fails (taking precedence over a DAT failure)." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x1ab_delete_numbered_save@0x427ed0 formats and calls DeleteFileA for the numbered .DAT and .STH paths with the layered status convention. Corpus: one call in SAVE.BIN." [[opcode.semantics.args]] i = 1 -role = "" +role = "status output" observed_types = ["l-int"] [[opcode.semantics.args]] i = 2 -role = "" +role = "numbered slot" observed_types = ["l-int"] [[opcode]] op = 0x1ac -label = "u0041CD80" +label = "copy-numbered-save" argc = 3 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u0041CD80" -category = "unknown" -summary = "" +name = "copy-numbered-save" +category = "control" +summary = "(status_out)(source_slot)(destination_slot) - copy both numbered `.DAT` state and `.STH` thumbnail, replacing destination files." +details = "Both copies are attempted with overwrite allowed. Status is 0 when both succeed, 1 when only the DAT copy fails, and 2 whenever the STH copy fails (taking precedence over a DAT failure)." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x1ac_copy_numbered_save@0x427fb0 formats source/destination SAVE%2.2d.DAT and SAVE%2.2d.STH paths and invokes CopyFileA with fail-if-exists false. Corpus: two calls in SAVE.BIN." [[opcode.semantics.args]] i = 1 -role = "" +role = "status output" observed_types = ["l-int"] [[opcode.semantics.args]] i = 2 -role = "" +role = "source numbered slot" observed_types = ["l-int"] [[opcode.semantics.args]] i = 3 -role = "" +role = "destination numbered slot" observed_types = ["l-int"] [[opcode]] @@ -3757,64 +3791,66 @@ evidence = "Ghidra /v2: op_0x1ad_mark_save_resume_frame@0x416b70 writes decoded [[opcode]] op = 0x1ae -label = "u0041CED0" +label = "write-numbered-save-thumbnail" argc = 3 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u0041CED0" -category = "unknown" -summary = "" +name = "write-numbered-save-thumbnail" +category = "draw" +summary = "(status_out)(slot)(surface_slot) - encode the selected surface into the numbered save's separate `SAVE%02d.STH` thumbnail file." +details = "Status is 0 on success, 1 when the file cannot be created/opened, and 2 when surface encoding or writing fails. Renderer backend selects a handle-based or path-based native worker." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x1ae_write_numbered_save_thumbnail@0x428100 creates SAVE%2.2d.STH with CREATE_ALWAYS and serializes operand 3's surface via the active renderer backend. Corpus: two calls in SAVE.BIN and SELSTAGE.BIN." [[opcode.semantics.args]] i = 1 -role = "" +role = "status output" observed_types = ["g-int", "l-int"] [[opcode.semantics.args]] i = 2 -role = "" +role = "numbered slot" observed_types = ["g-int", "l-int"] [[opcode.semantics.args]] i = 3 -role = "" +role = "source surface slot" observed_types = ["imm"] [[opcode]] op = 0x1af -label = "u004245C0" +label = "load-numbered-save-thumbnail" argc = 3 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u004245C0" -category = "unknown" -summary = "" +name = "load-numbered-save-thumbnail" +category = "draw" +summary = "(status_out)(slot)(surface_slot) - decode the numbered save's separate `SAVE%02d.STH` thumbnail into a surface slot." +details = "Status is 0 on success, 1 when the file cannot be opened, and 2 when image decoding fails. The thumbnail format is owned by the renderer codec and is not embedded in the numbered `.DAT` payload." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x1af_load_numbered_save_thumbnail@0x428240 opens SAVE%2.2d.STH and passes it plus operand 3's surface slot to the active renderer decoder. Corpus: one call in SAVE.BIN." [[opcode.semantics.args]] i = 1 -role = "" +role = "status output" observed_types = ["l-int"] [[opcode.semantics.args]] i = 2 -role = "" +role = "numbered slot" observed_types = ["l-int"] [[opcode.semantics.args]] i = 3 -role = "" +role = "destination surface slot" observed_types = ["l-int"] [[opcode]]