Implement native shared profile persistence
This commit is contained in:
@@ -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/)
|
||||
|
||||
@@ -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
|
||||
`<root>\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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
193
engine/Age.Engine.Tests/SharedProfileTests.cs
Normal file
193
engine/Age.Engine.Tests/SharedProfileTests.cs
Normal file
@@ -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<int, uint> { [0x1234] = 0xffffffff },
|
||||
stringCells: new Dictionary<int, string> { [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<int, uint> { [1] = 2 }),
|
||||
metadata);
|
||||
|
||||
byte[] badType = logical.ToArray();
|
||||
badType[8] = 0x05;
|
||||
Assert.Contains("type tag", Assert.Throws<InvalidDataException>(
|
||||
() => SharedProfilePayloadCodec.Decode(badType, metadata)).Message);
|
||||
|
||||
byte[] trailing = [.. logical, 0, 0, 0, 0];
|
||||
Assert.Contains("trailing", Assert.Throws<InvalidDataException>(
|
||||
() => 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));
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ public sealed record NativeSaveIdentity(
|
||||
|
||||
public interface INativeDatStore
|
||||
{
|
||||
NativeSaveIdentity Identity { get; }
|
||||
NativeSaveDocument? LoadShared();
|
||||
void SaveShared(ReadOnlySpan<byte> 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)
|
||||
{
|
||||
|
||||
443
engine/Age.Engine/Persistence/SharedProfile.cs
Normal file
443
engine/Age.Engine/Persistence/SharedProfile.cs
Normal file
@@ -0,0 +1,443 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace Age.Engine.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class SharedProfilePayload
|
||||
{
|
||||
public IReadOnlyList<uint> CatalogCompatibilityValues { get; }
|
||||
public IReadOnlyDictionary<int, uint> IntegerCells { get; }
|
||||
public IReadOnlyDictionary<int, string> StringCells { get; }
|
||||
public IReadOnlyList<uint> ExtendedSelectorCounts { get; }
|
||||
public IReadOnlyList<uint> ExtendedValues { get; }
|
||||
public IReadOnlyList<uint> ReservedTail { get; }
|
||||
|
||||
public SharedProfilePayload(
|
||||
IEnumerable<uint>? catalogCompatibilityValues = null,
|
||||
IEnumerable<KeyValuePair<int, uint>>? integerCells = null,
|
||||
IEnumerable<KeyValuePair<int, string>>? stringCells = null,
|
||||
IEnumerable<uint>? extendedSelectorCounts = null,
|
||||
IEnumerable<uint>? extendedValues = null,
|
||||
IEnumerable<uint>? reservedTail = null)
|
||||
{
|
||||
uint[] selectors = extendedSelectorCounts?.ToArray() ?? Array.Empty<uint>();
|
||||
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<uint>());
|
||||
IntegerCells = new ReadOnlyDictionary<int, uint>(
|
||||
integerCells?.ToDictionary() ?? new Dictionary<int, uint>());
|
||||
StringCells = new ReadOnlyDictionary<int, string>(
|
||||
stringCells?.ToDictionary() ?? new Dictionary<int, string>());
|
||||
ExtendedSelectorCounts = Array.AsReadOnly(selectors);
|
||||
ExtendedValues = Array.AsReadOnly(extendedValues?.ToArray() ?? Array.Empty<uint>());
|
||||
ReservedTail = Array.AsReadOnly(tail);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<int, uint>[] integers = payload.IntegerCells
|
||||
.OrderBy(entry => entry.Key)
|
||||
.ToArray();
|
||||
writer.Write(checked((uint)integers.Length));
|
||||
Span<byte> 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<int, string>[] 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<uint> 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<byte> 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<int, uint>(integerCount);
|
||||
for (int i = 0; i < integerCount; i++)
|
||||
{
|
||||
ReadOnlySpan<byte> 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<byte> stringBlob = reader.ReadBytes(stringBlobLength, "string blob");
|
||||
var strings = new Dictionary<int, string>(stringCount);
|
||||
int stringPosition = 0;
|
||||
for (int i = 0; i < stringCount; i++)
|
||||
{
|
||||
ReadOnlySpan<byte> key = ReadCString(stringBlob, ref stringPosition, "string key");
|
||||
int address = ReadTypedKey(key, StringTypeTag);
|
||||
ReadOnlySpan<byte> 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>();
|
||||
uint[] extended = Array.Empty<uint>();
|
||||
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<KeyValuePair<int, string>> entries)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
Span<byte> 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<byte> 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<byte> 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<byte> ReadCString(
|
||||
ReadOnlySpan<byte> 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<byte> value = source.Slice(position, relativeEnd);
|
||||
position = checked(position + relativeEnd + 1);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static void WriteDwordArray(BinaryWriter writer, IReadOnlyList<uint> 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<byte> _source;
|
||||
private int _position;
|
||||
|
||||
internal PayloadReader(ReadOnlySpan<byte> source)
|
||||
{
|
||||
_source = source;
|
||||
_position = 0;
|
||||
}
|
||||
|
||||
internal bool AtEnd => _position == _source.Length;
|
||||
internal int Remaining => _source.Length - _position;
|
||||
|
||||
internal uint ReadUInt32(string field)
|
||||
{
|
||||
ReadOnlySpan<byte> 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<byte> ReadBytes(int count, string field)
|
||||
{
|
||||
if (count < 0 || count > Remaining)
|
||||
throw new InvalidDataException($"Shared SAVE.DAT {field} is truncated.");
|
||||
ReadOnlySpan<byte> result = _source.Slice(_position, count);
|
||||
_position += count;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class SharedProfile
|
||||
{
|
||||
private uint[] _catalogCompatibilityValues = Array.Empty<uint>();
|
||||
private readonly Dictionary<int, uint> _integerCells = new();
|
||||
private readonly Dictionary<int, string> _stringCells = new();
|
||||
private uint[] _extendedSelectorCounts = Array.Empty<uint>();
|
||||
private uint[] _extendedValues = Array.Empty<uint>();
|
||||
private uint[] _reservedTail = new uint[SharedProfilePayloadCodec.ReservedTailDwordCount];
|
||||
|
||||
public IReadOnlyDictionary<int, uint> IntegerCells => _integerCells;
|
||||
public IReadOnlyDictionary<int, string> 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<uint>();
|
||||
_integerCells.Clear();
|
||||
_stringCells.Clear();
|
||||
_extendedSelectorCounts = Array.Empty<uint>();
|
||||
_extendedValues = Array.Empty<uint>();
|
||||
_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.");
|
||||
}
|
||||
}
|
||||
@@ -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<int, long> Globals { get; } = new();
|
||||
public Dictionary<int, string> GlobalStrings { get; } = new();
|
||||
/// <summary>AGE's selected profile-wide cells and native shared SAVE.DAT lifecycle.</summary>
|
||||
public SharedProfile SharedProfile { get; }
|
||||
/// <summary>The live retained ADV backlog shared by every VM run in this session.</summary>
|
||||
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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/// <summary>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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user