Implement numbered save pair lifecycle

This commit is contained in:
gamer147
2026-07-24 15:32:55 -04:00
parent bda586aa28
commit 3b63c42826
16 changed files with 730 additions and 38 deletions

View File

@@ -104,7 +104,7 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings)
│ ├── Age.Engine/Sys4/ runtime catalog parser, loose-first bounded ALF asset store, │ ├── Age.Engine/Sys4/ runtime catalog parser, loose-first bounded ALF asset store,
│ script provider, AGF/LZSS and Windows CUR decoders, and resource facade │ script provider, AGF/LZSS and Windows CUR decoders, and resource facade
│ └── Age.Engine/Persistence/ native S3SD/S4SD + S3RT codecs, shared payload/ReadTextDB, │ └── Age.Engine/Persistence/ native S3SD/S4SD + S3RT codecs, shared payload/ReadTextDB,
profile-owned state, and shared/numbered DAT lifecycle seam numbered DAT/STH pair + BMP codec, and profile-owned state
├── native/ authored native runtime boundaries ├── native/ authored native runtime boundaries
│ └── age_movie_ffmpeg/ project-owned FFmpeg C ABI, immutable Windows dependency manifest, │ └── age_movie_ffmpeg/ project-owned FFmpeg C ABI, immutable Windows dependency manifest,
│ and bootstrap/build scripts (outputs stay under disposable build/) │ and bootstrap/build scripts (outputs stay under disposable build/)

View File

@@ -1610,12 +1610,11 @@ Corpus placement agrees with the native dataflow: 1,928 executions appear across
return paths from `HISTORY`, `MENU`, `HIDEWIN`, and `INPUTNAME`. Those calls re-establish the enclosing ADV return paths from `HISTORY`, `MENU`, `HIDEWIN`, and `INPUTNAME`. Those calls re-establish the enclosing ADV
frame as the safe numbered-save resume point after modal/nested scripts finish. frame as the safe numbered-save resume point after modal/nested scripts finish.
**Port implication:** the current port-owned JSON session snapshot persists only global integer/string banks **Port implication:** the port-owned JSON session snapshot still persists only global integer/string banks,
and deliberately has no active-frame or numbered-save backend. Treating `0x1ad` as a no-op is behaviorally but the VM now tracks `0x1ad` as an identity reference to the active `ExecFrame`. The marker survives nested
neutral only under that present limitation; counting it as faithfully implemented would be misleading. Its calls and clears when that same frame unwinds, exposing the inclusive zero-based cutoff for the native
real implementation belongs in the future unified save architecture, where the VM must serialize the active serializer. The following numbered-payload slice must consume this boundary while serializing the frame
`ExecFrame` chain and remember which frame is the resume boundary. This is the same architectural deferral as chain; no script seed or offset-specific shortcut is involved.
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) ### Native persistence opcode family and file layouts (resolved 2026-07-24)
@@ -1653,6 +1652,11 @@ Save paths are native engine policy, not script-provided strings. Every numbered
`SAVE%2.2d.STH`, `SAVE.DAT`, `SAVE.BAK`, `RT.DAT`, and their `$$` temporary names) under that root. `SAVE%2.2d.STH`, `SAVE.DAT`, `SAVE.BAK`, `RT.DAT`, and their `$$` temporary names) under that root.
The script operands select the operation and numbered slot only. The script operands select the operation and numbered slot only.
The port preserves that ownership boundary while intercepting the root: Godot supplies `user://SAVE`, and
`DirectoryNativeDatStore` owns the fixed native names beneath it. This isolates authored port saves from the
original installation while retaining compatible file structure; another root can be injected without
changing script semantics.
The resolver reads two per-game settings from the SYS4INI-backed settings registry: The resolver reads two per-game settings from the SYS4INI-backed settings registry:
`set:UseAppDataFolder` and `set:SavePath`. When `UseAppDataFolder == 1`, the modern-Windows branch `set:UseAppDataFolder` and `set:SavePath`. When `UseAppDataFolder == 1`, the modern-Windows branch
loads `SHGetFolderPathA` and requests `CSIDL_LOCAL_APPDATA | CSIDL_FLAG_CREATE` (`0x801c`); it then loads `SHGetFolderPathA` and requests `CSIDL_LOCAL_APPDATA | CSIDL_FLAG_CREATE` (`0x801c`); it then
@@ -1719,6 +1723,10 @@ Both shared and numbered `.DAT` payloads use the same native container. The fixe
| `0x11c` | 4 | `SaveVersion1` / logical state-layout version | | `0x11c` | 4 | `SaveVersion1` / logical state-layout version |
| `0x120` | 4 | `SaveVersion2` / payload-codec subversion | | `0x120` | 4 | `SaveVersion2` / payload-codec subversion |
The wrapper algorithm is shared, but Himegari's identities are domain-specific: shared `SAVE.DAT` and
`RT.DAT` use compatibility id `0x4a343234`, while installed numbered `SAVE##.DAT` files use
`0x42323234`. Both carry game id `姫狩りダンジョンマイスター` and versions 3.10.
`save_container_read_and_validate_header@0x4306f0` is the metadata-only path used by `0x1a0`. `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 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: `save_container_read_and_decode@0x42ff80`. Immediately after the header is this exact 20-byte codec frame:
@@ -1763,7 +1771,9 @@ resources, retained state, history, and—when requested by `0x1a1`—the active
The thumbnail is a separate file, never part of that logical state. Both renderer paths prove `.STH` is an 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 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 header, BGR pixels, and four-byte row padding. Installed Himegari thumbnails are 112x84 and 28,278 bytes.
The native writer records `bfSize=28,264`—DIB header plus pixel bytes, omitting the 14-byte BMP file header
even though that header and the `0x36` pixel offset are physically present. The handle-based path is
`gfx_surface_write_bmp24_to_handle@0x434bf0`; backend 1 reads back supported D3D surface formats and passes `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 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 compatibility implementation should therefore preserve the paired-file lifecycle and BMP payload rather than
@@ -1774,7 +1784,7 @@ inventing a second save container.
a profile/save service so extended mode can later add JSON inspection/export, namespaced mod state, migrations, 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. or a friendlier editor without changing compatibility-mode opcode semantics or the native import/export path.
**Port correspondence (2026-07-24, shared payload and selected cells implemented):** **Port correspondence (2026-07-24, numbered pair/thumbnail layer implemented):**
`Age.Engine.Persistence.NativeSaveContainerCodec` now reads and writes the common header, Shift-JIS game id, `Age.Engine.Persistence.NativeSaveContainerCodec` now reads and writes the common header, Shift-JIS game id,
SYSTEMTIME/playtime/version metadata, both CRC layers, version-2 compression wrapper, and exact reversible SYSTEMTIME/playtime/version metadata, both CRC layers, version-2 compression wrapper, and exact reversible
DWORD transform. `Sys4.LzssEncoder` emits the same 4 KiB-ring token dialect already consumed by DWORD transform. `Sys4.LzssEncoder` emits the same 4 KiB-ring token dialect already consumed by
@@ -1786,8 +1796,13 @@ 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 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 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 `0x1a9`/`0x1aa` now implement native upsert and missing-value defaults for direct cells and resolved
global pointers. `RT.DAT` is now implemented as the separate S3RT layer described below. Thumbnails and global pointers. `RT.DAT` is now implemented as the separate S3RT layer described below. The numbered store
numbered active-frame payloads remain later layers; the existing `GameSession` JSON snapshot is unchanged. validates its distinct compatibility id, queries metadata without decoding payloads, and performs exact
paired `.DAT`/`.STH` copy/delete status layering. `NumberedThumbnailCodec` reads and writes the native BMP
dialect through host surface capture/replacement, and ops `0x1a0`, `0x1ab``0x1af` (including `0x1ad`'s
frame marker) are wired. Godot injects the store at `user://SAVE`. Full layout-3 global/frame/history/
resource/retained-gfx serialization and the `0x19e`/`0x1a1`/`0xae` save-resume path remain the next layer;
the existing `GameSession` JSON snapshot is unchanged.
### Opcode `0xae` continues numbered-save stack restoration (2026-07-20) ### Opcode `0xae` continues numbered-save stack restoration (2026-07-20)

View File

@@ -490,7 +490,7 @@ This is the data-only companion to full-resume opcode 0x1a1. It selects the conf
- **grounding:** source=investigation, confidence=high - **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. - **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. 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. Himegari numbered files use compatibility id 0x42323234, distinct from shared SAVE.DAT/RT.DAT id 0x4a343234. Port status (2026-07-24): implemented through the native directory store with fixed-header-only reads.
### 0x1a1 `load-numbered-slot-and-resume` (load-numbered-slot-and-resume, argc 2) ### 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. - **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.
@@ -532,20 +532,22 @@ This is the paired reader for opcode 0x1a9 and the string counterpart to integer
- **grounding:** source=investigation, confidence=high - **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. - **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). 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). Port status (2026-07-24): implemented against the paired native filenames.
### 0x1ac `copy-numbered-save` (copy-numbered-save, argc 3) ### 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. - **summary:** (status_out)(source_slot)(destination_slot) - copy both numbered `.DAT` state and `.STH` thumbnail, replacing destination files.
- **grounding:** source=investigation, confidence=high - **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. - **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). 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). Port status (2026-07-24): implemented against the paired native filenames.
### 0x1ad `mark-save-resume-frame` (mark-save-resume-frame, argc 0) ### 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. - **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 - **grounding:** source=investigation, confidence=high
- **evidence:** Ghidra /v2: op_0x1ad_mark_save_resume_frame@0x416b70 writes decoded instruction size 1 and ctx+0x9928c=cur_ctx_index. context_state_serialize@0x40d320 uses that field (or cur_ctx_index when -1) as the inclusive frame cutoff for save layouts 2/3, serializes frames 0..cutoff, and forces the cutoff frame's saved return entry to -1. op_0x2_exit_or_return_frame@0x417940 clears the mark when unwinding below it. Corpus: 1,928 calls in 304 scripts; SC0000's six calls are at startup and immediately after HISTORY/MENU/HIDEWIN/INPUTNAME returns. - **evidence:** Ghidra /v2: op_0x1ad_mark_save_resume_frame@0x416b70 writes decoded instruction size 1 and ctx+0x9928c=cur_ctx_index. context_state_serialize@0x40d320 uses that field (or cur_ctx_index when -1) as the inclusive frame cutoff for save layouts 2/3, serializes frames 0..cutoff, and forces the cutoff frame's saved return entry to -1. op_0x2_exit_or_return_frame@0x417940 clears the mark when unwinding below it. Corpus: 1,928 calls in 304 scripts; SC0000's six calls are at startup and immediately after HISTORY/MENU/HIDEWIN/INPUTNAME returns.
Port status (2026-07-24): implemented as an active ExecFrame identity marker. It survives nested calls and clears when its owning frame unwinds; the following full numbered-payload slice will consume the exposed zero-based cutoff.
### 0x1cc `get-adv-read-skip-state` (get-adv-read-skip-state, argc 1) ### 0x1cc `get-adv-read-skip-state` (get-adv-read-skip-state, argc 1)
- **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. - **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.
- **grounding:** source=investigation, confidence=high - **grounding:** source=investigation, confidence=high
@@ -596,14 +598,14 @@ The five-dword definition is stored at EngineCtx+0x55180+style_index*0x14. Opcod
- **grounding:** source=investigation, confidence=high - **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. - **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. 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. Himegari writes an ordinary bottom-up 24-bit BMP (112x84 in installed files), BGR rows with four-byte padding, under the .STH extension. Its bfSize field historically omits the 14-byte BITMAPFILEHEADER. Port status (2026-07-24): implemented with exact native BMP output and host surface capture.
### 0x1af `load-numbered-save-thumbnail` (load-numbered-save-thumbnail, argc 3) ### 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. - **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 - **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. - **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. 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. Port status (2026-07-24): implemented with 24-bit BMP decode and host surface replacement.
### 0x1f6 `clear-retained-gfx-objects` (clear-retained-gfx-objects, argc 0) ### 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. - **summary:** Clear the complete retained gfx-object registry while preserving allocated surface resources. Subsequent object queries return absent until draw/geometry operations recreate records.

View File

@@ -3426,14 +3426,48 @@ current per-message state. Six focused tests cover exact bytes, malformed files,
the installed native oracle, queue/growth behavior, paired filesystem transactions, and fresh-VM opcode the installed native oracle, queue/growth behavior, paired filesystem transactions, and fresh-VM opcode
continuity. continuity.
**Next persistence step:** implement numbered active-frame SAVE##.DAT layouts and paired BMP `.STH` **Next persistence step:** implement the numbered pair/thumbnail/frame-boundary layer, then use that proven
thumbnail lifecycle. JSON inspection/export and mod-owned namespaced state remain extended-mode work. surface to implement the larger layout-3 active-frame/global payload. 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; 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 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 handled (98.4%); its only remaining effectful gaps are numbered-save restore boundaries `0x1ad`×6 and
`0xae`×1. `0xae`×1.
### Persistence implementation step 4 — numbered pair, metadata, thumbnail, and frame boundary (2026-07-24)
The bounded outer numbered-save lifecycle is now implemented before the much larger logical state payload.
Himegari's numbered `.DAT` compatibility id is `0x42323234`, distinct from shared `SAVE.DAT`/`RT.DAT`
id `0x4a343234`; `NativeSaveIdentity` and the directory store now validate those domains independently.
Opcode `0x1a0` reads and validates only the fixed header, while `0x1ab`/`0x1ac` attempt both native
`SAVE%02d.DAT` and `SAVE%02d.STH` members and reproduce DAT-failure/STH-precedence statuses.
`NumberedThumbnailCodec` reproduces the installed native `.STH` dialect: bottom-up 24-bit BGR BMP,
four-byte row padding, 112x84 installed dimensions, and the historical `bfSize` value that omits the
physically present 14-byte file header. Host capture/replacement seams connect this to Godot surfaces;
`0x1ae` and `0x1af` expose the native status contract. Godot redirects the engine-owned save root to
isolated `user://SAVE` while retaining the native names and formats, so it never mutates the original
installation's AppData saves.
Opcode `0x1ad` now marks the current `ExecFrame` by identity. The boundary survives nested calls and clears
when its owning frame unwinds, matching AGE's single context-index marker and providing the exact cutoff
the next serializer slice needs. It is no longer counted as a no-op, but this step deliberately does not
claim load-compatible numbered `.DAT` bodies: layout-3's fixed state, six global banks, active frames,
history, resources, surfaces, and retained graphics remain to be serialized together.
Seven focused tests cover exact BMP bytes and round-trip, the installed read-only thumbnail oracle,
header-only metadata, separate compatibility ids, pair failure precedence, VM opcode integration, and both
resume-marker unwind cases.
Validation: all 377 engine tests pass; the Godot C# build has zero warnings and the threaded headless
self-test reports `SELFTEST OK`. SC0000 now has 128/129 distinct opcodes handled (99.2%); only the active
numbered-load restoration rendezvous `0xae` remains effectful and unimplemented there.
**Next persistence step:** implement complete numbered logical layout 3 and wire `0x19e`, `0x1a1`, and
the active branch of `0xae`; retain `0x19f` as the mapped but corpus-unused data-only load path. JSON
inspection/export remains an additive extended-mode feature.
## Data-semantics sidebar: focused append EBINIT inspection (2026-07-24) ## 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. The static INIT surface now accepts a universal packed script id for focused append inspection.

View File

@@ -15,6 +15,8 @@ The VM and content pipeline are already mostly platform-neutral:
- Effectful bytecode operations cross `Hosting/IHost.cs`; the VM does not call native OS APIs. - Effectful bytecode operations cross `Hosting/IHost.cs`; the VM does not call native OS APIs.
- Movie payloads arrive from `IAssetStore` as owned bytes and decoded frames enter the compositor as the - Movie payloads arrive from `IAssetStore` as owned bytes and decoded frames enter the compositor as the
platform-neutral `RgbaImage` type. platform-neutral `RgbaImage` type.
- Native-compatible persistence uses managed streams behind `INativeDatStore`; Godot redirects AGE's
engine-owned root to `user://SAVE` while retaining its fixed portable DAT/STH filenames.
The selected movie path now uses the project-owned FFmpeg C ABI rather than a Windows multimedia API, but only a The selected movie path now uses the project-owned FFmpeg C ABI rather than a Windows multimedia API, but only a
Windows-x64 native bundle is built and staged today. The retired-live DirectShow implementation remains in-tree Windows-x64 native bundle is built and staged today. The retired-live DirectShow implementation remains in-tree
@@ -31,6 +33,7 @@ replaced before claiming portable exports.
| Movie audio | FFmpeg detects the audio stream but the current ABI returns video frames only | MPEG movie audio remains intentionally silent | Extend the ABI with timestamped PCM and select an audio/presentation clock; separate feature slice | | Movie audio | FFmpeg detects the audio stream but the current ABI returns video frames only | MPEG movie audio remains intentionally silent | Extend the ABI with timestamped PCM and select an audio/presentation clock; separate feature slice |
| ADV font discovery | `godot/Main.cs` probes `C:/Windows/Fonts` for Japanese fonts | Harmless fallback today, but appearance depends on host fonts | Bundle/configure a redistributable font or add platform-specific discovery | | ADV font discovery | `godot/Main.cs` probes `C:/Windows/Fonts` for Japanese fonts | Harmless fallback today, but appearance depends on host fonts | Bundle/configure a redistributable font or add platform-specific discovery |
| Filesystem semantics | Several filename and containment comparisons use `OrdinalIgnoreCase`; installed assets are conventionally uppercase | Needs validation on case-sensitive filesystems; may hide casing or containment mistakes | Add Linux/macOS tests with mixed-case synthetic roots and use filesystem-appropriate containment rules | | Filesystem semantics | Several filename and containment comparisons use `OrdinalIgnoreCase`; installed assets are conventionally uppercase | Needs validation on case-sensitive filesystems; may hide casing or containment mistakes | Add Linux/macOS tests with mixed-case synthetic roots and use filesystem-appropriate containment rules |
| Save/profile storage | Managed `DirectoryNativeDatStore` under Godot `user://SAVE`; native S3SD/S4SD/S3RT files and 24-bit BMP thumbnails | No Win32 path API at runtime; port saves remain isolated from the original installation | Validate replace/flush, case, permissions, and interrupted-write behavior on each export target |
| Install/repository discovery | `engine/Age.Engine/Sys4/Paths.cs` finds `age-reimpl` above `AppContext.BaseDirectory` and assumes the current workspace sibling layout | Suitable for development, not packaged exports on any OS | Replace runtime discovery with a user-selected game root/profile; retain repository paths only for developer tools/tests | | Install/repository discovery | `engine/Age.Engine/Sys4/Paths.cs` finds `age-reimpl` above `AppContext.BaseDirectory` and assumes the current workspace sibling layout | Suitable for development, not packaged exports on any OS | Replace runtime discovery with a user-selected game root/profile; retain repository paths only for developer tools/tests |
| Archive parity oracle | One integration test launches `bin/BinExtractALF.exe` | Windows-only test helper, not a shipped runtime dependency | Skip/replace on non-Windows CI; runtime ALF/AAI readers do not depend on it | | Archive parity oracle | One integration test launches `bin/BinExtractALF.exe` | Windows-only test helper, not a shipped runtime dependency | Skip/replace on non-Windows CI; runtime ALF/AAI readers do not depend on it |
| Native RE tools | Frida/Ghidra helpers target the original `AGE.EXE`; supporting utilities include Windows executables and Windows command conventions | Development/research only | Keep separate from export requirements; document platform prerequisites per tool | | Native RE tools | Frida/Ghidra helpers target the original `AGE.EXE`; supporting utilities include Windows executables and Windows command conventions | Development/research only | Keep separate from export requirements; document platform prerequisites per tool |

View File

@@ -471,7 +471,9 @@ replacement for compatibility-mode import/export. The recovered native contract
their four native opcodes while opaque catalog/version sections round-trip unchanged. Native `RT.DAT` 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, 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 including `message:ReadTextSkip` ops `0x1ca`/`0x1cb` and state query `0x1cc`. Numbered active-frame state
and thumbnails remain later Phase B work. remains later Phase B work. The outer numbered layer is now live: metadata query, paired `.DAT`/`.STH`
copy/delete, exact native BMP thumbnail I/O, and the active-frame boundary marker. Full layout-3 payload
serialization/restoration is the next persistence slice.
### Phase C — Externalize & modding foundation ### Phase C — Externalize & modding foundation
- Add **editable named data overlays** mapped explicitly onto the VM's `*INIT`-produced state; external - Add **editable named data overlays** mapped explicitly onto the VM's `*INIT`-produced state; external

View File

@@ -0,0 +1,256 @@
using System.Buffers.Binary;
using Age.Engine.Model;
using Age.Engine.Persistence;
using Age.Engine.Sys4;
using Age.Engine.Vm;
public class NumberedSavePairTests
{
private const int Immediate = 0;
private const int GlobalInt = 3;
private static readonly OpcodeTable Table = OpcodeTableJson.Load(Paths.OpcodesJson);
private static readonly NativeSystemTime Timestamp =
new(2026, 7, 5, 24, 13, 42, 17, 321);
private static readonly NativeSaveIdentity Identity =
new(NativeSaveMagic.S4SD, 0x4a343234, "numbered-test", 3, 10, 0x42323234);
[Fact]
public void ThumbnailCodecWritesNativeBottomUpBmpAndRoundTrips()
{
var image = new RgbaImage(2, 2,
[
255, 0, 0, 255, 0, 255, 0, 255,
0, 0, 255, 255, 255, 255, 255, 255,
]);
byte[] encoded = NumberedThumbnailCodec.Encode(image);
Assert.Equal((byte)'B', encoded[0]);
Assert.Equal((byte)'M', encoded[1]);
Assert.Equal(56u, BinaryPrimitives.ReadUInt32LittleEndian(encoded.AsSpan(2)));
Assert.Equal(70, encoded.Length);
Assert.Equal(54u, BinaryPrimitives.ReadUInt32LittleEndian(encoded.AsSpan(10)));
Assert.Equal(2, BinaryPrimitives.ReadInt32LittleEndian(encoded.AsSpan(18)));
Assert.Equal(2, BinaryPrimitives.ReadInt32LittleEndian(encoded.AsSpan(22)));
Assert.Equal((ushort)24, BinaryPrimitives.ReadUInt16LittleEndian(encoded.AsSpan(28)));
Assert.Equal(new byte[] { 255, 0, 0, 255, 255, 255 }, encoded[54..60]);
RgbaImage decoded = NumberedThumbnailCodec.Decode(encoded);
Assert.Equal(image.Width, decoded.Width);
Assert.Equal(image.Height, decoded.Height);
Assert.Equal(image.Pixels, decoded.Pixels);
}
[Fact]
public void InstalledHimegariThumbnailMatchesNativeBmpDialectWhenPresent()
{
string eushullyRoot = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Eushully");
if (!Directory.Exists(eushullyRoot)) return;
string? dataPath = Directory.EnumerateFiles(
eushullyRoot, "SAVE00.DAT", SearchOption.AllDirectories)
.FirstOrDefault(path =>
{
try
{
using var stream = File.OpenRead(path);
byte[] header = new byte[NativeSaveContainerCodec.HeaderSize];
stream.ReadExactly(header);
NativeSaveMetadata metadata = NativeSaveContainerCodec.ReadMetadata(header);
return metadata.CompatibilityId == 0x42323234
&& metadata.SaveVersion1 == 3 && metadata.SaveVersion2 == 10;
}
catch (Exception ex) when (ex is IOException or InvalidDataException
or UnauthorizedAccessException)
{
return false;
}
});
if (dataPath == null) return;
string thumbnailPath = Path.ChangeExtension(dataPath, ".STH");
if (!File.Exists(thumbnailPath)) return;
byte[] native = File.ReadAllBytes(thumbnailPath);
RgbaImage decoded = NumberedThumbnailCodec.Decode(native);
Assert.Equal(112, decoded.Width);
Assert.Equal(84, decoded.Height);
Assert.Equal(28278, native.Length);
Assert.Equal(28264u, BinaryPrimitives.ReadUInt32LittleEndian(native.AsSpan(2)));
Assert.Equal(54u, BinaryPrimitives.ReadUInt32LittleEndian(native.AsSpan(10)));
Assert.Equal(native.Length, NumberedThumbnailCodec.Encode(decoded).Length);
}
[Fact]
public void NumberedMetadataUsesItsOwnIdentityAndDoesNotDecodePayload()
{
string root = NewTempRoot();
try
{
var store = new DirectoryNativeDatStore(root, Identity);
store.SaveNumbered(4, [1, 2, 3, 4], Timestamp, 54321);
string path = Path.Combine(root, "SAVE04.DAT");
using (var stream = new FileStream(path, FileMode.Open, FileAccess.Write, FileShare.None))
stream.SetLength(NativeSaveContainerCodec.HeaderSize);
NativeSaveMetadata metadata = store.QueryNumberedMetadata(4)!;
Assert.Equal(0x42323234u, metadata.CompatibilityId);
Assert.Equal(Timestamp, metadata.Timestamp);
Assert.Equal(54321u, metadata.AccumulatedPlaySeconds);
Assert.Throws<InvalidDataException>(() => store.LoadNumbered(4));
}
finally
{
Directory.Delete(root, recursive: true);
}
}
[Fact]
public void PairOperationsAttemptBothMembersAndPreserveNativeStatusPrecedence()
{
string root = NewTempRoot();
try
{
var store = new DirectoryNativeDatStore(root, Identity);
store.SaveNumbered(1, [1, 2, 3, 4], Timestamp, 1);
Assert.Equal(2, store.CopyNumberedPair(1, 2));
Assert.NotNull(store.LoadNumbered(2));
Assert.Null(store.LoadNumberedThumbnail(2));
store.SaveNumberedThumbnail(3, [7, 8, 9]);
Assert.Equal(1, store.CopyNumberedPair(3, 4));
Assert.Null(store.LoadNumbered(4));
Assert.Equal(new byte[] { 7, 8, 9 }, store.LoadNumberedThumbnail(4));
Assert.Equal(2, store.DeleteNumberedPair(1));
Assert.Equal(1, store.DeleteNumberedPair(3));
Assert.Equal(2, store.DeleteNumberedPair(99));
}
finally
{
Directory.Delete(root, recursive: true);
}
}
[Fact]
public void VmMetadataThumbnailCopyAndDeleteOpcodesUseTheNativePairStore()
{
string root = NewTempRoot();
try
{
var store = new DirectoryNativeDatStore(root, Identity);
store.SaveNumbered(4, [1, 2, 3, 4], Timestamp, 54321);
var host = new RecordingHost();
host.SurfacePixels[7] = new RgbaImage(1, 1, [10, 20, 30, 255]);
Script script = ScriptAssembler.Assemble(Table, "NUMBERED_SAVE_OPS",
[
(0x1a0, [
G(100), I(4), G(101), G(102), G(103), G(104), G(105), G(106), G(107),
]),
(0x1ae, [G(110), I(4), I(7)]),
(0x1af, [G(111), I(4), I(8)]),
(0x1ac, [G(112), I(4), I(5)]),
(0x1ab, [G(113), I(5)]),
(0x2, []),
], []);
var vm = new VirtualMachine(script, Table, host, nativeDatStore: store);
vm.Run();
Assert.Equal(0, vm.Globals[100]);
Assert.Equal(2026, vm.Globals[101]);
Assert.Equal(7, vm.Globals[102]);
Assert.Equal(24, vm.Globals[103]);
Assert.Equal(13, vm.Globals[104]);
Assert.Equal(42, vm.Globals[105]);
Assert.Equal(17, vm.Globals[106]);
Assert.Equal(54321, vm.Globals[107]);
Assert.Equal(0, vm.Globals[110]);
Assert.Equal(0, vm.Globals[111]);
Assert.Equal(new byte[] { 10, 20, 30, 255 }, host.SurfacePixels[8].Pixels);
Assert.Equal(0, vm.Globals[112]);
Assert.Equal(0, vm.Globals[113]);
Assert.Null(store.LoadNumbered(5));
Assert.Null(store.LoadNumberedThumbnail(5));
}
finally
{
Directory.Delete(root, recursive: true);
}
}
[Fact]
public void SaveResumeMarkerSurvivesNestedReturnUntilItsOwningFrameUnwinds()
{
int callScript = Table.ByLabel("call-script")!.Value;
Script child = ScriptAssembler.Assemble(Table, "CHILD",
[
(0x1a8, []),
(0x2, []),
], []);
Script root = ScriptAssembler.Assemble(Table, "ROOT",
[
(0x1ad, []),
(callScript, [I(7)]),
(0x2, []),
], []);
var provider = new MapProvider(new Dictionary<long, Script> { [7] = child });
var host = new MarkerObservingHost();
var vm = new VirtualMachine(root, Table, host, provider: provider);
host.Vm = vm;
vm.Run();
Assert.NotEmpty(host.ObservedDepths);
Assert.All(host.ObservedDepths, depth => Assert.Equal(0, depth));
Assert.Null(vm.SaveResumeFrameDepth);
}
[Fact]
public void ChildMarkerIsClearedWhenTheChildFrameUnwinds()
{
int callScript = Table.ByLabel("call-script")!.Value;
Script child = ScriptAssembler.Assemble(Table, "CHILD",
[
(0x1ad, []),
(0x2, []),
], []);
Script root = ScriptAssembler.Assemble(Table, "ROOT",
[
(callScript, [I(7)]),
(0x1a8, []),
(0x2, []),
], []);
var provider = new MapProvider(new Dictionary<long, Script> { [7] = child });
var host = new MarkerObservingHost();
var vm = new VirtualMachine(root, Table, host, provider: provider);
host.Vm = vm;
vm.Run();
Assert.Contains(1, host.ObservedDepths);
int marked = host.ObservedDepths.FindLastIndex(depth => depth == 1);
Assert.Contains(host.ObservedDepths.Skip(marked + 1), depth => depth == null);
Assert.Null(vm.SaveResumeFrameDepth);
}
private static Operand I(long value) => new(Immediate, value);
private static Operand G(long value) => new(GlobalInt, value);
private static string NewTempRoot()
{
string root = Path.Combine(Path.GetTempPath(), "age-numbered-save-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
return root;
}
private sealed class MarkerObservingHost : RecordingHost
{
public VirtualMachine Vm { get; set; } = null!;
public List<int?> ObservedDepths { get; } = new();
public override void FrameYield() => ObservedDepths.Add(Vm.SaveResumeFrameDepth);
}
}

View File

@@ -3,6 +3,7 @@ using System.Linq;
using Age.Engine.Diagnostics; using Age.Engine.Diagnostics;
using Age.Engine.Hosting; using Age.Engine.Hosting;
using Age.Engine.Model; using Age.Engine.Model;
using Age.Engine.Sys4;
/// <summary>Shared test doubles: a host that records observable effects, and an in-memory script /// <summary>Shared test doubles: a host that records observable effects, and an in-memory script
/// provider for synthetic call-script targets.</summary> /// provider for synthetic call-script targets.</summary>
@@ -53,10 +54,18 @@ internal class RecordingHost : IHost
public readonly List<string> Warnings = new(); public readonly List<string> Warnings = new();
public readonly List<long> CursorResources = new(); public readonly List<long> CursorResources = new();
public readonly List<bool> AdvPagePresentationSuspended = new(); public readonly List<bool> AdvPagePresentationSuspended = new();
public readonly Dictionary<int, RgbaImage> SurfacePixels = new();
public int CursorClearCount; public int CursorClearCount;
public int SceneContextResets; public int SceneContextResets;
public void ReportWarning(string message) => Warnings.Add(message); public void ReportWarning(string message) => Warnings.Add(message);
public void ShowText(int offset, string text) => Lines.Add((offset, text)); public void ShowText(int offset, string text) => Lines.Add((offset, text));
public RgbaImage? CaptureSurfacePixels(int slot)
=> SurfacePixels.TryGetValue(slot, out var image) ? image : null;
public bool ReplaceSurfacePixels(int slot, RgbaImage image)
{
SurfacePixels[slot] = image;
return true;
}
public void SetAdvTextCursor(int layoutSlot, int x, int y) => TextCursors.Add((layoutSlot, x, y)); public void SetAdvTextCursor(int layoutSlot, int x, int y) => TextCursors.Add((layoutSlot, x, y));
public void DrawStringToSurface(int surfaceSlot, int x, int y, string text) public void DrawStringToSurface(int surfaceSlot, int x, int y, string text)
=> SurfaceStrings.Add((surfaceSlot, x, y, text)); => SurfaceStrings.Add((surfaceSlot, x, y, text));

View File

@@ -1,4 +1,5 @@
using Age.Engine.Model; using Age.Engine.Model;
using Age.Engine.Sys4;
namespace Age.Engine.Hosting; namespace Age.Engine.Hosting;
@@ -91,6 +92,10 @@ public interface IHost
// numbered surfaces, then block while the engine alpha-composites target over source. // numbered surfaces, then block while the engine alpha-composites target over source.
void CrossfadeSurfaces(GfxState gfx, int sourceSurface, int targetSurface, long intervalArgument) { } void CrossfadeSurfaces(GfxState gfx, int sourceSurface, int targetSurface, long intervalArgument) { }
void CreateTexture(int slot, int width, int height); void CreateTexture(int slot, int width, int height);
/// <summary>Return a stable RGBA snapshot of one numbered surface, or null when unavailable.</summary>
RgbaImage? CaptureSurfacePixels(int slot) => null;
/// <summary>Replace one numbered surface from decoded RGBA pixels. False means unsupported.</summary>
bool ReplaceSurfacePixels(int slot, RgbaImage image) => false;
void SetTexture(long resourceId, int slot); void SetTexture(long resourceId, int slot);
void SetTexture(long resourceId, int slot, long colorKey) => SetTexture(resourceId, slot); void SetTexture(long resourceId, int slot, long colorKey) => SetTexture(resourceId, slot);
void ReleaseSurface(int slot) { } void ReleaseSurface(int slot) { }

View File

@@ -7,16 +7,25 @@ public sealed record NativeSaveIdentity(
uint CompatibilityId, uint CompatibilityId,
string GameId, string GameId,
int SaveVersion1, int SaveVersion1,
int SaveVersion2) int SaveVersion2,
uint? NumberedCompatibilityId = null)
{ {
public NativeSaveMetadata CreateMetadata(NativeSystemTime timestamp, uint accumulatedPlaySeconds) public uint EffectiveNumberedCompatibilityId => NumberedCompatibilityId ?? CompatibilityId;
=> new(Magic, CompatibilityId, GameId, timestamp, accumulatedPlaySeconds, SaveVersion1, SaveVersion2);
public void Validate(NativeSaveMetadata metadata) public NativeSaveMetadata CreateMetadata(
NativeSystemTime timestamp,
uint accumulatedPlaySeconds,
bool numbered = false)
=> new(
Magic, numbered ? EffectiveNumberedCompatibilityId : CompatibilityId, GameId,
timestamp, accumulatedPlaySeconds, SaveVersion1, SaveVersion2);
public void Validate(NativeSaveMetadata metadata, bool numbered = false)
{ {
if (metadata.Magic != Magic) if (metadata.Magic != Magic)
throw new InvalidDataException($"Native save generation mismatch: expected {Magic}, got {metadata.Magic}."); throw new InvalidDataException($"Native save generation mismatch: expected {Magic}, got {metadata.Magic}.");
if (metadata.CompatibilityId != CompatibilityId) uint expectedCompatibilityId = numbered ? EffectiveNumberedCompatibilityId : CompatibilityId;
if (metadata.CompatibilityId != expectedCompatibilityId)
throw new InvalidDataException("Native save compatibility id mismatch."); throw new InvalidDataException("Native save compatibility id mismatch.");
if (!StringComparer.Ordinal.Equals(metadata.GameId, GameId)) if (!StringComparer.Ordinal.Equals(metadata.GameId, GameId))
throw new InvalidDataException("Native save game id mismatch."); throw new InvalidDataException("Native save game id mismatch.");
@@ -41,8 +50,13 @@ public interface INativeDatStore
void SaveShared(ReadOnlySpan<byte> payload, NativeSystemTime timestamp, uint accumulatedPlaySeconds); void SaveShared(ReadOnlySpan<byte> payload, NativeSystemTime timestamp, uint accumulatedPlaySeconds);
ReadTextDatabaseSnapshot? LoadReadText(); ReadTextDatabaseSnapshot? LoadReadText();
void SaveReadText(ReadTextDatabaseSnapshot snapshot); void SaveReadText(ReadTextDatabaseSnapshot snapshot);
NativeSaveMetadata? QueryNumberedMetadata(int slot);
NativeSaveDocument? LoadNumbered(int slot); NativeSaveDocument? LoadNumbered(int slot);
void SaveNumbered(int slot, ReadOnlySpan<byte> payload, NativeSystemTime timestamp, uint accumulatedPlaySeconds); void SaveNumbered(int slot, ReadOnlySpan<byte> payload, NativeSystemTime timestamp, uint accumulatedPlaySeconds);
int DeleteNumberedPair(int slot);
int CopyNumberedPair(int sourceSlot, int destinationSlot);
byte[]? LoadNumberedThumbnail(int slot);
void SaveNumberedThumbnail(int slot, ReadOnlySpan<byte> data);
} }
/// <summary> /// <summary>
@@ -143,7 +157,21 @@ public sealed class DirectoryNativeDatStore : INativeDatStore
public NativeSaveDocument? LoadNumbered(int slot) public NativeSaveDocument? LoadNumbered(int slot)
{ {
string path = Path.Combine(_root, NumberedFileName(slot)); string path = Path.Combine(_root, NumberedFileName(slot));
return File.Exists(path) ? LoadAndValidate(path) : null; return File.Exists(path) ? LoadAndValidate(path, numbered: true) : null;
}
public NativeSaveMetadata? QueryNumberedMetadata(int slot)
{
string path = Path.Combine(_root, NumberedFileName(slot));
if (!File.Exists(path)) return null;
using var stream = new FileStream(
path, FileMode.Open, FileAccess.Read, FileShare.Read, NativeSaveContainerCodec.HeaderSize,
FileOptions.SequentialScan);
byte[] header = new byte[NativeSaveContainerCodec.HeaderSize];
stream.ReadExactly(header);
NativeSaveMetadata metadata = NativeSaveContainerCodec.ReadMetadata(header);
_identity.Validate(metadata, numbered: true);
return metadata;
} }
public void SaveNumbered( public void SaveNumbered(
@@ -153,24 +181,84 @@ public sealed class DirectoryNativeDatStore : INativeDatStore
uint accumulatedPlaySeconds) uint accumulatedPlaySeconds)
{ {
byte[] encoded = NativeSaveContainerCodec.Encode( byte[] encoded = NativeSaveContainerCodec.Encode(
payload, _identity.CreateMetadata(timestamp, accumulatedPlaySeconds)); payload, _identity.CreateMetadata(timestamp, accumulatedPlaySeconds, numbered: true));
Directory.CreateDirectory(_root); Directory.CreateDirectory(_root);
WriteThrough(Path.Combine(_root, NumberedFileName(slot)), encoded); WriteThrough(Path.Combine(_root, NumberedFileName(slot)), encoded);
} }
public int DeleteNumberedPair(int slot)
{
bool dataDeleted = TryDelete(Path.Combine(_root, NumberedFileName(slot)));
bool thumbnailDeleted = TryDelete(Path.Combine(_root, NumberedThumbnailFileName(slot)));
return !thumbnailDeleted ? 2 : !dataDeleted ? 1 : 0;
}
public int CopyNumberedPair(int sourceSlot, int destinationSlot)
{
Directory.CreateDirectory(_root);
bool dataCopied = TryCopy(
Path.Combine(_root, NumberedFileName(sourceSlot)),
Path.Combine(_root, NumberedFileName(destinationSlot)));
bool thumbnailCopied = TryCopy(
Path.Combine(_root, NumberedThumbnailFileName(sourceSlot)),
Path.Combine(_root, NumberedThumbnailFileName(destinationSlot)));
return !thumbnailCopied ? 2 : !dataCopied ? 1 : 0;
}
public byte[]? LoadNumberedThumbnail(int slot)
{
string path = Path.Combine(_root, NumberedThumbnailFileName(slot));
return File.Exists(path) ? File.ReadAllBytes(path) : null;
}
public void SaveNumberedThumbnail(int slot, ReadOnlySpan<byte> data)
{
Directory.CreateDirectory(_root);
WriteThrough(Path.Combine(_root, NumberedThumbnailFileName(slot)), data);
}
public static string NumberedFileName(int slot) public static string NumberedFileName(int slot)
{ {
if (slot < 0) throw new ArgumentOutOfRangeException(nameof(slot)); if (slot < 0) throw new ArgumentOutOfRangeException(nameof(slot));
return "SAVE" + slot.ToString("00", CultureInfo.InvariantCulture) + ".DAT"; return "SAVE" + slot.ToString("00", CultureInfo.InvariantCulture) + ".DAT";
} }
private NativeSaveDocument LoadAndValidate(string path) public static string NumberedThumbnailFileName(int slot)
{
if (slot < 0) throw new ArgumentOutOfRangeException(nameof(slot));
return "SAVE" + slot.ToString("00", CultureInfo.InvariantCulture) + ".STH";
}
private NativeSaveDocument LoadAndValidate(string path, bool numbered = false)
{ {
NativeSaveDocument document = NativeSaveContainerCodec.Decode(File.ReadAllBytes(path)); NativeSaveDocument document = NativeSaveContainerCodec.Decode(File.ReadAllBytes(path));
_identity.Validate(document.Metadata); _identity.Validate(document.Metadata, numbered);
return document; return document;
} }
private static bool TryDelete(string path)
{
try
{
if (!File.Exists(path)) return false;
File.Delete(path);
return true;
}
catch (IOException) { return false; }
catch (UnauthorizedAccessException) { return false; }
}
private static bool TryCopy(string source, string destination)
{
try
{
File.Copy(source, destination, overwrite: true);
return true;
}
catch (IOException) { return false; }
catch (UnauthorizedAccessException) { return false; }
}
private static void WriteThrough(string path, ReadOnlySpan<byte> data) private static void WriteThrough(string path, ReadOnlySpan<byte> data)
{ {
using var stream = new FileStream( using var stream = new FileStream(

View File

@@ -0,0 +1,105 @@
using System.Buffers.Binary;
using Age.Engine.Sys4;
namespace Age.Engine.Persistence;
/// <summary>
/// AGE numbered-save thumbnails are ordinary uncompressed bottom-up 24-bit BMPs under the .STH
/// extension. The native writer's bfSize omits the 14-byte BITMAPFILEHEADER even though the file
/// and pixel offset include it; this codec reproduces that harmless historical quirk.
/// </summary>
public static class NumberedThumbnailCodec
{
public const int FileHeaderSize = 14;
public const int DibHeaderSize = 40;
public const int PixelOffset = FileHeaderSize + DibHeaderSize;
public static byte[] Encode(RgbaImage image)
{
ArgumentNullException.ThrowIfNull(image);
ValidateRgba(image);
if (image.Width <= 0 || image.Height <= 0)
throw new InvalidDataException("Numbered thumbnail dimensions must be positive.");
int rowBytes = checked(image.Width * 3);
int rowStride = checked((rowBytes + 3) & ~3);
int pixelBytes = checked(rowStride * image.Height);
byte[] result = new byte[checked(PixelOffset + pixelBytes)];
Span<byte> header = result.AsSpan(0, PixelOffset);
header[0] = (byte)'B';
header[1] = (byte)'M';
BinaryPrimitives.WriteUInt32LittleEndian(
header[2..], checked((uint)(DibHeaderSize + pixelBytes)));
BinaryPrimitives.WriteUInt32LittleEndian(header[10..], (uint)PixelOffset);
BinaryPrimitives.WriteUInt32LittleEndian(header[14..], (uint)DibHeaderSize);
BinaryPrimitives.WriteInt32LittleEndian(header[18..], image.Width);
BinaryPrimitives.WriteInt32LittleEndian(header[22..], image.Height);
BinaryPrimitives.WriteUInt16LittleEndian(header[26..], 1);
BinaryPrimitives.WriteUInt16LittleEndian(header[28..], 24);
for (int destinationRow = 0; destinationRow < image.Height; destinationRow++)
{
int sourceY = image.Height - 1 - destinationRow;
int source = sourceY * image.Width * 4;
int destination = PixelOffset + destinationRow * rowStride;
for (int x = 0; x < image.Width; x++, source += 4, destination += 3)
{
result[destination] = image.Pixels[source + 2];
result[destination + 1] = image.Pixels[source + 1];
result[destination + 2] = image.Pixels[source];
}
}
return result;
}
public static RgbaImage Decode(ReadOnlySpan<byte> source)
{
if (source.Length < PixelOffset
|| source[0] != (byte)'B' || source[1] != (byte)'M')
throw new InvalidDataException("Numbered thumbnail is not a BMP file.");
uint rawOffset = BinaryPrimitives.ReadUInt32LittleEndian(source[10..]);
uint dibSize = BinaryPrimitives.ReadUInt32LittleEndian(source[14..]);
int width = BinaryPrimitives.ReadInt32LittleEndian(source[18..]);
int storedHeight = BinaryPrimitives.ReadInt32LittleEndian(source[22..]);
ushort planes = BinaryPrimitives.ReadUInt16LittleEndian(source[26..]);
ushort bitsPerPixel = BinaryPrimitives.ReadUInt16LittleEndian(source[28..]);
uint compression = BinaryPrimitives.ReadUInt32LittleEndian(source[30..]);
if (dibSize < DibHeaderSize
|| (ulong)rawOffset < (ulong)FileHeaderSize + dibSize
|| rawOffset > int.MaxValue || width <= 0 || storedHeight == 0
|| storedHeight == int.MinValue || planes != 1 || bitsPerPixel != 24 || compression != 0)
throw new InvalidDataException("Numbered thumbnail has an unsupported BMP layout.");
bool bottomUp = storedHeight > 0;
int height = Math.Abs(storedHeight);
int rowBytes = checked(width * 3);
int rowStride = checked((rowBytes + 3) & ~3);
int pixelOffset = (int)rawOffset;
int pixelBytes = checked(rowStride * height);
if (pixelOffset > source.Length || pixelBytes > source.Length - pixelOffset)
throw new InvalidDataException("Numbered thumbnail pixel data is truncated.");
byte[] rgba = new byte[checked(width * height * 4)];
for (int storedRow = 0; storedRow < height; storedRow++)
{
int destinationY = bottomUp ? height - 1 - storedRow : storedRow;
int sourcePosition = pixelOffset + storedRow * rowStride;
int destination = destinationY * width * 4;
for (int x = 0; x < width; x++, sourcePosition += 3, destination += 4)
{
rgba[destination] = source[sourcePosition + 2];
rgba[destination + 1] = source[sourcePosition + 1];
rgba[destination + 2] = source[sourcePosition];
rgba[destination + 3] = 255;
}
}
return new RgbaImage(width, height, rgba);
}
private static void ValidateRgba(RgbaImage image)
{
if (image.Width < 0 || image.Height < 0
|| image.Pixels.Length != checked(image.Width * image.Height * 4))
throw new InvalidDataException("Numbered thumbnail RGBA buffer has invalid dimensions.");
}
}

View File

@@ -23,11 +23,16 @@ public sealed class GameSession
public Dictionary<int, string> GlobalStrings { get; } = new(); public Dictionary<int, string> GlobalStrings { get; } = new();
/// <summary>AGE's selected profile-wide cells plus native shared SAVE.DAT/RT.DAT lifecycle.</summary> /// <summary>AGE's selected profile-wide cells plus native shared SAVE.DAT/RT.DAT lifecycle.</summary>
public SharedProfile SharedProfile { get; } public SharedProfile SharedProfile { get; }
/// <summary>Native shared/numbered save directory service used by persistence opcodes.</summary>
public INativeDatStore? NativeDatStore { get; }
/// <summary>The live retained ADV backlog shared by every VM run in this session.</summary> /// <summary>The live retained ADV backlog shared by every VM run in this session.</summary>
public AdvTextHistory TextHistory { get; } = new(); public AdvTextHistory TextHistory { get; } = new();
public GameSession(SharedProfile? sharedProfile = null) public GameSession(SharedProfile? sharedProfile = null, INativeDatStore? nativeDatStore = null)
=> SharedProfile = sharedProfile ?? new SharedProfile(); {
SharedProfile = sharedProfile ?? new SharedProfile();
NativeDatStore = nativeDatStore;
}
public void Seed(int addr, long value) => Globals[addr] = value; public void Seed(int addr, long value) => Globals[addr] = value;
public void SeedString(int addr, string value) => GlobalStrings[addr] = value; public void SeedString(int addr, string value) => GlobalStrings[addr] = value;
@@ -38,7 +43,7 @@ public sealed class GameSession
ITraceSink? sink = null) ITraceSink? sink = null)
{ {
var vm = new VirtualMachine( var vm = new VirtualMachine(
script, table, host, options, provider, sink, TextHistory, SharedProfile); script, table, host, options, provider, sink, TextHistory, SharedProfile, NativeDatStore);
foreach (var kv in Globals) vm.Globals[kv.Key] = kv.Value; foreach (var kv in Globals) vm.Globals[kv.Key] = kv.Value;
foreach (var kv in GlobalStrings) vm.GlobalStrings[kv.Key] = kv.Value; foreach (var kv in GlobalStrings) vm.GlobalStrings[kv.Key] = kv.Value;

View File

@@ -27,6 +27,7 @@ public sealed class VirtualMachine
private readonly Encoding _nativeStringEncoding; private readonly Encoding _nativeStringEncoding;
private readonly IScriptProvider? _provider; private readonly IScriptProvider? _provider;
private readonly SharedProfile _sharedProfile; private readonly SharedProfile _sharedProfile;
private readonly INativeDatStore? _nativeDatStore;
private static readonly bool _diagSetTexture = System.Environment.GetEnvironmentVariable("AGE_DIAG_SETTEX") == "1"; private static readonly bool _diagSetTexture = System.Environment.GetEnvironmentVariable("AGE_DIAG_SETTEX") == "1";
private ExecFrame _cur = null!; private ExecFrame _cur = null!;
private int _depth; private int _depth;
@@ -34,6 +35,8 @@ public sealed class VirtualMachine
private readonly object _interactiveLock = new(); private readonly object _interactiveLock = new();
private readonly object _debugControlLock = new(); private readonly object _debugControlLock = new();
private readonly List<string> _activeFrameNames = new(); private readonly List<string> _activeFrameNames = new();
private readonly List<ExecFrame> _activeExecutionFrames = new();
private ExecFrame? _saveResumeFrame;
private ExecFrame? _debugActiveFrame; private ExecFrame? _debugActiveFrame;
private long _debugActiveFrameId; private long _debugActiveFrameId;
private long _debugNextFrameId; private long _debugNextFrameId;
@@ -75,6 +78,21 @@ public sealed class VirtualMachine
public long Steps { get; private set; } public long Steps { get; private set; }
public bool AutoMessageEnabled => _autoMessageEnabled; public bool AutoMessageEnabled => _autoMessageEnabled;
public bool MessageSkipEnabled => _messageSkipEnabled; public bool MessageSkipEnabled => _messageSkipEnabled;
/// <summary>
/// Zero-based active-frame cutoff selected by opcode 0x1ad, or null when no surviving marker
/// exists. A numbered-save serializer consumes this boundary in the full payload slice.
/// </summary>
public int? SaveResumeFrameDepth
{
get
{
lock (_debugControlLock)
{
int index = _saveResumeFrame == null ? -1 : _activeExecutionFrames.IndexOf(_saveResumeFrame);
return index >= 0 ? index : null;
}
}
}
/// <summary>True while a script-owned timed mouse/input callback loop (HISTORY/HIDEWIN family) owns input.</summary> /// <summary>True while a script-owned timed mouse/input callback loop (HISTORY/HIDEWIN family) owns input.</summary>
public bool IsRawInputCallbackActive public bool IsRawInputCallbackActive
{ {
@@ -97,13 +115,15 @@ public sealed class VirtualMachine
public VirtualMachine(Script s, OpcodeTable t, IHost host, VmOptions? o = null, public VirtualMachine(Script s, OpcodeTable t, IHost host, VmOptions? o = null,
IScriptProvider? provider = null, ITraceSink? sink = null, IScriptProvider? provider = null, ITraceSink? sink = null,
AdvTextHistory? textHistory = null, SharedProfile? sharedProfile = null) AdvTextHistory? textHistory = null, SharedProfile? sharedProfile = null,
INativeDatStore? nativeDatStore = null)
{ {
_s = s; _t = t; _host = host; _o = o ?? new VmOptions(); _provider = provider; _s = s; _t = t; _host = host; _o = o ?? new VmOptions(); _provider = provider;
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
_nativeStringEncoding = Encoding.GetEncoding(_o.NativeStringCodePage); _nativeStringEncoding = Encoding.GetEncoding(_o.NativeStringCodePage);
_sink = sink ?? NullTraceSink.Instance; TextHistory = textHistory ?? new AdvTextHistory(); _sink = sink ?? NullTraceSink.Instance; TextHistory = textHistory ?? new AdvTextHistory();
_sharedProfile = sharedProfile ?? new SharedProfile(); _sharedProfile = sharedProfile ?? new SharedProfile();
_nativeDatStore = nativeDatStore;
} }
/// <summary>Queue global writes and return only the identified active frame at its next opcode boundary. /// <summary>Queue global writes and return only the identified active frame at its next opcode boundary.
@@ -596,6 +616,7 @@ public sealed class VirtualMachine
_debugActiveFrame = null; _debugActiveFrame = null;
_debugActiveFrameId = 0; _debugActiveFrameId = 0;
_debugFrameReturnRequest = null; _debugFrameReturnRequest = null;
_saveResumeFrame = null;
} }
_autoMessageEnabled = false; _autoMessageEnabled = false;
_autoVoicePending = false; _autoVoicePending = false;
@@ -629,6 +650,7 @@ public sealed class VirtualMachine
_debugActiveFrame = frame; _debugActiveFrame = frame;
_debugActiveFrameId = ++_debugNextFrameId; _debugActiveFrameId = ++_debugNextFrameId;
_activeFrameNames.Add(frame.Script.Name); _activeFrameNames.Add(frame.Script.Name);
_activeExecutionFrames.Add(frame);
} }
bool hostContextEntered = false; bool hostContextEntered = false;
try try
@@ -664,7 +686,10 @@ public sealed class VirtualMachine
lock (_debugControlLock) lock (_debugControlLock)
{ {
if (ReferenceEquals(_debugFrameReturnRequest?.Frame, frame)) _debugFrameReturnRequest = null; if (ReferenceEquals(_debugFrameReturnRequest?.Frame, frame)) _debugFrameReturnRequest = null;
if (ReferenceEquals(_saveResumeFrame, frame)) _saveResumeFrame = null;
if (_activeFrameNames.Count > 0) _activeFrameNames.RemoveAt(_activeFrameNames.Count - 1); if (_activeFrameNames.Count > 0) _activeFrameNames.RemoveAt(_activeFrameNames.Count - 1);
if (_activeExecutionFrames.Count > 0)
_activeExecutionFrames.RemoveAt(_activeExecutionFrames.Count - 1);
_debugActiveFrame = previousDebugActiveFrame; _debugActiveFrame = previousDebugActiveFrame;
_debugActiveFrameId = previousDebugActiveFrameId; _debugActiveFrameId = previousDebugActiveFrameId;
} }
@@ -794,6 +819,110 @@ public sealed class VirtualMachine
case "halve-strlen": // 0x1a6: strlen(native encoded bytes) >> 1 case "halve-strlen": // 0x1a6: strlen(native encoded bytes) >> 1
Write(a[0], NativeStringByteLength(ReadStr(a[1])) >> 1); Write(a[0], NativeStringByteLength(ReadStr(a[1])) >> 1);
return pc + 1; return pc + 1;
case "query-numbered-save-metadata": // 0x1a0
{
if (_nativeDatStore == null)
{
Write(a[0], 1);
return pc + 1;
}
try
{
NativeSaveMetadata? metadata =
_nativeDatStore.QueryNumberedMetadata(unchecked((int)Read(a[1])));
if (metadata == null)
{
Write(a[0], 1);
return pc + 1;
}
Write(a[2], metadata.Timestamp.Year);
Write(a[3], metadata.Timestamp.Month);
Write(a[4], metadata.Timestamp.Day);
Write(a[5], metadata.Timestamp.Hour);
Write(a[6], metadata.Timestamp.Minute);
Write(a[7], metadata.Timestamp.Second);
Write(a[8], unchecked((int)metadata.AccumulatedPlaySeconds));
Write(a[0], 0);
}
catch (EndOfStreamException) { Write(a[0], 2); }
catch (InvalidDataException) { Write(a[0], 2); }
catch (ArgumentOutOfRangeException) { Write(a[0], 2); }
catch (IOException) { Write(a[0], 1); }
catch (UnauthorizedAccessException) { Write(a[0], 1); }
return pc + 1;
}
case "delete-numbered-save": // 0x1ab
try
{
Write(a[0], _nativeDatStore?.DeleteNumberedPair(unchecked((int)Read(a[1]))) ?? 2);
}
catch (ArgumentOutOfRangeException) { Write(a[0], 2); }
return pc + 1;
case "copy-numbered-save": // 0x1ac
try
{
Write(a[0], _nativeDatStore?.CopyNumberedPair(
unchecked((int)Read(a[1])), unchecked((int)Read(a[2]))) ?? 2);
}
catch (ArgumentOutOfRangeException) { Write(a[0], 2); }
catch (IOException) { Write(a[0], 2); }
catch (UnauthorizedAccessException) { Write(a[0], 2); }
return pc + 1;
case "mark-save-resume-frame": // 0x1ad
lock (_debugControlLock) _saveResumeFrame = _cur;
return pc + 1;
case "write-numbered-save-thumbnail": // 0x1ae
{
if (_nativeDatStore == null)
{
Write(a[0], 1);
return pc + 1;
}
try
{
var image = _host.CaptureSurfacePixels(unchecked((int)Read(a[2])));
if (image == null)
{
Write(a[0], 2);
return pc + 1;
}
byte[] encoded = NumberedThumbnailCodec.Encode(image);
_nativeDatStore.SaveNumberedThumbnail(unchecked((int)Read(a[1])), encoded);
Write(a[0], 0);
}
catch (ArgumentOutOfRangeException) { Write(a[0], 2); }
catch (InvalidDataException) { Write(a[0], 2); }
catch (OverflowException) { Write(a[0], 2); }
catch (IOException) { Write(a[0], 1); }
catch (UnauthorizedAccessException) { Write(a[0], 1); }
return pc + 1;
}
case "load-numbered-save-thumbnail": // 0x1af
{
if (_nativeDatStore == null)
{
Write(a[0], 1);
return pc + 1;
}
try
{
byte[]? encoded =
_nativeDatStore.LoadNumberedThumbnail(unchecked((int)Read(a[1])));
if (encoded == null)
{
Write(a[0], 1);
return pc + 1;
}
var image = NumberedThumbnailCodec.Decode(encoded);
Write(a[0], _host.ReplaceSurfacePixels(unchecked((int)Read(a[2])), image) ? 0 : 2);
}
catch (ArgumentOutOfRangeException) { Write(a[0], 2); }
catch (InvalidDataException) { Write(a[0], 2); }
catch (OverflowException) { Write(a[0], 2); }
catch (IOException) { Write(a[0], 1); }
catch (UnauthorizedAccessException) { Write(a[0], 1); }
return pc + 1;
}
case "store-shared-profile-int": // 0x1a2 case "store-shared-profile-int": // 0x1a2
{ {
if (!TryResolveSharedProfileCell(a[0], isString: false, out int address)) if (!TryResolveSharedProfileCell(a[0], isString: false, out int address))

View File

@@ -849,6 +849,34 @@ public sealed class GodotAdvHost : IHost
public (int Width, int Height) GetTextureSize(int slot) public (int Width, int Height) GetTextureSize(int slot)
=> _slotDims.TryGetValue(slot, out var d) ? (d.W, d.H) : (0, 0); => _slotDims.TryGetValue(slot, out var d) ? (d.W, d.H) : (0, 0);
public RgbaImage? CaptureSurfacePixels(int slot)
{
RgbaImage? image = ResolveSurfacePixels(slot);
return image == null
? null
: new RgbaImage(image.Width, image.Height, (byte[])image.Pixels.Clone());
}
public bool ReplaceSurfacePixels(int slot, RgbaImage image)
{
if (image.Width <= 0 || image.Height <= 0
|| image.Pixels.Length != checked(image.Width * image.Height * 4))
return false;
lock (_imageLock)
{
_surfaceImages[slot] =
new RgbaImage(image.Width, image.Height, (byte[])image.Pixels.Clone());
_surfaceColorKeys.Remove(slot);
}
lock (_textLock)
{
_surfaceText.Remove(slot);
_surfaceResources.Remove(slot);
}
_slotDims[slot] = (image.Width, image.Height);
return true;
}
// Retained render model: draw-texture updates GfxState (object -> surface bind); Main._Process composites // Retained render model: draw-texture updates GfxState (object -> surface bind); Main._Process composites
// the visible objects each frame in ascending-handle order. No immediate blit here. // the visible objects each frame in ascending-handle order. No immediate blit here.
public void DrawTexture(int slot, int srcX, int srcY, int width, int height, int dstX, int dstY) { } public void DrawTexture(int slot, int srcX, int srcY, int width, int height, int dstX, int dstY) { }

View File

@@ -8,6 +8,7 @@ using Godot;
using Age.Engine.Diagnostics; using Age.Engine.Diagnostics;
using Age.Engine.Hosting; using Age.Engine.Hosting;
using Age.Engine.Model; using Age.Engine.Model;
using Age.Engine.Persistence;
using Age.Engine.Sys4; using Age.Engine.Sys4;
using Age.Engine.Vm; using Age.Engine.Vm;
using Script = Age.Engine.Model.Script; // disambiguate from Godot.Script using Script = Age.Engine.Model.Script; // disambiguate from Godot.Script
@@ -248,8 +249,17 @@ public partial class Main : Godot.Control
Age.Engine.Diagnostics.ITraceSink sink = _trace; Age.Engine.Diagnostics.ITraceSink sink = _trace;
if (histFile != null) { _hist = new Age.Engine.Diagnostics.HistogramTraceSink(); if (histFile != null) { _hist = new Age.Engine.Diagnostics.HistogramTraceSink();
sink = new Age.Engine.Diagnostics.CompositeTraceSink(_trace, _hist); } sink = new Age.Engine.Diagnostics.CompositeTraceSink(_trace, _hist); }
// Persistence opcodes retain AGE's native filenames and binary formats, but the port owns the
// root interception point. Keep authored saves isolated from the original installation under
// Godot's per-application user directory.
var nativeSaveStore = new DirectoryNativeDatStore(
ProjectSettings.GlobalizePath("user://SAVE"),
new NativeSaveIdentity(
NativeSaveMagic.S4SD, 0x4a343234, "姫狩りダンジョンマイスター",
SaveVersion1: 3, SaveVersion2: 10, NumberedCompatibilityId: 0x42323234));
_vm = new VirtualMachine(script, table, _host, _vm = new VirtualMachine(script, table, _host,
new VmOptions(MaxSteps: 20_000_000, IgnoreExitRequests: nativeDebugMenu), provider, sink); new VmOptions(MaxSteps: 20_000_000, IgnoreExitRequests: nativeDebugMenu), provider, sink,
nativeDatStore: nativeSaveStore);
if (scripts != null) if (scripts != null)
{ {
_debugSceneEntries = DebugSceneCatalog.Build(scripts.Catalog); _debugSceneEntries = DebugSceneCatalog.Build(scripts.Catalog);

View File

@@ -3438,7 +3438,7 @@ abi_source = "kelebek+decode-validated"
name = "query-numbered-save-metadata" name = "query-numbered-save-metadata"
category = "control" 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." 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." 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. Himegari numbered files use compatibility id 0x42323234, distinct from shared SAVE.DAT/RT.DAT id 0x4a343234. Port status (2026-07-24): implemented through the native directory store with fixed-header-only reads."
noop_headless = false noop_headless = false
source = "investigation" source = "investigation"
confidence = "high" confidence = "high"
@@ -3726,7 +3726,7 @@ abi_source = "kelebek+decode-validated"
name = "delete-numbered-save" name = "delete-numbered-save"
category = "control" category = "control"
summary = "(status_out)(slot) - attempt to delete both `SAVE%02d.DAT` and its `SAVE%02d.STH` thumbnail." 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)." 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). Port status (2026-07-24): implemented against the paired native filenames."
noop_headless = false noop_headless = false
source = "investigation" source = "investigation"
confidence = "high" confidence = "high"
@@ -3753,7 +3753,7 @@ abi_source = "kelebek+decode-validated"
name = "copy-numbered-save" name = "copy-numbered-save"
category = "control" category = "control"
summary = "(status_out)(source_slot)(destination_slot) - copy both numbered `.DAT` state and `.STH` thumbnail, replacing destination files." 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)." 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). Port status (2026-07-24): implemented against the paired native filenames."
noop_headless = false noop_headless = false
source = "investigation" source = "investigation"
confidence = "high" confidence = "high"
@@ -3785,6 +3785,7 @@ abi_source = "kelebek+decode-validated"
name = "mark-save-resume-frame" name = "mark-save-resume-frame"
category = "control" category = "control"
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." 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."
details = "Port status (2026-07-24): implemented as an active ExecFrame identity marker. It survives nested calls and clears when its owning frame unwinds; the following full numbered-payload slice will consume the exposed zero-based cutoff."
noop_headless = false noop_headless = false
source = "investigation" source = "investigation"
confidence = "high" confidence = "high"
@@ -3801,7 +3802,7 @@ abi_source = "kelebek+decode-validated"
name = "write-numbered-save-thumbnail" name = "write-numbered-save-thumbnail"
category = "draw" category = "draw"
summary = "(status_out)(slot)(surface_slot) - encode the selected surface into the numbered save's separate `SAVE%02d.STH` thumbnail file." 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." 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. Himegari writes an ordinary bottom-up 24-bit BMP (112x84 in installed files), BGR rows with four-byte padding, under the .STH extension. Its bfSize field historically omits the 14-byte BITMAPFILEHEADER. Port status (2026-07-24): implemented with exact native BMP output and host surface capture."
noop_headless = false noop_headless = false
source = "investigation" source = "investigation"
confidence = "high" confidence = "high"
@@ -3833,7 +3834,7 @@ abi_source = "kelebek+decode-validated"
name = "load-numbered-save-thumbnail" name = "load-numbered-save-thumbnail"
category = "draw" category = "draw"
summary = "(status_out)(slot)(surface_slot) - decode the numbered save's separate `SAVE%02d.STH` thumbnail into a surface slot." 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." 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. Port status (2026-07-24): implemented with 24-bit BMP decode and host surface replacement."
noop_headless = false noop_headless = false
source = "investigation" source = "investigation"
confidence = "high" confidence = "high"