Implement numbered save pair lifecycle
This commit is contained in:
@@ -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/)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
256
engine/Age.Engine.Tests/NumberedSavePairTests.cs
Normal file
256
engine/Age.Engine.Tests/NumberedSavePairTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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) { }
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
105
engine/Age.Engine/Persistence/NumberedThumbnailCodec.cs
Normal file
105
engine/Age.Engine/Persistence/NumberedThumbnailCodec.cs
Normal 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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) { }
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user