From 8fe610b66d2ead8fac78d5793b85f06179e3f6db Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 24 Jul 2026 15:32:55 -0400 Subject: [PATCH] Implement numbered save pair lifecycle --- docs/PROJECT-STRUCTURE.md | 2 +- docs/engine-re.md | 35 ++- docs/opcode-reference.md | 12 +- docs/phase-a-slice-plan.md | 38 ++- docs/platform-portability.md | 3 + docs/remake-architecture-and-roadmap.md | 4 +- .../Age.Engine.Tests/NumberedSavePairTests.cs | 256 ++++++++++++++++++ engine/Age.Engine.Tests/TestSupport.cs | 9 + engine/Age.Engine/Hosting/IHost.cs | 5 + .../Age.Engine/Persistence/NativeDatStore.cs | 106 +++++++- .../Persistence/NumberedThumbnailCodec.cs | 105 +++++++ engine/Age.Engine/Vm/GameSession.cs | 11 +- engine/Age.Engine/Vm/VirtualMachine.cs | 131 ++++++++- godot/GodotAdvHost.cs | 28 ++ godot/Main.cs | 12 +- vm-map/opcodes.toml | 11 +- 16 files changed, 730 insertions(+), 38 deletions(-) create mode 100644 engine/Age.Engine.Tests/NumberedSavePairTests.cs create mode 100644 engine/Age.Engine/Persistence/NumberedThumbnailCodec.cs diff --git a/docs/PROJECT-STRUCTURE.md b/docs/PROJECT-STRUCTURE.md index ed8ee9e..2a64bcf 100644 --- a/docs/PROJECT-STRUCTURE.md +++ b/docs/PROJECT-STRUCTURE.md @@ -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, │ script provider, AGF/LZSS and Windows CUR decoders, and resource facade │ └── 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 │ └── age_movie_ffmpeg/ project-owned FFmpeg C ABI, immutable Windows dependency manifest, │ and bootstrap/build scripts (outputs stay under disposable build/) diff --git a/docs/engine-re.md b/docs/engine-re.md index 7a0fe14..5a359b1 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -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 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 -and deliberately has no active-frame or numbered-save backend. Treating `0x1ad` as a no-op is behaviorally -neutral only under that present limitation; counting it as faithfully implemented would be misleading. Its -real implementation belongs in the future unified save architecture, where the VM must serialize the active -`ExecFrame` chain and remember which frame is the resume boundary. This is the same architectural deferral as -the already-deferred profile/read-state work, not a reason to invent a seed or offset-specific shortcut. +**Port implication:** the port-owned JSON session snapshot still persists only global integer/string banks, +but the VM now tracks `0x1ad` as an identity reference to the active `ExecFrame`. The marker survives nested +calls and clears when that same frame unwinds, exposing the inclusive zero-based cutoff for the native +serializer. The following numbered-payload slice must consume this boundary while serializing the frame +chain; no script seed or offset-specific shortcut is involved. ### 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. 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: `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 @@ -1719,6 +1723,10 @@ Both shared and numbered `.DAT` payloads use the same native container. The fixe | `0x11c` | 4 | `SaveVersion1` / logical state-layout version | | `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`. 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: @@ -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 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 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 @@ -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, 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, 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 @@ -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 load/save lifecycle; `GameSession` injects it into every fresh VM. Opcodes `0x1a2`/`0x1a3` and `0x1a9`/`0x1aa` now implement native upsert and missing-value defaults for direct cells and resolved -global pointers. `RT.DAT` is now implemented as the separate S3RT layer described below. Thumbnails and -numbered active-frame payloads remain later layers; the existing `GameSession` JSON snapshot is unchanged. +global pointers. `RT.DAT` is now implemented as the separate S3RT layer described below. The numbered store +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) diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 9024587..968e8e5 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -490,7 +490,7 @@ This is the data-only companion to full-resume opcode 0x1a1. It selects the conf - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2: op_0x1a0_query_numbered_save_metadata@0x427ba0 calls save_container_read_and_validate_header@0x4306f0. It reads SYSTEMTIME WORDs at header +0x108/+0x10a/+0x10e/+0x110/+0x112/+0x114 and DWORD accumulated playtime at +0x118. Corpus: three calls in SAVE.BIN. -Status 0 means valid metadata was written, 1 means the file could not be opened, and 2 means its native header was invalid or incompatible. Metadata comes from the fixed 0x124-byte S3SD/S4SD container header; no payload decode is needed. +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) - **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 - **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) - **summary:** (status_out)(source_slot)(destination_slot) - copy both numbered `.DAT` state and `.STH` thumbnail, replacing destination files. - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2: op_0x1ac_copy_numbered_save@0x427fb0 formats source/destination SAVE%2.2d.DAT and SAVE%2.2d.STH paths and invokes CopyFileA with fail-if-exists false. Corpus: two calls in SAVE.BIN. -Both copies are attempted with overwrite allowed. Status is 0 when both succeed, 1 when only the DAT copy fails, and 2 whenever the STH copy fails (taking precedence over a DAT failure). +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) - **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 - **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) - **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 @@ -596,14 +598,14 @@ The five-dword definition is stored at EngineCtx+0x55180+style_index*0x14. Opcod - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2: op_0x1ae_write_numbered_save_thumbnail@0x428100 creates SAVE%2.2d.STH with CREATE_ALWAYS and serializes operand 3's surface via the active renderer backend. Corpus: two calls in SAVE.BIN and SELSTAGE.BIN. -Status is 0 on success, 1 when the file cannot be created/opened, and 2 when surface encoding or writing fails. Renderer backend selects a handle-based or path-based native worker. +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) - **summary:** (status_out)(slot)(surface_slot) - decode the numbered save's separate `SAVE%02d.STH` thumbnail into a surface slot. - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2: op_0x1af_load_numbered_save_thumbnail@0x428240 opens SAVE%2.2d.STH and passes it plus operand 3's surface slot to the active renderer decoder. Corpus: one call in SAVE.BIN. -Status is 0 on success, 1 when the file cannot be opened, and 2 when image decoding fails. The thumbnail format is owned by the renderer codec and is not embedded in the numbered `.DAT` payload. +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) - **summary:** Clear the complete retained gfx-object registry while preserving allocated surface resources. Subsequent object queries return absent until draw/geometry operations recreate records. diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index 3bf76d4..fc536a2 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -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 continuity. -**Next persistence step:** implement numbered active-frame SAVE##.DAT layouts and paired BMP `.STH` -thumbnail lifecycle. JSON inspection/export and mod-owned namespaced state remain extended-mode work. +**Next persistence step:** implement the numbered pair/thumbnail/frame-boundary layer, then use that proven +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; opcode generator tests/lint and the Godot C# build are clean. SC0000 is now 127/129 distinct opcodes handled (98.4%); its only remaining effectful gaps are numbered-save restore boundaries `0x1ad`×6 and `0xae`×1. +### 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) The static INIT surface now accepts a universal packed script id for focused append inspection. diff --git a/docs/platform-portability.md b/docs/platform-portability.md index d7aacab..36ee712 100644 --- a/docs/platform-portability.md +++ b/docs/platform-portability.md @@ -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. - Movie payloads arrive from `IAssetStore` as owned bytes and decoded frames enter the compositor as the 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 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 | | 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 | +| 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 | | 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 | diff --git a/docs/remake-architecture-and-roadmap.md b/docs/remake-architecture-and-roadmap.md index 5ef38ce..491cb70 100644 --- a/docs/remake-architecture-and-roadmap.md +++ b/docs/remake-architecture-and-roadmap.md @@ -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` import/export and the packed-script/T1 ReadTextDB queue/commit/query lifecycle are also implemented, including `message:ReadTextSkip` ops `0x1ca`/`0x1cb` and state query `0x1cc`. Numbered active-frame state -and thumbnails remain later Phase B work. +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 - Add **editable named data overlays** mapped explicitly onto the VM's `*INIT`-produced state; external diff --git a/engine/Age.Engine.Tests/NumberedSavePairTests.cs b/engine/Age.Engine.Tests/NumberedSavePairTests.cs new file mode 100644 index 0000000..5e91eaf --- /dev/null +++ b/engine/Age.Engine.Tests/NumberedSavePairTests.cs @@ -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(() => 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 { [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 { [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 ObservedDepths { get; } = new(); + public override void FrameYield() => ObservedDepths.Add(Vm.SaveResumeFrameDepth); + } +} diff --git a/engine/Age.Engine.Tests/TestSupport.cs b/engine/Age.Engine.Tests/TestSupport.cs index fac364e..73b079f 100644 --- a/engine/Age.Engine.Tests/TestSupport.cs +++ b/engine/Age.Engine.Tests/TestSupport.cs @@ -3,6 +3,7 @@ using System.Linq; using Age.Engine.Diagnostics; using Age.Engine.Hosting; using Age.Engine.Model; +using Age.Engine.Sys4; /// Shared test doubles: a host that records observable effects, and an in-memory script /// provider for synthetic call-script targets. @@ -53,10 +54,18 @@ internal class RecordingHost : IHost public readonly List Warnings = new(); public readonly List CursorResources = new(); public readonly List AdvPagePresentationSuspended = new(); + public readonly Dictionary SurfacePixels = new(); public int CursorClearCount; public int SceneContextResets; public void ReportWarning(string message) => Warnings.Add(message); 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 DrawStringToSurface(int surfaceSlot, int x, int y, string text) => SurfaceStrings.Add((surfaceSlot, x, y, text)); diff --git a/engine/Age.Engine/Hosting/IHost.cs b/engine/Age.Engine/Hosting/IHost.cs index 234b4ca..077e71c 100644 --- a/engine/Age.Engine/Hosting/IHost.cs +++ b/engine/Age.Engine/Hosting/IHost.cs @@ -1,4 +1,5 @@ using Age.Engine.Model; +using Age.Engine.Sys4; namespace Age.Engine.Hosting; @@ -91,6 +92,10 @@ public interface IHost // numbered surfaces, then block while the engine alpha-composites target over source. void CrossfadeSurfaces(GfxState gfx, int sourceSurface, int targetSurface, long intervalArgument) { } void CreateTexture(int slot, int width, int height); + /// Return a stable RGBA snapshot of one numbered surface, or null when unavailable. + RgbaImage? CaptureSurfacePixels(int slot) => null; + /// Replace one numbered surface from decoded RGBA pixels. False means unsupported. + bool ReplaceSurfacePixels(int slot, RgbaImage image) => false; void SetTexture(long resourceId, int slot); void SetTexture(long resourceId, int slot, long colorKey) => SetTexture(resourceId, slot); void ReleaseSurface(int slot) { } diff --git a/engine/Age.Engine/Persistence/NativeDatStore.cs b/engine/Age.Engine/Persistence/NativeDatStore.cs index 97c1d63..321b7ed 100644 --- a/engine/Age.Engine/Persistence/NativeDatStore.cs +++ b/engine/Age.Engine/Persistence/NativeDatStore.cs @@ -7,16 +7,25 @@ public sealed record NativeSaveIdentity( uint CompatibilityId, string GameId, int SaveVersion1, - int SaveVersion2) + int SaveVersion2, + uint? NumberedCompatibilityId = null) { - public NativeSaveMetadata CreateMetadata(NativeSystemTime timestamp, uint accumulatedPlaySeconds) - => new(Magic, CompatibilityId, GameId, timestamp, accumulatedPlaySeconds, SaveVersion1, SaveVersion2); + public uint EffectiveNumberedCompatibilityId => NumberedCompatibilityId ?? CompatibilityId; - 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) 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."); if (!StringComparer.Ordinal.Equals(metadata.GameId, GameId)) throw new InvalidDataException("Native save game id mismatch."); @@ -41,8 +50,13 @@ public interface INativeDatStore void SaveShared(ReadOnlySpan payload, NativeSystemTime timestamp, uint accumulatedPlaySeconds); ReadTextDatabaseSnapshot? LoadReadText(); void SaveReadText(ReadTextDatabaseSnapshot snapshot); + NativeSaveMetadata? QueryNumberedMetadata(int slot); NativeSaveDocument? LoadNumbered(int slot); void SaveNumbered(int slot, ReadOnlySpan payload, NativeSystemTime timestamp, uint accumulatedPlaySeconds); + int DeleteNumberedPair(int slot); + int CopyNumberedPair(int sourceSlot, int destinationSlot); + byte[]? LoadNumberedThumbnail(int slot); + void SaveNumberedThumbnail(int slot, ReadOnlySpan data); } /// @@ -143,7 +157,21 @@ public sealed class DirectoryNativeDatStore : INativeDatStore public NativeSaveDocument? LoadNumbered(int 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( @@ -153,24 +181,84 @@ public sealed class DirectoryNativeDatStore : INativeDatStore uint accumulatedPlaySeconds) { byte[] encoded = NativeSaveContainerCodec.Encode( - payload, _identity.CreateMetadata(timestamp, accumulatedPlaySeconds)); + payload, _identity.CreateMetadata(timestamp, accumulatedPlaySeconds, numbered: true)); Directory.CreateDirectory(_root); 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 data) + { + Directory.CreateDirectory(_root); + WriteThrough(Path.Combine(_root, NumberedThumbnailFileName(slot)), data); + } + public static string NumberedFileName(int slot) { if (slot < 0) throw new ArgumentOutOfRangeException(nameof(slot)); 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)); - _identity.Validate(document.Metadata); + _identity.Validate(document.Metadata, numbered); 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 data) { using var stream = new FileStream( diff --git a/engine/Age.Engine/Persistence/NumberedThumbnailCodec.cs b/engine/Age.Engine/Persistence/NumberedThumbnailCodec.cs new file mode 100644 index 0000000..5b7b013 --- /dev/null +++ b/engine/Age.Engine/Persistence/NumberedThumbnailCodec.cs @@ -0,0 +1,105 @@ +using System.Buffers.Binary; +using Age.Engine.Sys4; + +namespace Age.Engine.Persistence; + +/// +/// 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. +/// +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 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 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."); + } +} diff --git a/engine/Age.Engine/Vm/GameSession.cs b/engine/Age.Engine/Vm/GameSession.cs index 8ff74f3..832fa6c 100644 --- a/engine/Age.Engine/Vm/GameSession.cs +++ b/engine/Age.Engine/Vm/GameSession.cs @@ -23,11 +23,16 @@ public sealed class GameSession public Dictionary GlobalStrings { get; } = new(); /// AGE's selected profile-wide cells plus native shared SAVE.DAT/RT.DAT lifecycle. public SharedProfile SharedProfile { get; } + /// Native shared/numbered save directory service used by persistence opcodes. + public INativeDatStore? NativeDatStore { get; } /// The live retained ADV backlog shared by every VM run in this session. public AdvTextHistory TextHistory { get; } = new(); - public GameSession(SharedProfile? sharedProfile = null) - => SharedProfile = sharedProfile ?? new SharedProfile(); + public GameSession(SharedProfile? sharedProfile = null, INativeDatStore? nativeDatStore = null) + { + SharedProfile = sharedProfile ?? new SharedProfile(); + NativeDatStore = nativeDatStore; + } public void Seed(int addr, long value) => Globals[addr] = value; public void SeedString(int addr, string value) => GlobalStrings[addr] = value; @@ -38,7 +43,7 @@ public sealed class GameSession ITraceSink? sink = null) { 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 GlobalStrings) vm.GlobalStrings[kv.Key] = kv.Value; diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 89a57c1..d53e0d7 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -27,6 +27,7 @@ public sealed class VirtualMachine private readonly Encoding _nativeStringEncoding; private readonly IScriptProvider? _provider; private readonly SharedProfile _sharedProfile; + private readonly INativeDatStore? _nativeDatStore; private static readonly bool _diagSetTexture = System.Environment.GetEnvironmentVariable("AGE_DIAG_SETTEX") == "1"; private ExecFrame _cur = null!; private int _depth; @@ -34,6 +35,8 @@ public sealed class VirtualMachine private readonly object _interactiveLock = new(); private readonly object _debugControlLock = new(); private readonly List _activeFrameNames = new(); + private readonly List _activeExecutionFrames = new(); + private ExecFrame? _saveResumeFrame; private ExecFrame? _debugActiveFrame; private long _debugActiveFrameId; private long _debugNextFrameId; @@ -75,6 +78,21 @@ public sealed class VirtualMachine public long Steps { get; private set; } public bool AutoMessageEnabled => _autoMessageEnabled; public bool MessageSkipEnabled => _messageSkipEnabled; + /// + /// 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. + /// + public int? SaveResumeFrameDepth + { + get + { + lock (_debugControlLock) + { + int index = _saveResumeFrame == null ? -1 : _activeExecutionFrames.IndexOf(_saveResumeFrame); + return index >= 0 ? index : null; + } + } + } /// True while a script-owned timed mouse/input callback loop (HISTORY/HIDEWIN family) owns input. public bool IsRawInputCallbackActive { @@ -97,13 +115,15 @@ public sealed class VirtualMachine public VirtualMachine(Script s, OpcodeTable t, IHost host, VmOptions? o = 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; Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); _nativeStringEncoding = Encoding.GetEncoding(_o.NativeStringCodePage); _sink = sink ?? NullTraceSink.Instance; TextHistory = textHistory ?? new AdvTextHistory(); _sharedProfile = sharedProfile ?? new SharedProfile(); + _nativeDatStore = nativeDatStore; } /// 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; _debugActiveFrameId = 0; _debugFrameReturnRequest = null; + _saveResumeFrame = null; } _autoMessageEnabled = false; _autoVoicePending = false; @@ -629,6 +650,7 @@ public sealed class VirtualMachine _debugActiveFrame = frame; _debugActiveFrameId = ++_debugNextFrameId; _activeFrameNames.Add(frame.Script.Name); + _activeExecutionFrames.Add(frame); } bool hostContextEntered = false; try @@ -664,7 +686,10 @@ public sealed class VirtualMachine lock (_debugControlLock) { if (ReferenceEquals(_debugFrameReturnRequest?.Frame, frame)) _debugFrameReturnRequest = null; + if (ReferenceEquals(_saveResumeFrame, frame)) _saveResumeFrame = null; if (_activeFrameNames.Count > 0) _activeFrameNames.RemoveAt(_activeFrameNames.Count - 1); + if (_activeExecutionFrames.Count > 0) + _activeExecutionFrames.RemoveAt(_activeExecutionFrames.Count - 1); _debugActiveFrame = previousDebugActiveFrame; _debugActiveFrameId = previousDebugActiveFrameId; } @@ -794,6 +819,110 @@ public sealed class VirtualMachine case "halve-strlen": // 0x1a6: strlen(native encoded bytes) >> 1 Write(a[0], NativeStringByteLength(ReadStr(a[1])) >> 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 { if (!TryResolveSharedProfileCell(a[0], isString: false, out int address)) diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index aafae5e..a394a7e 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -849,6 +849,34 @@ public sealed class GodotAdvHost : IHost public (int Width, int Height) GetTextureSize(int slot) => _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 // 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) { } diff --git a/godot/Main.cs b/godot/Main.cs index a6d5181..8b5b6c7 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -8,6 +8,7 @@ using Godot; using Age.Engine.Diagnostics; using Age.Engine.Hosting; using Age.Engine.Model; +using Age.Engine.Persistence; using Age.Engine.Sys4; using Age.Engine.Vm; 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; if (histFile != null) { _hist = new Age.Engine.Diagnostics.HistogramTraceSink(); 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, - 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) { _debugSceneEntries = DebugSceneCatalog.Build(scripts.Catalog); diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 44b945b..88abc09 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -3438,7 +3438,7 @@ abi_source = "kelebek+decode-validated" name = "query-numbered-save-metadata" category = "control" summary = "(status_out)(slot)(year)(month)(day)(hour)(minute)(second)(playtime_seconds) - validate a numbered `.DAT` header and return its timestamp and accumulated playtime." -details = "Status 0 means valid metadata was written, 1 means the file could not be opened, and 2 means its native header was invalid or incompatible. Metadata comes from the fixed 0x124-byte S3SD/S4SD container header; no payload decode is needed." +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 source = "investigation" confidence = "high" @@ -3726,7 +3726,7 @@ abi_source = "kelebek+decode-validated" name = "delete-numbered-save" category = "control" summary = "(status_out)(slot) - attempt to delete both `SAVE%02d.DAT` and its `SAVE%02d.STH` thumbnail." -details = "Both deletes are attempted. Status is 0 when both succeed, 1 when only the DAT delete fails, and 2 whenever the STH delete fails (taking precedence over a DAT failure)." +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 source = "investigation" confidence = "high" @@ -3753,7 +3753,7 @@ abi_source = "kelebek+decode-validated" name = "copy-numbered-save" category = "control" summary = "(status_out)(source_slot)(destination_slot) - copy both numbered `.DAT` state and `.STH` thumbnail, replacing destination files." -details = "Both copies are attempted with overwrite allowed. Status is 0 when both succeed, 1 when only the DAT copy fails, and 2 whenever the STH copy fails (taking precedence over a DAT failure)." +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 source = "investigation" confidence = "high" @@ -3785,6 +3785,7 @@ abi_source = "kelebek+decode-validated" name = "mark-save-resume-frame" 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." +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 source = "investigation" confidence = "high" @@ -3801,7 +3802,7 @@ abi_source = "kelebek+decode-validated" name = "write-numbered-save-thumbnail" category = "draw" summary = "(status_out)(slot)(surface_slot) - encode the selected surface into the numbered save's separate `SAVE%02d.STH` thumbnail file." -details = "Status is 0 on success, 1 when the file cannot be created/opened, and 2 when surface encoding or writing fails. Renderer backend selects a handle-based or path-based native worker." +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 source = "investigation" confidence = "high" @@ -3833,7 +3834,7 @@ abi_source = "kelebek+decode-validated" name = "load-numbered-save-thumbnail" category = "draw" summary = "(status_out)(slot)(surface_slot) - decode the numbered save's separate `SAVE%02d.STH` thumbnail into a surface slot." -details = "Status is 0 on success, 1 when the file cannot be opened, and 2 when image decoding fails. The thumbnail format is owned by the renderer codec and is not embedded in the numbered `.DAT` payload." +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 source = "investigation" confidence = "high"