diff --git a/docs/PROJECT-STRUCTURE.md b/docs/PROJECT-STRUCTURE.md index 9e9f6c6..fa97ac1 100644 --- a/docs/PROJECT-STRUCTURE.md +++ b/docs/PROJECT-STRUCTURE.md @@ -103,7 +103,8 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings) ├── engine/ DELIVERABLE — the .NET VM core (AgeEngine.sln: Age.Engine / Age.Cli / tests) │ ├── Age.Engine/Sys4/ runtime catalog parser, loose-first bounded ALF asset store, │ script provider, AGF/LZSS and Windows CUR decoders, and resource facade - │ └── Age.Engine/Persistence/ native S3SD/S4SD container codec and profile/numbered DAT lifecycle seam + │ └── Age.Engine/Persistence/ native S3SD/S4SD container + shared-payload codecs, + │ profile-owned selected cells, and shared/numbered DAT lifecycle seam ├── native/ authored native runtime boundaries │ └── age_movie_ffmpeg/ project-owned FFmpeg C ABI, immutable Windows dependency manifest, │ and bootstrap/build scripts (outputs stay under disposable build/) diff --git a/docs/engine-re.md b/docs/engine-re.md index 3515f03..a95ff3b 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -198,14 +198,12 @@ and pinning the real handler plus its paired reader resolves the service: globals with consecutive loads; `SYSTEM4.BIN` stores its initialized-config flag; gameplay and ADV scripts store selected array cells rather than the whole VM global bank. -**Port verdict: semantics solved, persistence implementation deferred.** The old unread `GfxState` -`HashSet` and legacy VM handler have been removed, so both opcodes now appear as effectful gaps rather than -false implementations. A faithful service needs a profile-owned `global-cell-index → raw-int32` map shared -across VM/script lifetimes, with `0x1a2` upsert and `0x1a3` load-or-zero, then a deliberate persistence -boundary. The current `GameSession` JSON serializes the entire global bank, which can accidentally preserve -some values but cannot reproduce AGE's selected-cell restore/reset lifecycle. Do not add another ad-hoc JSON -field until the unified shared `SAVE.DAT`/`RT.DAT`/numbered-save architecture chooses ownership and migration. -This deferral is now explicit in opcode coverage; it is not a safe-noop claim. +**Port verdict (implemented 2026-07-24):** `SharedProfile` now owns the selected +`global-cell-index → raw-int32` map across VM/script lifetimes. Opcode `0x1a2` upserts and `0x1a3` +loads-or-zero for direct globals and local pointers resolved into the global bank. The profile is shared by +fresh scene VMs through `GameSession`, while `GameSession` JSON remains intentionally unchanged and does not +silently alias this native profile domain. The paired string service and native `SAVE.DAT` payload/store +lifecycle are implemented with it; see the persistence-family section below. The FIELD snippet `lookup(0x5f0ed, 0x62ccf); mov(ptr,1); lookup(...); 0x1a2(ptr)` therefore persists that selected global array cell to the shared profile. It does not resolve decision→scene; scene dispatch remains @@ -1648,6 +1646,32 @@ Thumbnail I/O uses `0=success`, `1=open/create failure`, and `2=codec failure`. expects its caller to pre-seed status zero: on success it starts the resume sequence without rewriting that operand, while a missing file writes one. +#### Save-root resolution + +Save paths are native engine policy, not script-provided strings. Every numbered/shared handler first calls +`save_root_resolve@0x40b880`, then formats one of the engine-owned names (`SAVE%2.2d.DAT`, +`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 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 +appends `SavePath`. Otherwise the configured `SavePath` is used directly. It probes +`\SAVE.DAT`; if that file does not exist, it walks the root and creates missing directory +components before returning it. + +Himegari's SYS4INI trailer supplies: + +```text +USEAPPDATAFOLDER=1 +SAVEPATH=Eushully\姫狩りダンジョンマイスター\SAVE +``` + +Thus its normal modern-Windows root is +`%LOCALAPPDATA%\Eushully\姫狩りダンジョンマイスター\SAVE`. The complete filenames and backup +lifecycle are fixed by AGE after that root has been selected; no persistence opcode accepts an arbitrary +path or filename. + #### Shared `SAVE.DAT` `shared_profile_save@0x40c950` writes `$$SAVE.DAT`, replaces `SAVE.DAT`, and keeps `SAVE.BAK`; it performs @@ -1658,17 +1682,27 @@ use this backup transaction, but a successful numbered serialization flushes the After the common container decode, the shared logical payload is: 1. a DWORD catalog/compatibility count followed by that many DWORDs; -2. a DWORD integer-entry count, then fixed 16-byte entries `{ascii_key[12], raw_value_u32}`; -3. a DWORD string-entry count and DWORD padded string-blob length, then concatenated - `ascii_key\0value\0` pairs with DWORD padding; +2. a DWORD integer-entry count, then fixed 16-byte entries `{typed_key[12], raw_value_u32}`; +3. a DWORD string-entry count and a string-blob **DWORD count**, then concatenated + `typed_key\0value\0` pairs with DWORD padding; 4. for shared save version at least 3.10, a 256-DWORD selector/count table, a DWORD extra-count, and that many extra DWORDs; -5. a trailing zero DWORD. +5. one explicit trailing zero DWORD plus eight historical allocation-slack DWORDs (nine reserved tail + DWORDs in decoded native Himegari files). -Integer-cell ops `0x1a2`/`0x1a3` use keys `3%08x`; string-cell ops `0x1a9`/`0x1aa` use `5%08x`. In both -cases the hexadecimal portion is the VM lvalue's resolved global-bank index. The string table therefore -is not an incidental settings blob: it is the paired profile-wide selected-cell service for string globals, -with insert-or-assign and empty-on-miss behavior. +The key prefix is binary, not the ASCII character suggested by shorthand such as `3%08x`: +integer-cell ops `0x1a2`/`0x1a3` use raw byte `0x03` followed by eight lowercase ASCII hex digits; +string-cell ops `0x1a9`/`0x1aa` use raw byte `0x05` plus the same address text. In both cases the +hexadecimal portion is the VM lvalue's resolved global-bank index. Integer fixed-key bytes 10 and 11 are +unused native stack padding and cannot be required to be zero when importing an original file. The string +table is therefore not an incidental settings blob: it is the paired profile-wide selected-cell service +for string globals, with insert-or-assign and empty-on-miss behavior. + +The installed native Himegari `SAVE.DAT` provides a direct compatibility oracle for this shape: +version 3.10 decodes to 13,210 opaque catalog values, 24,070 integer cells, 605 string cells, selector +entry 1 equal to 81, 83 extended values, and the nine-DWORD reserved tail. The port decoder imports that +file exactly; catalog/extended/tail sections remain opaque and are preserved losslessly rather than +reinterpreted as mod state. #### Common `.DAT` container @@ -1740,15 +1774,20 @@ 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, codec foundation implemented):** +**Port correspondence (2026-07-24, shared payload and selected cells 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 `LzssDecoder`, falling back to native verbatim storage when compression does not shrink. The `INativeDatStore` boundary and `DirectoryNativeDatStore` own shared `$$SAVE.DAT` → `SAVE.DAT` / -`SAVE.BAK` replacement/fallback and direct numbered `SAVE##.DAT` writes. Payload schemas, `RT.DAT`, -thumbnails, and opcode wiring remain deliberately above or after this layer; the existing `GameSession` -JSON snapshot is unchanged. +`SAVE.BAK` replacement/fallback and direct numbered `SAVE##.DAT` writes. + +`SharedProfilePayloadCodec` owns the typed shared logical layout, including native CP932 strings and +lossless opaque-section preservation. `SharedProfile` owns selected integer/string maps and explicit +load/save lifecycle; `GameSession` injects it into every fresh VM. Opcodes `0x1a2`/`0x1a3` and +`0x1a9`/`0x1aa` now implement native upsert and missing-value defaults for direct cells and resolved +global pointers. `RT.DAT`, thumbnails, and numbered active-frame payloads remain later layers; the existing +`GameSession` JSON snapshot is unchanged. ### Opcode `0xae` continues numbered-save stack restoration (2026-07-20) @@ -2880,9 +2919,9 @@ These handlers and their newly understood workers are renamed/commented in Ghidr The exact source metadata lives in `vm-map/opcodes.toml`. The port now implements this cluster against the shared Godot frame clock, retained graphics state, movie decoder state, mutable RGBA surfaces, and delayed voice service. `0x207` copies colorkey-baked source pixels into immutable published snapshots so compositor -reads cannot race VM-side mutations. The first complete player attack remains the manual acceptance test; -the implementation deliberately leaves BTL's two `0x1a2` shared-profile writes deferred because they do -not feed same-exchange combat state. +reads cannot race VM-side mutations. The first complete player attack remains the manual acceptance test. +BTL's two `0x1a2` shared-profile writes were initially deferred because they do not feed same-exchange +combat state; the unified profile service now handles them through the ordinary opcode dispatch. ### Movement/attack flood-fill FIFO -- opcodes `0x132`-`0x134` (2026-07-21) diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 8dff8cc..d428312 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -496,32 +496,32 @@ Status 0 means valid metadata was written, 1 means the file could not be opened, The caller pre-seeds status to zero. A missing/open failure writes 1; success starts the asynchronous native stack-restoration rendezvous and does not overwrite that zero. This is the ordinary load-game path, unlike data-only opcode 0x19f. ### 0x1a2 `store-shared-profile-int` (store-shared-profile-int, argc 1) -- **summary:** 0x1a2 (cell) — snapshot the selected global integer cell into AGE's shared SAVE.DAT profile table. The key is `3%08x`, where the address is resolved through global/local pointer operands; the stored value is the cell's current raw 32-bit value. Insert-or-assign semantics replace an existing entry. +- **summary:** 0x1a2 (cell) — snapshot the selected global integer cell into AGE's shared SAVE.DAT profile table. Its native key is raw type byte `0x03` followed by eight lowercase ASCII hex digits for the lvalue's resolved global-bank index; the stored value is the cell's current raw 32-bit value. Insert-or-assign semantics replace an existing entry. - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2: op_0x1a2_store_shared_profile_int@0x42d360 reads operand 1 with vm_operand_fetch, resolves its cell index with vm_operand_lvalue (accepted types 3/6/0xc), formats `3%08x`, and calls hash_table_insert_or_assign with ECX=ctx+0x5190. shared_profile_payload_write@0x430a20 serializes each 12-byte key plus 32-bit value to SAVE.DAT. Corpus: 17,585 calls in 315 scripts; 17,539 use local-ptr after lookup-array and 46 use global-int. -Paired reader 0x1a3 restores the same cell, returning zero when the key is absent. The table is embedded at ctx+0x5190, explicitly enumerated by shared_profile_payload_write into shared SAVE.DAT, and reconstructed by shared_profile_payload_read. It is not the retained gfx map, numbered SAVE##.DAT state, or RT.DAT ReadTextDB. Port status (2026-07-20): deliberately unimplemented pending the unified shared-profile storage boundary; the former inert GfxState HashSet approximation was removed so coverage reports the gap. +Paired reader 0x1a3 restores the same cell, returning zero when the key is absent. The table is embedded at ctx+0x5190, explicitly enumerated by shared_profile_payload_write into shared SAVE.DAT, and reconstructed by shared_profile_payload_read. It is not the retained gfx map, numbered SAVE##.DAT state, or RT.DAT ReadTextDB. Port status (2026-07-24): implemented through profile-owned SharedProfile state shared across fresh scene VMs and the native shared-payload/store lifecycle; direct globals and resolved global local-pointers are covered. ### 0x1a3 `load-shared-profile-int` (load-shared-profile-int, argc 1) -- **summary:** 0x1a3 (cell) — restore the selected global integer cell from AGE's shared SAVE.DAT profile table. It resolves the same `3%08x` cell-address key as 0x1a2 and overwrites the operand with the stored raw 32-bit value, or zero when the key is absent. +- **summary:** 0x1a3 (cell) — restore the selected global integer cell from AGE's shared SAVE.DAT profile table. It resolves the same raw `0x03` plus eight-hex-digit cell-address key as 0x1a2 and overwrites the operand with the stored raw 32-bit value, or zero when the key is absent. - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2: op_0x1a3_load_shared_profile_int@0x427e90 resolves operand 1 with vm_operand_lvalue, calls shared_profile_int_lookup@0x4199d0 with the shared-profile object at ctx+0x4d7c, then vm_operand_write stores the result. The wrapper searches the table at object+0x414 = ctx+0x5190 and returns zero on a miss. Current override-aware corpus: 77 calls in 12 scripts. -This is the read half of the shared-profile integer service, not a string operation. LOADCONFIG.BIN restores configuration globals with it; GAMESTART/GAMECLEAR and array-pointer call sites restore other selected profile-wide values. Port status (2026-07-20): deliberately unimplemented with 0x1a2 pending the unified shared-profile storage boundary. +This is the read half of the shared-profile integer service, not a string operation. LOADCONFIG.BIN restores configuration globals with it; GAMESTART/GAMECLEAR and array-pointer call sites restore other selected profile-wide values. Port status (2026-07-24): implemented with 0x1a2 through the shared profile service and native payload codec. ### 0x1a9 `store-shared-profile-string` (store-shared-profile-string, argc 1) -- **summary:** (cell) - snapshot the selected global string cell into AGE's shared `SAVE.DAT` profile table under key `5%08x`, replacing any earlier value. +- **summary:** (cell) - snapshot the selected global string cell into AGE's shared `SAVE.DAT` profile table under a raw `0x05` type byte plus eight lowercase ASCII hex digits for the resolved global-bank index, replacing any earlier value. - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2: op_0x1a9_store_shared_profile_string@0x42d3e0 fetches operand 1's string, resolves the lvalue cell index, and calls shared_profile_store_string_by_typed_key@0x42d2b0 with type prefix 5. Corpus: 17 calls in GAMECLEAR, INPUTNAME, SAVE, and SELSTAGE. -This is the string counterpart to integer-store opcode 0x1a2. The value is profile-wide rather than numbered-slot-local and is written by the shared profile lifecycle. +This is the string counterpart to integer-store opcode 0x1a2. The value is profile-wide rather than numbered-slot-local and is written by the shared profile lifecycle. Port status (2026-07-24): implemented for direct global strings and resolved global string-pointers. ### 0x1aa `load-shared-profile-string` (load-shared-profile-string, argc 1) -- **summary:** (cell) - restore the selected global string cell from AGE's shared `SAVE.DAT` profile table using key `5%08x`; a missing key yields the native empty-string default. +- **summary:** (cell) - restore the selected global string cell from AGE's shared `SAVE.DAT` profile table using the raw `0x05` plus eight-hex-digit key; a missing key yields the native empty-string default. - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2: op_0x1aa_load_shared_profile_string@0x42bd90 resolves operand 1's lvalue index, calls shared_profile_load_string_by_typed_key@0x419ca0 with type prefix 5, and writes the result back through the VM string lvalue. Corpus: seven calls in GAMESTART, INIT2, INPUTNAME, and SAVE. -This is the paired reader for opcode 0x1a9 and the string counterpart to integer-load opcode 0x1a3. +This is the paired reader for opcode 0x1a9 and the string counterpart to integer-load opcode 0x1a3. Port status (2026-07-24): implemented through the shared profile service and native payload codec. ### 0x1ab `delete-numbered-save` (delete-numbered-save, argc 2) - **summary:** (status_out)(slot) - attempt to delete both `SAVE%02d.DAT` and its `SAVE%02d.STH` thumbnail. diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index 229af55..411b4ac 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -3381,6 +3381,30 @@ integer/string cell maps, then connect `0x1a2`/`0x1a3` and `0x1a9`/`0x1aa`. Add read-message lifecycle after that shared ownership is live; numbered active-frame layouts remain the later, larger payload. +### Persistence implementation step 2 — shared SAVE.DAT payload and selected cells (2026-07-24) + +The typed shared payload and profile ownership are now implemented. `SharedProfilePayloadCodec` reads and +writes the catalog block, fixed integer entries, DWORD-counted CP932 string blob, version-3.10 selector and +extra arrays, and native reserved tail. `SharedProfile` preserves the catalog/extended sections opaquely, +owns selected raw-int32 and string maps, and explicitly loads/saves through `INativeDatStore`. +`GameSession` shares that profile across fresh scene VMs without adding it to the existing whole-bank JSON +diagnostic snapshot. + +The original AppData `SAVE.DAT` served as a read-only compatibility oracle and decodes completely: +13,210 catalog values, 24,070 integer cells, 605 string cells, selector 1 = 81, 83 extended values, and +nine reserved tail DWORDs. That check corrected three reconnaissance details before implementation: typed +keys begin with raw bytes `0x03`/`0x05` rather than ASCII digits; the string length is a DWORD count; and +the native writer's explicit terminator plus historical allocation slack leaves nine tail DWORDs. + +VM opcodes `0x1a2`/`0x1a3` and `0x1a9`/`0x1aa` now implement insert-or-assign plus native zero/empty +miss defaults. Direct global cells and the shipped local-pointer-to-global idiom are covered, including +fresh-VM continuity through `GameSession`. Five focused shared-profile tests plus the seven container/store +tests cover binary shape, malformed input, native lifecycle, pointer lvalues, miss behavior, and JSON-domain +separation. + +**Next persistence step:** add native `RT.DAT` and connect the already-mapped ReadTextDB queue/commit/query +lifecycle. Numbered active-frame saves and `.STH` remain the later, larger payload slice. + ## Data-semantics sidebar: focused append EBINIT inspection (2026-07-24) The static INIT surface now accepts a universal packed script id for focused append inspection. diff --git a/docs/remake-architecture-and-roadmap.md b/docs/remake-architecture-and-roadmap.md index 2f61829..e0bf9e9 100644 --- a/docs/remake-architecture-and-roadmap.md +++ b/docs/remake-architecture-and-roadmap.md @@ -466,8 +466,10 @@ domains and lifecycle—shared `SAVE.DAT`/`SAVE.BAK`, `RT.DAT`/`RT.BAK`, numbere paired BMP `.STH` thumbnails. Keep the codec behind a profile/save-service boundary. Human-readable JSON inspection/export, migrations, and namespaced mod state are additive extended-mode work, not a replacement for compatibility-mode import/export. The recovered native contract lives in -`docs/engine-re.md`. The common container codec and payload-agnostic shared/numbered DAT store boundary -landed on 2026-07-24; logical payload services and opcode wiring remain Phase B work. +`docs/engine-re.md`. The common container codec/store and typed shared `SAVE.DAT` payload landed on +2026-07-24. Profile-owned selected integer/string cells now survive across scene VMs and are wired to +their four native opcodes while opaque catalog/version sections round-trip unchanged. `RT.DAT` is the +next profile domain; numbered active-frame state and thumbnails remain later Phase B work. ### Phase C — Externalize & modding foundation - Add **editable named data overlays** mapped explicitly onto the VM's `*INIT`-produced state; external diff --git a/docs/sys4-format-notes.md b/docs/sys4-format-notes.md index 1ae5341..eb315f7 100644 --- a/docs/sys4-format-notes.md +++ b/docs/sys4-format-notes.md @@ -26,6 +26,11 @@ The Himegari trailer contains `SCREENX=800` and `SCREENY=600`; Kamidori's indepe initialization and the executable's `640x480` fallback are documented in `engine-re.md`; fullscreen/display-mode settings are a separate concern from these logical dimensions. +The same Himegari trailer also contains `USEAPPDATAFOLDER=1` and +`SAVEPATH=Eushully\姫狩りダンジョンマイスター\SAVE`. AGE's native save-root resolver consumes those +settings to select `%LOCALAPPDATA%` plus that relative path on modern Windows. Filename patterns remain +native policy rather than trailer or script strings; see `engine-re.md` under "Save-root resolution." + ## Header — CONFIRMED Fixed 60-byte (0x3C) header: 8-byte magic + thirteen little-endian u32 fields. diff --git a/engine/Age.Engine.Tests/SharedProfileTests.cs b/engine/Age.Engine.Tests/SharedProfileTests.cs new file mode 100644 index 0000000..4775485 --- /dev/null +++ b/engine/Age.Engine.Tests/SharedProfileTests.cs @@ -0,0 +1,193 @@ +using System.Buffers.Binary; +using Age.Engine.Model; +using Age.Engine.Persistence; +using Age.Engine.Sys4; +using Age.Engine.Vm; + +public class SharedProfileTests +{ + private const int Immediate = 0, InlineString = 2, GlobalInt = 3, GlobalString = 5, + LocalPointer = 12, LocalStringPointer = 14; + private static readonly NativeSystemTime Timestamp = + new(2026, 7, 5, 24, 13, 42, 17, 321); + + [Fact] + public void SharedPayloadRoundTripsNativeTypedKeysAndOpaqueSections() + { + var selectors = new uint[SharedProfilePayloadCodec.ExtendedSelectorCount]; + selectors[1] = 81; + var payload = new SharedProfilePayload( + catalogCompatibilityValues: [0x11223344, 0xaabbccdd], + integerCells: new Dictionary { [0x1234] = 0xffffffff }, + stringCells: new Dictionary { [0x5678] = "姫狩り\0ignored" }, + extendedSelectorCounts: selectors, + extendedValues: [7, 8, 9]); + var metadata = new NativeSaveMetadata( + NativeSaveMagic.S4SD, 123, "himegari-test", Timestamp, 42, 3, 10); + + byte[] logical = SharedProfilePayloadCodec.Encode(payload, metadata); + + Assert.Equal(2u, BinaryPrimitives.ReadUInt32LittleEndian(logical)); + Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(logical.AsSpan(12))); + Assert.Equal(0x03, logical[16]); + Assert.Equal("00001234", System.Text.Encoding.ASCII.GetString(logical, 17, 8)); + Assert.Equal(0, logical[25]); + Assert.Equal(0xffffffffu, BinaryPrimitives.ReadUInt32LittleEndian(logical.AsSpan(28))); + + byte[] file = NativeSaveContainerCodec.Encode( + logical, metadata, new NativeSaveEncodingOptions(0x12345678, 3)); + NativeSaveDocument document = NativeSaveContainerCodec.Decode(file); + SharedProfilePayload decoded = + SharedProfilePayloadCodec.Decode(document.Payload, document.Metadata); + + Assert.Equal(payload.CatalogCompatibilityValues, decoded.CatalogCompatibilityValues); + Assert.Equal(0xffffffffu, decoded.IntegerCells[0x1234]); + Assert.Equal("姫狩り", decoded.StringCells[0x5678]); + Assert.Equal(81u, decoded.ExtendedSelectorCounts[1]); + Assert.Equal(new uint[] { 7, 8, 9 }, decoded.ExtendedValues); + Assert.Equal(9, decoded.ReservedTail.Count); + Assert.All(decoded.ReservedTail, value => Assert.Equal(0u, value)); + } + + [Fact] + public void SharedPayloadRejectsMalformedTypedKeysAndTrailingData() + { + var metadata = new NativeSaveMetadata( + NativeSaveMagic.S4SD, 1, "test", Timestamp, 0, 1, 1); + byte[] logical = SharedProfilePayloadCodec.Encode( + new SharedProfilePayload(integerCells: new Dictionary { [1] = 2 }), + metadata); + + byte[] badType = logical.ToArray(); + badType[8] = 0x05; + Assert.Contains("type tag", Assert.Throws( + () => SharedProfilePayloadCodec.Decode(badType, metadata)).Message); + + byte[] trailing = [.. logical, 0, 0, 0, 0]; + Assert.Contains("trailing", Assert.Throws( + () => SharedProfilePayloadCodec.Decode(trailing, metadata)).Message); + } + + [Fact] + public void SharedProfilePersistsSelectedCellsThroughNativeDirectoryStore() + { + string root = Path.Combine(Path.GetTempPath(), "age-shared-profile-" + Guid.NewGuid().ToString("N")); + try + { + var identity = new NativeSaveIdentity( + NativeSaveMagic.S4SD, 123, "himegari-test", 3, 10); + var store = new DirectoryNativeDatStore(root, identity); + var profile = new SharedProfile(); + var selectors = new uint[SharedProfilePayloadCodec.ExtendedSelectorCount]; + selectors[1] = 81; + profile.Replace(new SharedProfilePayload( + catalogCompatibilityValues: [10, 20, 30], + extendedSelectorCounts: selectors, + extendedValues: [99])); + profile.StoreInteger(0x100, -7); + profile.StoreString(0x200, "リリィ"); + + profile.Save(store, Timestamp, 456); + + var loaded = new SharedProfile(); + Assert.True(loaded.Load(store)); + Assert.Equal(-7, loaded.LoadInteger(0x100)); + Assert.Equal("リリィ", loaded.LoadString(0x200)); + Assert.Equal(0, loaded.LoadInteger(0x101)); + Assert.Equal(string.Empty, loaded.LoadString(0x201)); + Assert.Equal(new uint[] { 10, 20, 30 }, + loaded.Snapshot().CatalogCompatibilityValues); + Assert.Equal(81u, loaded.Snapshot().ExtendedSelectorCounts[1]); + Assert.Equal(456u, store.LoadShared()!.Metadata.AccumulatedPlaySeconds); + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void SelectedCellOpcodesUseResolvedGlobalAddressesAndNativeMissDefaults() + { + OpcodeTable table = OpcodeTableJson.Load(Paths.OpcodesJson); + int move = table.ByLabel("mov")!.Value; + int lookup = table.ByLabel("lookup-array")!.Value; + Script script = ScriptAssembler.Assemble(table, "SHARED_PROFILE_OPS", new List<(int, Operand[])> + { + (move, [new Operand(GlobalInt, 0x700), new Operand(Immediate, 123)]), + (0x1a2, [new Operand(GlobalInt, 0x700)]), + (move, [new Operand(GlobalInt, 0x700), new Operand(Immediate, 0)]), + (0x1a3, [new Operand(GlobalInt, 0x700)]), + (0x1a3, [new Operand(GlobalInt, 0x701)]), + + (lookup, [ + new Operand(LocalPointer, 0), new Operand(GlobalInt, 0x710), new Operand(Immediate, 2), + ]), + (move, [new Operand(LocalPointer, 0), new Operand(Immediate, 77)]), + (0x1a2, [new Operand(LocalPointer, 0)]), + (move, [new Operand(LocalPointer, 0), new Operand(Immediate, 0)]), + (0x1a3, [new Operand(LocalPointer, 0)]), + + (move, [new Operand(GlobalString, 0x800), new Operand(InlineString, 0)]), + (0x1a9, [new Operand(GlobalString, 0x800)]), + (move, [new Operand(GlobalString, 0x800), new Operand(InlineString, 1)]), + (0x1aa, [new Operand(GlobalString, 0x800)]), + (0x1aa, [new Operand(GlobalString, 0x801)]), + + (0x63, [new Operand(LocalStringPointer, 0), new Operand(GlobalString, 0x810)]), + (move, [new Operand(LocalStringPointer, 0), new Operand(InlineString, 2)]), + (0x1a9, [new Operand(LocalStringPointer, 0)]), + (move, [new Operand(LocalStringPointer, 0), new Operand(InlineString, 1)]), + (0x1aa, [new Operand(LocalStringPointer, 0)]), + (0x2, []), + }, ["リリィ", "changed", "使い魔"]); + var profile = new SharedProfile(); + var vm = new VirtualMachine( + script, table, new RecordingHost(), sharedProfile: profile); + vm.Globals[0x701] = 999; + vm.GlobalStrings[0x801] = "not empty"; + + vm.Run(); + + Assert.Equal("exit", vm.HaltReason); + Assert.Equal(123, vm.Globals[0x700]); + Assert.Equal(0, vm.Globals[0x701]); + Assert.Equal(77, vm.Globals[0x712]); + Assert.Equal("リリィ", vm.GlobalStrings[0x800]); + Assert.Equal(string.Empty, vm.GlobalStrings[0x801]); + Assert.Equal("使い魔", vm.GlobalStrings[0x810]); + Assert.Equal(123, profile.LoadInteger(0x700)); + Assert.Equal(77, profile.LoadInteger(0x712)); + Assert.Equal("リリィ", profile.LoadString(0x800)); + Assert.Equal("使い魔", profile.LoadString(0x810)); + } + + [Fact] + public void GameSessionCarriesSharedProfileAcrossFreshSceneVmsButJsonDoesNotAliasIt() + { + OpcodeTable table = OpcodeTableJson.Load(Paths.OpcodesJson); + int move = table.ByLabel("mov")!.Value; + Script store = ScriptAssembler.Assemble(table, "PROFILE_STORE", new List<(int, Operand[])> + { + (move, [new Operand(GlobalInt, 0x900), new Operand(Immediate, 42)]), + (0x1a2, [new Operand(GlobalInt, 0x900)]), + (0x2, []), + }, []); + Script load = ScriptAssembler.Assemble(table, "PROFILE_LOAD", new List<(int, Operand[])> + { + (move, [new Operand(GlobalInt, 0x900), new Operand(Immediate, 0)]), + (0x1a3, [new Operand(GlobalInt, 0x900)]), + (0x2, []), + }, []); + var session = new GameSession(); + + session.RunScene(store, table, new RecordingHost()); + session.RunScene(load, table, new RecordingHost()); + + Assert.Equal(42, session.Globals[0x900]); + Assert.Equal(42, session.SharedProfile.LoadInteger(0x900)); + GameSession jsonClone = GameSession.FromJson(session.ToJson()); + Assert.Equal(42, jsonClone.Globals[0x900]); + Assert.Equal(0, jsonClone.SharedProfile.LoadInteger(0x900)); + } +} diff --git a/engine/Age.Engine/Persistence/NativeDatStore.cs b/engine/Age.Engine/Persistence/NativeDatStore.cs index d883918..394cfb2 100644 --- a/engine/Age.Engine/Persistence/NativeDatStore.cs +++ b/engine/Age.Engine/Persistence/NativeDatStore.cs @@ -36,6 +36,7 @@ public sealed record NativeSaveIdentity( public interface INativeDatStore { + NativeSaveIdentity Identity { get; } NativeSaveDocument? LoadShared(); void SaveShared(ReadOnlySpan payload, NativeSystemTime timestamp, uint accumulatedPlaySeconds); NativeSaveDocument? LoadNumbered(int slot); @@ -55,6 +56,7 @@ public sealed class DirectoryNativeDatStore : INativeDatStore private readonly string _root; private readonly NativeSaveIdentity _identity; + public NativeSaveIdentity Identity => _identity; public DirectoryNativeDatStore(string root, NativeSaveIdentity identity) { diff --git a/engine/Age.Engine/Persistence/SharedProfile.cs b/engine/Age.Engine/Persistence/SharedProfile.cs new file mode 100644 index 0000000..7b1b069 --- /dev/null +++ b/engine/Age.Engine/Persistence/SharedProfile.cs @@ -0,0 +1,443 @@ +using System.Buffers.Binary; +using System.Collections.ObjectModel; +using System.Globalization; +using System.Text; + +namespace Age.Engine.Persistence; + +/// +/// Typed logical contents of AGE's shared SAVE.DAT payload. The catalog and extended arrays are +/// intentionally opaque: the selected-cell service owns only the integer and string maps, while +/// native import/export must preserve the other engine-owned sections losslessly. +/// +public sealed class SharedProfilePayload +{ + public IReadOnlyList CatalogCompatibilityValues { get; } + public IReadOnlyDictionary IntegerCells { get; } + public IReadOnlyDictionary StringCells { get; } + public IReadOnlyList ExtendedSelectorCounts { get; } + public IReadOnlyList ExtendedValues { get; } + public IReadOnlyList ReservedTail { get; } + + public SharedProfilePayload( + IEnumerable? catalogCompatibilityValues = null, + IEnumerable>? integerCells = null, + IEnumerable>? stringCells = null, + IEnumerable? extendedSelectorCounts = null, + IEnumerable? extendedValues = null, + IEnumerable? reservedTail = null) + { + uint[] selectors = extendedSelectorCounts?.ToArray() ?? Array.Empty(); + if (selectors.Length is not 0 and not SharedProfilePayloadCodec.ExtendedSelectorCount) + throw new ArgumentException( + $"Extended selector table must contain exactly {SharedProfilePayloadCodec.ExtendedSelectorCount} values.", + nameof(extendedSelectorCounts)); + + uint[] tail = reservedTail?.ToArray() ?? new uint[SharedProfilePayloadCodec.ReservedTailDwordCount]; + if (tail.Length != SharedProfilePayloadCodec.ReservedTailDwordCount) + throw new ArgumentException( + $"Reserved tail must contain exactly {SharedProfilePayloadCodec.ReservedTailDwordCount} DWORDs.", + nameof(reservedTail)); + + CatalogCompatibilityValues = Array.AsReadOnly( + catalogCompatibilityValues?.ToArray() ?? Array.Empty()); + IntegerCells = new ReadOnlyDictionary( + integerCells?.ToDictionary() ?? new Dictionary()); + StringCells = new ReadOnlyDictionary( + stringCells?.ToDictionary() ?? new Dictionary()); + ExtendedSelectorCounts = Array.AsReadOnly(selectors); + ExtendedValues = Array.AsReadOnly(extendedValues?.ToArray() ?? Array.Empty()); + ReservedTail = Array.AsReadOnly(tail); + } +} + +/// +/// Codec for the logical payload inside shared SAVE.DAT's common S3SD/S4SD container. +/// Keys use a raw one-byte AGE type tag followed by eight lowercase ASCII hex digits. +/// +public static class SharedProfilePayloadCodec +{ + public const int ExtendedSelectorCount = 0x100; + // Native writer emits one explicit zero terminator and its historical allocation formula + // leaves eight additional DWORDs at the end of the logical payload. + public const int ReservedTailDwordCount = 9; + + private const byte IntegerTypeTag = 0x03; + private const byte StringTypeTag = 0x05; + private const int FixedIntegerEntrySize = 0x10; + private const int FixedKeyFieldSize = 0x0c; + private static readonly Encoding ShiftJis = CreateShiftJis(); + + public static byte[] Encode(SharedProfilePayload payload, NativeSaveMetadata metadata) + { + ArgumentNullException.ThrowIfNull(payload); + ArgumentNullException.ThrowIfNull(metadata); + + using var stream = new MemoryStream(); + using var writer = new BinaryWriter(stream, Encoding.ASCII, leaveOpen: true); + + WriteDwordArray(writer, payload.CatalogCompatibilityValues); + + KeyValuePair[] integers = payload.IntegerCells + .OrderBy(entry => entry.Key) + .ToArray(); + writer.Write(checked((uint)integers.Length)); + Span entryBuffer = stackalloc byte[FixedIntegerEntrySize]; + foreach (var (address, value) in integers) + { + entryBuffer.Clear(); + WriteTypedKey(entryBuffer[..FixedKeyFieldSize], IntegerTypeTag, address); + BinaryPrimitives.WriteUInt32LittleEndian(entryBuffer[FixedKeyFieldSize..], value); + writer.Write(entryBuffer); + } + + KeyValuePair[] strings = payload.StringCells + .OrderBy(entry => entry.Key) + .ToArray(); + writer.Write(checked((uint)strings.Length)); + byte[] stringBlob = BuildStringBlob(strings); + writer.Write(checked((uint)(stringBlob.Length / 4))); + writer.Write(stringBlob); + + if (HasExtendedSections(metadata)) + { + IReadOnlyList selectors = payload.ExtendedSelectorCounts.Count == 0 + ? new uint[ExtendedSelectorCount] + : payload.ExtendedSelectorCounts; + foreach (uint value in selectors) writer.Write(value); + WriteDwordArray(writer, payload.ExtendedValues); + } + else if (payload.ExtendedSelectorCounts.Count != 0 || payload.ExtendedValues.Count != 0) + { + throw new InvalidDataException( + $"Shared profile version {metadata.SaveVersion1}.{metadata.SaveVersion2} " + + "cannot encode version-3.10 extended sections."); + } + + foreach (uint value in payload.ReservedTail) writer.Write(value); + writer.Flush(); + return stream.ToArray(); + } + + public static SharedProfilePayload Decode(ReadOnlySpan source, NativeSaveMetadata metadata) + { + ArgumentNullException.ThrowIfNull(metadata); + var reader = new PayloadReader(source); + + uint[] catalog = reader.ReadDwordArray("catalog compatibility"); + + int integerCount = reader.ReadCount("integer entry", FixedIntegerEntrySize); + var integers = new Dictionary(integerCount); + for (int i = 0; i < integerCount; i++) + { + ReadOnlySpan entry = reader.ReadBytes(FixedIntegerEntrySize, "integer entry"); + int address = ReadTypedKey(entry[..FixedKeyFieldSize], IntegerTypeTag); + uint value = BinaryPrimitives.ReadUInt32LittleEndian(entry[FixedKeyFieldSize..]); + if (!integers.TryAdd(address, value)) + throw new InvalidDataException($"Shared SAVE.DAT repeats integer cell 0x{address:x8}."); + } + + int stringCount = reader.ReadCount("string entry", minimumBytesPerEntry: 2); + uint stringBlobDwords = reader.ReadUInt32("string blob DWORD count"); + if (stringBlobDwords > int.MaxValue / 4) + throw new InvalidDataException("Shared SAVE.DAT string blob is too large."); + int stringBlobLength = checked((int)stringBlobDwords * 4); + ReadOnlySpan stringBlob = reader.ReadBytes(stringBlobLength, "string blob"); + var strings = new Dictionary(stringCount); + int stringPosition = 0; + for (int i = 0; i < stringCount; i++) + { + ReadOnlySpan key = ReadCString(stringBlob, ref stringPosition, "string key"); + int address = ReadTypedKey(key, StringTypeTag); + ReadOnlySpan value = ReadCString(stringBlob, ref stringPosition, "string value"); + string decoded; + try + { + decoded = ShiftJis.GetString(value); + } + catch (DecoderFallbackException error) + { + throw new InvalidDataException( + $"Shared SAVE.DAT string cell 0x{address:x8} is not valid CP932.", error); + } + if (!strings.TryAdd(address, decoded)) + throw new InvalidDataException($"Shared SAVE.DAT repeats string cell 0x{address:x8}."); + } + if (stringBlob[stringPosition..].IndexOfAnyExcept((byte)0) >= 0) + throw new InvalidDataException("Shared SAVE.DAT string blob has nonzero padding."); + + uint[] selectors = Array.Empty(); + uint[] extended = Array.Empty(); + if (HasExtendedSections(metadata)) + { + selectors = reader.ReadDwords(ExtendedSelectorCount, "extended selector table"); + extended = reader.ReadDwordArray("extended"); + } + + uint[] tail = reader.ReadDwords(ReservedTailDwordCount, "reserved tail"); + if (!reader.AtEnd) + throw new InvalidDataException( + $"Shared SAVE.DAT has {reader.Remaining} unexpected trailing byte(s)."); + + return new SharedProfilePayload(catalog, integers, strings, selectors, extended, tail); + } + + private static byte[] BuildStringBlob(IReadOnlyList> entries) + { + using var stream = new MemoryStream(); + Span key = stackalloc byte[10]; + foreach (var (address, rawValue) in entries) + { + WriteTypedKey(key, StringTypeTag, address); + stream.Write(key); + + string value = TruncateAtNul(rawValue ?? string.Empty); + byte[] encodedValue = ShiftJis.GetBytes(value); + stream.Write(encodedValue); + stream.WriteByte(0); + } + + // Native stores a DWORD count and rounds up with `(byte_length / 4) + 1`, which deliberately + // adds a whole zero DWORD when the strings already end on a DWORD boundary. + int paddedLength = checked((int)((stream.Length / 4 + 1) * 4)); + stream.SetLength(paddedLength); + return stream.ToArray(); + } + + private static void WriteTypedKey(Span destination, byte typeTag, int address) + { + if (address < 0) + throw new InvalidDataException("Shared profile cell addresses cannot be negative."); + if (destination.Length < 10) + throw new ArgumentException("Typed-key destination is too short.", nameof(destination)); + + destination[0] = typeTag; + bool written = address.TryFormat( + destination[1..9], out int charsWritten, "x8", CultureInfo.InvariantCulture); + if (!written || charsWritten != 8) + throw new InvalidDataException($"Shared profile cell address 0x{address:x} does not fit its key."); + destination[9] = 0; + } + + private static int ReadTypedKey(ReadOnlySpan source, byte expectedTypeTag) + { + bool hasTerminator = source.Length >= 10; + if (source.Length < 9 || source[0] != expectedTypeTag + || hasTerminator && source[9] != 0) + throw new InvalidDataException( + $"Shared SAVE.DAT key does not have type tag 0x{expectedTypeTag:x2} and eight hex digits."); + uint address = 0; + for (int i = 1; i <= 8; i++) + { + int digit = source[i] switch + { + >= (byte)'0' and <= (byte)'9' => source[i] - '0', + >= (byte)'a' and <= (byte)'f' => source[i] - 'a' + 10, + >= (byte)'A' and <= (byte)'F' => source[i] - 'A' + 10, + _ => -1, + }; + if (digit < 0) + throw new InvalidDataException("Shared SAVE.DAT typed key contains a non-hex digit."); + address = address * 16 + (uint)digit; + } + if (address > int.MaxValue) + throw new InvalidDataException("Shared SAVE.DAT cell address exceeds the VM global-bank range."); + return (int)address; + } + + private static ReadOnlySpan ReadCString( + ReadOnlySpan source, ref int position, string field) + { + if ((uint)position > (uint)source.Length) + throw new InvalidDataException($"Shared SAVE.DAT {field} starts outside its string blob."); + int relativeEnd = source[position..].IndexOf((byte)0); + if (relativeEnd < 0) + throw new InvalidDataException($"Shared SAVE.DAT {field} is not NUL-terminated."); + ReadOnlySpan value = source.Slice(position, relativeEnd); + position = checked(position + relativeEnd + 1); + return value; + } + + private static void WriteDwordArray(BinaryWriter writer, IReadOnlyList values) + { + writer.Write(checked((uint)values.Count)); + foreach (uint value in values) writer.Write(value); + } + + private static bool HasExtendedSections(NativeSaveMetadata metadata) + => metadata.SaveVersion1 > 3 + || metadata.SaveVersion1 == 3 && metadata.SaveVersion2 >= 10; + + private static string TruncateAtNul(string value) + { + int nul = value.IndexOf('\0'); + return nul < 0 ? value : value[..nul]; + } + + private static Encoding CreateShiftJis() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + return Encoding.GetEncoding( + 932, EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback); + } + + private ref struct PayloadReader + { + private readonly ReadOnlySpan _source; + private int _position; + + internal PayloadReader(ReadOnlySpan source) + { + _source = source; + _position = 0; + } + + internal bool AtEnd => _position == _source.Length; + internal int Remaining => _source.Length - _position; + + internal uint ReadUInt32(string field) + { + ReadOnlySpan bytes = ReadBytes(4, field); + return BinaryPrimitives.ReadUInt32LittleEndian(bytes); + } + + internal int ReadCount(string field, int minimumBytesPerEntry) + { + uint count = ReadUInt32(field + " count"); + if (count > int.MaxValue + || minimumBytesPerEntry > 0 && count > (uint)(Remaining / minimumBytesPerEntry)) + throw new InvalidDataException($"Shared SAVE.DAT {field} count is too large."); + return (int)count; + } + + internal uint[] ReadDwordArray(string field) + { + int count = ReadCount(field, 4); + return ReadDwords(count, field); + } + + internal uint[] ReadDwords(int count, string field) + { + if (count < 0 || count > Remaining / 4) + throw new InvalidDataException($"Shared SAVE.DAT {field} is truncated."); + var values = new uint[count]; + for (int i = 0; i < count; i++) values[i] = ReadUInt32(field); + return values; + } + + internal ReadOnlySpan ReadBytes(int count, string field) + { + if (count < 0 || count > Remaining) + throw new InvalidDataException($"Shared SAVE.DAT {field} is truncated."); + ReadOnlySpan result = _source.Slice(_position, count); + _position += count; + return result; + } + } +} + +/// +/// Profile-lifetime selected cells plus the opaque native sections required to round-trip SAVE.DAT. +/// VM opcodes mutate this object; explicit Load/Save calls own filesystem lifecycle. +/// +public sealed class SharedProfile +{ + private uint[] _catalogCompatibilityValues = Array.Empty(); + private readonly Dictionary _integerCells = new(); + private readonly Dictionary _stringCells = new(); + private uint[] _extendedSelectorCounts = Array.Empty(); + private uint[] _extendedValues = Array.Empty(); + private uint[] _reservedTail = new uint[SharedProfilePayloadCodec.ReservedTailDwordCount]; + + public IReadOnlyDictionary IntegerCells => _integerCells; + public IReadOnlyDictionary StringCells => _stringCells; + + public void StoreInteger(int address, long value) + { + ValidateAddress(address); + _integerCells[address] = unchecked((uint)value); + } + + public int LoadInteger(int address) + { + ValidateAddress(address); + return _integerCells.TryGetValue(address, out uint value) ? unchecked((int)value) : 0; + } + + public void StoreString(int address, string value) + { + ValidateAddress(address); + ArgumentNullException.ThrowIfNull(value); + int nul = value.IndexOf('\0'); + _stringCells[address] = nul < 0 ? value : value[..nul]; + } + + public string LoadString(int address) + { + ValidateAddress(address); + return _stringCells.TryGetValue(address, out string? value) ? value : string.Empty; + } + + public bool Load(INativeDatStore store) + { + ArgumentNullException.ThrowIfNull(store); + NativeSaveDocument? document = store.LoadShared(); + if (document is null) + { + Clear(); + return false; + } + + Replace(SharedProfilePayloadCodec.Decode(document.Payload, document.Metadata)); + return true; + } + + public void Save( + INativeDatStore store, + NativeSystemTime timestamp, + uint accumulatedPlaySeconds) + { + ArgumentNullException.ThrowIfNull(store); + NativeSaveMetadata metadata = store.Identity.CreateMetadata(timestamp, accumulatedPlaySeconds); + byte[] payload = SharedProfilePayloadCodec.Encode(Snapshot(), metadata); + store.SaveShared(payload, timestamp, accumulatedPlaySeconds); + } + + public SharedProfilePayload Snapshot() + => new( + _catalogCompatibilityValues, + _integerCells, + _stringCells, + _extendedSelectorCounts, + _extendedValues, + _reservedTail); + + public void Replace(SharedProfilePayload payload) + { + ArgumentNullException.ThrowIfNull(payload); + _catalogCompatibilityValues = payload.CatalogCompatibilityValues.ToArray(); + _integerCells.Clear(); + foreach (var entry in payload.IntegerCells) _integerCells.Add(entry.Key, entry.Value); + _stringCells.Clear(); + foreach (var entry in payload.StringCells) _stringCells.Add(entry.Key, entry.Value); + _extendedSelectorCounts = payload.ExtendedSelectorCounts.ToArray(); + _extendedValues = payload.ExtendedValues.ToArray(); + _reservedTail = payload.ReservedTail.ToArray(); + } + + public void Clear() + { + _catalogCompatibilityValues = Array.Empty(); + _integerCells.Clear(); + _stringCells.Clear(); + _extendedSelectorCounts = Array.Empty(); + _extendedValues = Array.Empty(); + _reservedTail = new uint[SharedProfilePayloadCodec.ReservedTailDwordCount]; + } + + private static void ValidateAddress(int address) + { + if (address < 0) + throw new ArgumentOutOfRangeException(nameof(address), "Shared profile cell address cannot be negative."); + } +} diff --git a/engine/Age.Engine/Vm/GameSession.cs b/engine/Age.Engine/Vm/GameSession.cs index 2c10eda..b36d142 100644 --- a/engine/Age.Engine/Vm/GameSession.cs +++ b/engine/Age.Engine/Vm/GameSession.cs @@ -2,6 +2,7 @@ using System.Text.Json; using Age.Engine.Diagnostics; using Age.Engine.Hosting; using Age.Engine.Model; +using Age.Engine.Persistence; namespace Age.Engine.Vm; @@ -20,9 +21,14 @@ public sealed class GameSession { public Dictionary Globals { get; } = new(); public Dictionary GlobalStrings { get; } = new(); + /// AGE's selected profile-wide cells and native shared SAVE.DAT lifecycle. + public SharedProfile SharedProfile { 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 void Seed(int addr, long value) => Globals[addr] = value; public void SeedString(int addr, string value) => GlobalStrings[addr] = value; @@ -31,7 +37,8 @@ public sealed class GameSession VmOptions? options = null, IScriptProvider? provider = null, ITraceSink? sink = null) { - var vm = new VirtualMachine(script, table, host, options, provider, sink, TextHistory); + var vm = new VirtualMachine( + script, table, host, options, provider, sink, TextHistory, SharedProfile); 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 c620078..07625de 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -1,6 +1,7 @@ using Age.Engine.Diagnostics; using Age.Engine.Hosting; using Age.Engine.Model; +using Age.Engine.Persistence; using System.Text; namespace Age.Engine.Vm; @@ -25,6 +26,7 @@ public sealed class VirtualMachine private readonly VmOptions _o; private readonly Encoding _nativeStringEncoding; private readonly IScriptProvider? _provider; + private readonly SharedProfile _sharedProfile; private static readonly bool _diagSetTexture = System.Environment.GetEnvironmentVariable("AGE_DIAG_SETTEX") == "1"; private ExecFrame _cur = null!; private int _depth; @@ -94,12 +96,13 @@ public sealed class VirtualMachine public VirtualMachine(Script s, OpcodeTable t, IHost host, VmOptions? o = null, IScriptProvider? provider = null, ITraceSink? sink = null, - AdvTextHistory? textHistory = null) + AdvTextHistory? textHistory = null, SharedProfile? sharedProfile = 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(); } /// Queue global writes and return only the identified active frame at its next opcode boundary. @@ -429,6 +432,21 @@ public sealed class VirtualMachine _ => VmAddress.Global((int)op.Value), }; + private bool TryResolveSharedProfileCell(Operand operand, bool isString, out int address) + { + bool acceptedType = isString + ? operand.Type is T_GSTR or T_GSTRPTR or T_LSTRPTR + : operand.Type is T_GINT or T_GPTR or T_LPTR; + VmAddress resolved = acceptedType ? BaseAddr(operand) : default; + if (!acceptedType || resolved.Space != VmAddressSpace.Global || resolved.Address < 0) + { + address = 0; + return false; + } + address = resolved.Address; + return true; + } + private bool TryStoreAddress(Operand destination, VmAddress address) { switch (destination.Type) @@ -756,6 +774,46 @@ 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 "store-shared-profile-int": // 0x1a2 + { + if (!TryResolveSharedProfileCell(a[0], isString: false, out int address)) + { + HaltReason ??= $"shared-profile-int-lvalue-type:{a[0].Type}"; + return HALT; + } + _sharedProfile.StoreInteger(address, Read(a[0])); + return pc + 1; + } + case "load-shared-profile-int": // 0x1a3 + { + if (!TryResolveSharedProfileCell(a[0], isString: false, out int address)) + { + HaltReason ??= $"shared-profile-int-lvalue-type:{a[0].Type}"; + return HALT; + } + Write(a[0], _sharedProfile.LoadInteger(address)); + return pc + 1; + } + case "store-shared-profile-string": // 0x1a9 + { + if (!TryResolveSharedProfileCell(a[0], isString: true, out int address)) + { + HaltReason ??= $"shared-profile-string-lvalue-type:{a[0].Type}"; + return HALT; + } + _sharedProfile.StoreString(address, ReadStr(a[0])); + return pc + 1; + } + case "load-shared-profile-string": // 0x1aa + { + if (!TryResolveSharedProfileCell(a[0], isString: true, out int address)) + { + HaltReason ??= $"shared-profile-string-lvalue-type:{a[0].Type}"; + return HALT; + } + WriteStr(a[0], _sharedProfile.LoadString(address)); + return pc + 1; + } case "strlen": // 0x2c5: raw strlen(native encoded bytes) Write(a[0], NativeStringByteLength(ReadStr(a[1]))); return pc + 1; diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 26426af..552f6a2 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -3524,8 +3524,8 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "store-shared-profile-int" category = "control" -summary = "0x1a2 (cell) — snapshot the selected global integer cell into AGE's shared SAVE.DAT profile table. The key is `3%08x`, where the address is resolved through global/local pointer operands; the stored value is the cell's current raw 32-bit value. Insert-or-assign semantics replace an existing entry." -details = "Paired reader 0x1a3 restores the same cell, returning zero when the key is absent. The table is embedded at ctx+0x5190, explicitly enumerated by shared_profile_payload_write into shared SAVE.DAT, and reconstructed by shared_profile_payload_read. It is not the retained gfx map, numbered SAVE##.DAT state, or RT.DAT ReadTextDB. Port status (2026-07-20): deliberately unimplemented pending the unified shared-profile storage boundary; the former inert GfxState HashSet approximation was removed so coverage reports the gap." +summary = "0x1a2 (cell) — snapshot the selected global integer cell into AGE's shared SAVE.DAT profile table. Its native key is raw type byte `0x03` followed by eight lowercase ASCII hex digits for the lvalue's resolved global-bank index; the stored value is the cell's current raw 32-bit value. Insert-or-assign semantics replace an existing entry." +details = "Paired reader 0x1a3 restores the same cell, returning zero when the key is absent. The table is embedded at ctx+0x5190, explicitly enumerated by shared_profile_payload_write into shared SAVE.DAT, and reconstructed by shared_profile_payload_read. It is not the retained gfx map, numbered SAVE##.DAT state, or RT.DAT ReadTextDB. Port status (2026-07-24): implemented through profile-owned SharedProfile state shared across fresh scene VMs and the native shared-payload/store lifecycle; direct globals and resolved global local-pointers are covered." noop_headless = false source = "investigation" confidence = "high" @@ -3546,8 +3546,8 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "load-shared-profile-int" category = "control" -summary = "0x1a3 (cell) — restore the selected global integer cell from AGE's shared SAVE.DAT profile table. It resolves the same `3%08x` cell-address key as 0x1a2 and overwrites the operand with the stored raw 32-bit value, or zero when the key is absent." -details = "This is the read half of the shared-profile integer service, not a string operation. LOADCONFIG.BIN restores configuration globals with it; GAMESTART/GAMECLEAR and array-pointer call sites restore other selected profile-wide values. Port status (2026-07-20): deliberately unimplemented with 0x1a2 pending the unified shared-profile storage boundary." +summary = "0x1a3 (cell) — restore the selected global integer cell from AGE's shared SAVE.DAT profile table. It resolves the same raw `0x03` plus eight-hex-digit cell-address key as 0x1a2 and overwrites the operand with the stored raw 32-bit value, or zero when the key is absent." +details = "This is the read half of the shared-profile integer service, not a string operation. LOADCONFIG.BIN restores configuration globals with it; GAMESTART/GAMECLEAR and array-pointer call sites restore other selected profile-wide values. Port status (2026-07-24): implemented with 0x1a2 through the shared profile service and native payload codec." noop_headless = false source = "investigation" confidence = "high" @@ -3679,8 +3679,8 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "store-shared-profile-string" category = "control" -summary = "(cell) - snapshot the selected global string cell into AGE's shared `SAVE.DAT` profile table under key `5%08x`, replacing any earlier value." -details = "This is the string counterpart to integer-store opcode 0x1a2. The value is profile-wide rather than numbered-slot-local and is written by the shared profile lifecycle." +summary = "(cell) - snapshot the selected global string cell into AGE's shared `SAVE.DAT` profile table under a raw `0x05` type byte plus eight lowercase ASCII hex digits for the resolved global-bank index, replacing any earlier value." +details = "This is the string counterpart to integer-store opcode 0x1a2. The value is profile-wide rather than numbered-slot-local and is written by the shared profile lifecycle. Port status (2026-07-24): implemented for direct global strings and resolved global string-pointers." noop_headless = false source = "investigation" confidence = "high" @@ -3701,8 +3701,8 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "load-shared-profile-string" category = "control" -summary = "(cell) - restore the selected global string cell from AGE's shared `SAVE.DAT` profile table using key `5%08x`; a missing key yields the native empty-string default." -details = "This is the paired reader for opcode 0x1a9 and the string counterpart to integer-load opcode 0x1a3." +summary = "(cell) - restore the selected global string cell from AGE's shared `SAVE.DAT` profile table using the raw `0x05` plus eight-hex-digit key; a missing key yields the native empty-string default." +details = "This is the paired reader for opcode 0x1a9 and the string counterpart to integer-load opcode 0x1a3. Port status (2026-07-24): implemented through the shared profile service and native payload codec." noop_headless = false source = "investigation" confidence = "high"