diff --git a/docs/engine-ctx-reference.md b/docs/engine-ctx-reference.md index 11e40d5..54e4540 100644 --- a/docs/engine-ctx-reference.md +++ b/docs/engine-ctx-reference.md @@ -13,6 +13,8 @@ Struct `EngineCtx`, size `0xa1000`. Applied to the Ghidra `/v2` image (dispatch- | `0x13dc` | `message_skip_enabled` | `int` | persistent all-message Skip flag; op 0x88 writes it and adv_interpreter_tick injects input bit 0x40 while nonzero | | `0x1c34` | `mouse_wheel_delta` | `int` | signed WM_MOUSEWHEEL delta accumulated by age_main_window_proc; op 0x10d returns and clears it | | `0x3028` | `alt_pack_table` | `int` | call-script high-byte alternate pack table (unused by corpus) | +| `0x4d7c` | `shared_profile_state` | `void*` | embedded shared SAVE.DAT state object; owns profile integer/settings tables and container timing metadata | +| `0x5190` | `shared_profile_int_table` | `int` | open-addressing 12-byte string-key to 32-bit value table; op 0x1a2 stores, 0x1a3 loads, shared SAVE.DAT serializes it | | `0xb558` | `gfx_dirty_a` | `int` | gfx dirty flag (anim set raises) | | `0xb560` | `gfx_dirty_b` | `int` | gfx dirty flag | | `0x14d54` | `gfx_obj_ptr_table` | `void*` | per-object pointer table (ops 0x212/0x213 write obj+0x64/0x68/0x6c) | @@ -49,6 +51,7 @@ Struct `EngineCtx`, size `0xa1000`. Applied to the Ghidra `/v2` image (dispatch- | `0x5f6a8` | `timed_callback_cursor` | `int` | next schedule entry consumed by timed_callback_sequence_tick; op 0xd3 resets to zero | | `0x5f6b4` | `timed_callback_script_resource_id` | `uint` | active frame script resource retained by op 0xd5 for local callback dispatch safety | | `0x5f6b8` | `timed_callback_script_resource_guard` | `uint` | companion current-script identity checked before redirecting to a scheduled callback | +| `0x5f6c0` | `value_switch_table` | `int` | temporary formatted-value branch table reset by 0xa1, populated by 0xa2, queried by 0xa3; distinct from shared_profile_int_table | | `0x5f72c` | `adv_wait_indicator_frame` | `int` | current animated input-wait indicator frame, reset to zero when the service is armed | | `0x5f730` | `adv_wait_indicator_terminal_frame` | `int` | terminal frame returned by the indicator layout during ADV wait setup | | `0x5f734` | `adv_wait_indicator_enabled_value` | `int` | raw enable operand last written by op 0x1ce; nonzero arms the ADV wait-indicator service | diff --git a/docs/engine-re.md b/docs/engine-re.md index 503470e..b507e01 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -146,39 +146,52 @@ decompile `ctx->cur_ctx_index` / `ctx->frame_instruction_word_count` / `ctx->run ## Findings -### op `0x1a2` (`u00428010`) is a value/descriptor registration op — NOT save, NOT decision→scene (corrected 2026-07-20) +### ops `0x1a2`/`0x1a3` store and restore shared `SAVE.DAT` integer cells (resolved 2026-07-20) The SCJUMP slice assumed `u00428010` resolved a decision value to a scene. **That premise is wrong**, -and pinning the *real* handler via the dispatch table above corrects two layers of confusion: +and pinning the real handler plus its paired reader resolves the service: - **VA-drift trap:** Kelebek's `u00428010` = op `0x1a2`. But Kelebek's raw VA `0x428010`, in *our* build, sits inside a *different* handler `0x427fb0`, which is **op `0x1ac`** (per the table: `ctx[0x26e3f]=0x427fb0`). Op `0x1ac` is a **save-path** op — its handler formats `%s\SAVE%2.2d.DAT` (format string `0x571e70`) and is multi-operand. Reading the raw VA gave the wrong opcode. -- **Op `0x1a2`'s real handler = `FUN_0042d360`** (`= ctx[0x26c93+0x1a2] = ctx[0x26e35]`), argc 1. It - records the generic **3-dword instruction length** at `ctx+0x53d88 + curCtx*0x78`, fetches operand 1, - formats a key with `"%c%8.8x"` (format string `0x5714e0`) from `(type 3, operand lvalue descriptor)`, - and inserts the operand's current value into the separate open-addressing table at `ctx+0x5190`. - The old graphics classification came entirely from misreading the instruction-length field as a - command type. The handler is still definitively not save or scene-load. -- **Consequence — the decision→scene premise is discredited.** The FIELD snippet - `lookup(0x5f0ed, 0x62ccf); mov(ptr,1); lookup(0x5f0ed, 0x62ccf); u00428010(ptr)` (next op `0x21b`, - also outside the scene loader) is a **value-registration operation**, not scene sequencing. So `u00428010` does **not** - resolve decision→scene. **The real decision→scene mechanism is unidentified** — it belongs with the - call-script / script-load dispatch (`name-resolution.md §1`), the next target for this loop (now - armed with the dispatch table to resolve the call-script handler directly). +- **`0x1a2` = store.** `op_0x1a2_store_shared_profile_int@0x42d360` records the generic three-dword + instruction length, reads operand 1's current raw 32-bit value, and resolves its actual cell index with + `vm_operand_lvalue`. That accessor accepts a direct global integer (type 3), global pointer (type 6), or + local pointer (type `0xc`); pointer forms resolve to an index relative to the global integer bank. The + handler formats the key `3%08x` and insert-or-assigns the value in the table at `ctx+0x5190`. +- **`0x1a3` = load.** `op_0x1a3_load_shared_profile_int@0x427e90` resolves the identical cell index, + calls `shared_profile_int_lookup@0x4199d0`, and writes the result back through `vm_operand_write`. + A missing key returns **zero**. The old `string-lookup-set` label described neither its type nor effect. +- **The consumer is shared `SAVE.DAT`.** `shared_profile_payload_write@0x430a20` enumerates this table + and writes its entry count followed by one 12-byte key and one 32-bit value per entry. It is invoked by + `shared_profile_save@0x40c950`. `shared_profile_payload_read@0x431070`, called by + `shared_profile_load@0x40ccd0`, reconstructs the same table with insert-or-assign. Numbered + `SAVE##.DAT` paths use the surrounding state object only for container metadata/timing; they do not + enumerate this table. `RT.DAT` independently stores `ReadTextDB`. +- **The generic hash helper caused the earlier conflation.** `hash_table_insert_or_assign@0x42cf70` and + `hash_table_find_value_ptr@0x419290` operate on whichever table ECX selects. `0x1a2` selects + `ctx+0x5190`; value-switch ops `0xa2`/`0xa3` select the distinct temporary table at `ctx+0x5f6c0`; + engine settings and text caches use still other instances. +- **Corpus shape matches profile persistence.** The corpus has 17,585 `0x1a2` calls in 315 scripts: + 17,539 operate on a local pointer immediately resolved by `lookup-array`, while 46 name a global cell + directly. The paired `0x1a3` appears 73 times in 12 scripts. `LOADCONFIG.BIN` restores configuration + 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 audit consequence (2026-07-20): `0x1a2` is not faithfully implemented.** The VM still dispatches -the opcode through its legacy `gfx-cmd-register` label to `GfxState.Register(Read(a[0]))`. That method adds -only the operand's current numeric value to `_operandRegistry`, a `HashSet`; it does not retain the -lvalue descriptor key used by AGE. `IsRegistered` has no runtime callers, so the set currently has no -gameplay or rendering effect (and its removal during gfx-object erase is likewise inert). Therefore this -mistake does **not** invalidate the completed retained-object, animation, History, or ADV work, whose -semantics came from independent workers/fields/traces. It does mean the opcode table's syntactic `impl` -classification overstates compatibility for `0x1a2`: keep it as explicit follow-up debt until the native -descriptor table's consumer is identified, then move the model out of `GfxState` and preserve descriptor→value -semantics in the appropriate VM/service subsystem. +**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. + +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 +the separate call-script/progression path documented in `name-resolution.md` and `scjump-progression.md`. **Lesson:** never analyze a native op by its Kelebek `u00XXXXXX` VA directly — always resolve the real handler through the dispatch table (`ctx[0x26c93 + op]`). The raw VA is off by whole functions. @@ -244,7 +257,7 @@ VA-drift — it lands inside the unrelated `FUN_00421090`. Same lesson as `0x1a2 2. **`out = FUN_0047f280(FUN_0041b940(2))`** — `FUN_0041b940(2)` fetches operand 2 (the bytecode handle key); `FUN_0047f280` is a **`std::map::find`** over an engine-internal associative registry, returning the mapped value or **`0xffffffff` (not-found)**; `FUN_00425fb0(1, out)` writes it to operand 1. That - registry is populated by the retained-object draw/geometry workers. Op `0x1a2`'s descriptor-value + registry is populated by the retained-object draw/geometry workers. Op `0x1a2`'s shared-profile integer table is separate and does not populate this map. **(a) vs (b) — the verdict is (b).** The value `0x215` returns is **native retained-object state**: "has a @@ -263,7 +276,7 @@ probe never observed. Absence in that capture therefore says nothing about the n **The fix is tractable and Frida-free.** (b) does *not* mean an opaque native state machine. The subsystem is a **modelable data structure**: retained object records (slot / geometry / draw state) behind a handle→object registry (a `std::map`). Geometry and draw workers lazily populate that retained-object map; -query and erase workers read/remove the same entries. Op `0x1a2` also maintains a descriptor-value hash, +query and erase workers read/remove the same entries. Op `0x1a2` also maintains a shared-profile integer table, but that is a separate structure and is not what op `0x215` queries. The opcode-level source of truth is `vm-map/opcodes.toml`. @@ -280,7 +293,7 @@ The decisive caller/callee detail is the owner pointer. Op `0x215`'s handler pas - draw-bound object → `0x215` returns the live source slot from `obj+4`. The earlier “op `0x215` reads a registry populated only by `0x1a2`” conclusion was wrong: it conflated -the retained-object `std::map` with `0x1a2`'s open-addressing operand-descriptor hash. The useful part of +the retained-object `std::map` with `0x1a2`'s open-addressing shared-profile integer table. The useful part of the earlier fix remains: `GetOrCreate` must not fabricate a slot. A fresh object stays unbound (-1) until `draw-texture` supplies its real source slot. @@ -1272,6 +1285,10 @@ Persistence is shared across numbered save slots. `shared_profile_save@0x40c950` save calls the shared-profile writer, and shutdown also calls it unless `set:NoSaveDat` suppresses shared data writes. +The selected integer-cell portion of shared `SAVE.DAT` is the `0x1a2` store / `0x1a3` restore service +documented above. It is independent of the `RT.DAT` read-message database even though the shared-profile +writer updates both files in one lifecycle. + The `RT.DAT` header is `0x114` bytes: magic `0x54523353` (bytes `S3RT`), a compatibility id, a 256-byte game id, version pair `1,0`, and script-record count. It is followed by 12-byte script records containing `{script_id, message_count, pointer_placeholder}` and the corresponding `message_count` dword flag arrays. diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 1c81314..9ae95a5 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -288,12 +288,19 @@ Native handler sleep_op_0xc8 @0x420ec0 is NON-BLOCKING: it arms a timer (sleep_t - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2: op_0x199_yield_adv_coroutine@0x416440 selects the registered coroutine yield-A or yield-B PC according to ctx+0x6dbc8, saves the current resume offset/state, and redirects the current frame PC. SC0000's x=772 ADV button invokes it; the SO001 tooltip at source x=528 reads Window hide, and the surrounding coroutine calls HIDEWIN.BIN. -### 0x1a2 `register-lvalue-value` (gfx-cmd-register, argc 1) -- **summary:** 0x1a2 (value) — register operand 1's current value under a key derived from its lvalue descriptor in the open-addressing table at ctx+0x5190. The write of 3 at ctx+0x53d88 is only this instruction's encoded dword length, not a graphics command type. This structure is separate from op 0x215's retained gfx-object map; op 0x215 does not query it. NOT save/scene. +### 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. - **grounding:** source=investigation, confidence=high -- **evidence:** Ghidra: handler 0x42d360 fetches operand 1's value and lvalue descriptor separately, formats the descriptor key, then calls FUN_0042cf70. By contrast op 0x215 passes ctx+0x46614 to gfx_object_query_source_slot@0x47f280, which searches the retained object map and returns obj+4. +- **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. -Port status (2026-07-20): not faithfully implemented. The legacy gfx-cmd-register dispatch currently adds only the operand's numeric value to an otherwise runtime-unread HashSet in GfxState; it neither preserves the native descriptor key nor models a known consumer. Do not treat its presence in the VM switch or coverage's impl bucket as semantic completion. The native table's consumer must be identified before choosing the correct host subsystem. +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. + +### 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. +- **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. DATA1 corpus: 73 calls in 12 scripts, 42 local-ptr and 31 global-int. + +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. ### 0x1ad `mark-save-resume-frame` (mark-save-resume-frame, argc 0) - **summary:** Mark the current script context as the highest frame serialized by numbered-save layouts 2/3. The native serializer saves frames 0 through this boundary and strips the boundary frame's return target so loading resumes it as the top frame. This opcode performs no file I/O itself. @@ -1015,10 +1022,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it " - **summary:** — - **grounding:** source=kelebek, confidence=low -### 0x1a3 `string-lookup-set` (string-lookup-set, argc 1) -- **summary:** — -- **grounding:** source=kelebek, confidence=med - ### 0x1a5 `set-font` (set-font, argc 1) - **summary:** — - **grounding:** source=kelebek, confidence=med diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index 39e871b..9125123 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -296,8 +296,8 @@ SECOND half"; don't duplicate here): 1. **Native gfx ops (b):** the retained-object command-buffer ops (`0x1f7`,`0x1fa`,`0x1ff`,`0x202`,`0x203`, `0x212`,`0x213`,`0x215`–`0x21a`) were reversed + implemented against a host-side `GfxState` (VM execution state; `engine/Age.Engine/Model/GfxState.cs`). `0x215` now returns distinct per-object slots. **Correction - (2026-07-20):** `0x1a2` never belonged to this family; its current inert `GfxState` approximation is not a - faithful implementation. See `docs/engine-re.md` § op `0x1a2` for the compatibility-debt audit. + (2026-07-20):** `0x1a2` never belonged to this family; it is the shared `SAVE.DAT` integer-store half + paired with `0x1a3`, and its inert `GfxState` approximation has been removed. See `docs/engine-re.md`. 2. **Missing system-boot state (a):** the CG handle array `G[0x62455..]` is set by boot script **INIT2** (via entrypoint `SYSTEM4.BIN`), which a cold single-scene run skips → all CGs collapsed onto object 0. Supplied via **`Age.Cli gfx --boot`** and **Godot `--boot`** (run `INITCONFIG/INIT2/INIT` through @@ -694,7 +694,7 @@ SC0000 coverage is now **77/129 handled (59.7%)**, with 52 GAP ops / 613 GAP ins **Follow-up — magic-circle teardown fixed (2026-07-09).** Ghidra caller analysis corrected op `0x215`: it queries the retained gfx-object map and returns `obj+4`, the source surface slot written by -`draw-texture`; it does not query op `0x1a2`'s descriptor hash. The old host model returned -1 for CG +`draw-texture`; it does not query op `0x1a2`'s shared-profile integer table. The old host model returned -1 for CG handles, so SC0000 skipped its explicit `0x1f7(handle,10)` + `0x1fa(slot)` cleanup and left `AE001H.AGF` (resource `0x37`) visible. `GfxState.QuerySlot` now returns the bound source slot, `0x1f7` erases retained objects, and `0x1fa` clears the surface. A booted SC0000 integration regression @@ -2007,12 +2007,37 @@ Validation: all 206 engine tests pass; opcode and EngineCtx tests/lints, vm0 REC are clean. SC0000 coverage deliberately remains 119/129 distinct opcodes handled (92.2%), with `0x1ad`'s six calls retained as a gap until numbered saves serialize active execution frames. -**Follow-up audit — `0x1a2` compatibility status (2026-07-20).** Correcting the shared instruction-length -field does not invalidate any completed visible graphics/animation/UI behavior: every affected native opcode -has independent worker/field/trace evidence, and the port does not emulate `ctx+0x53d88`. The exception is -`0x1a2`, whose legacy VM case stores only a numeric value in an unread `GfxState` set instead of AGE's -lvalue-descriptor→value entry. It has no present runtime consumer, so this is latent compatibility debt rather -than a current visual regression. Coverage currently counts the case as `impl`; do not interpret that bucket -as proof of semantic completion for this opcode. Canonical details and the eventual investigation target live -in `docs/engine-re.md` and `vm-map/opcodes.toml`. Documentation validation: regenerated opcode references, -opcode build/lint and tests, and `git diff --check` are clean. +### Slice A2b-0x1a2/0x1a3 investigation — shared SAVE.DAT integer cells (2026-07-20) + +The foundational field correction does not invalidate completed visible graphics/animation/UI behavior, but +the flagged `0x1a2` debt is now fully classified. `0x1a2` snapshots the selected global integer cell into a +shared-profile hash keyed by its resolved global-bank index; `0x1a3` restores the paired value or zero. Native +`shared_profile_save` serializes the table to shared `SAVE.DAT`, and `shared_profile_load` reconstructs it. +It is neither retained graphics state, numbered-slot state, nor `RT.DAT` ReadTextDB state. The full native +contract and provenance live in `docs/engine-re.md` and `vm-map/opcodes.toml`. + +The inert `GfxState` approximation and its VM handler were removed. This intentionally changes coverage from +a false `impl` classification to an explicit effectful gap; it does not change visible runtime behavior because +the old set had no readers. `0x1a3` was already a default-stub gap. The port's whole-global-bank `GameSession` +snapshot can mask some persistence effects, but it is broader than AGE's selected-cell service and cannot +faithfully reproduce load-or-zero or lifecycle boundaries. + +**Deferred implementation boundary:** add the pair only with a profile-owned cell-index→raw-int32 service +shared across VM runs and an explicit decision for how port profiles persist/migrate shared `SAVE.DAT` state. +Do not seed values, special-case script offsets, or silently fold this table into retained graphics or numbered +saves. This can be implemented independently of raw native file import/export once the port-owned profile +schema is chosen; `RT.DAT` and numbered-save frame state may share the owning profile service without sharing +their native on-disk formats. + +The canonical opcode and EngineCtx sources are regenerated. The `/v2` handlers, shared-profile payload +reader/writer, and generic table helpers are named and commented; the expanded 69-field `EngineCtx` is +reapplied and the image is saved. Correcting the false implementation moves SC0000 from 119/129 to **118/129 +distinct opcodes handled (91.5%)**: its 206 `0x1a2` instructions now appear honestly among 225 effectful-gap +instructions. This is a tracker correction, not a new runtime failure. + +**Next:** keep `0x1a2`/`0x1a3`, `0x1ad`, and `0x1cb` grouped behind the future unified profile/save ownership +decision. Continue with the paired zero-operand `0x1f6`/`0x20e` scene setup/teardown investigation as the +largest repeated non-persistence SC0000 cluster. + +Validation: all 206 engine tests pass; opcode and EngineCtx tests/lints, vm0 RECOVER, and `git diff --check` +are clean. diff --git a/docs/scjump-progression.md b/docs/scjump-progression.md index 55b9b82..d06859f 100644 --- a/docs/scjump-progression.md +++ b/docs/scjump-progression.md @@ -41,14 +41,12 @@ Run: `py -3.11 -X utf8 tools/scjump_decode.py --verify`. ## The native decision→scene boundary (deferred) A FIELD snippet does `lookup-array(ptr, 0x5f0ed, 0x62ccf)` then `u00428010(ptr)`, which the spec -guessed was the scene resolver. **Correction (refined 2026-07-20, via Ghidra):** `u00428010` (op `0x1a2`) -registers an operand's current value under a `"%c%8.8x"` key derived from its lvalue descriptor in a -separate open-addressing table. Its write of 3 at `ctx+0x53d88` is the instruction length, not a gfx -command type. So that snippet is a **value-registration operation, not the -decision→scene dispatch**. The real decision→scene mechanism is **still unidentified** and belongs with -the call-script / script-load dispatch (`name-resolution.md §1`). See `docs/engine-re.md` for the -verified handler analysis, including the explicit note that the port's legacy inert `GfxState` approximation -is not a faithful implementation, and the opcode-dispatch table that will crack call-script next. +guessed was the scene resolver. **Correction (resolved 2026-07-20, via Ghidra):** `u00428010` (op `0x1a2`) +snapshots that selected global integer array cell into the shared `SAVE.DAT` profile table, keyed by its +resolved global-bank index. Paired op `0x1a3` restores a selected cell or zero. Its write of 3 at +`ctx+0x53d88` is only the instruction length. So this is **profile persistence, not decision→scene +dispatch**; scene loading remains the separate call-script/progression mechanism. See `docs/engine-re.md` +for the verified pair, shared-save serialization path, and explicit port deferral boundary. ## See also - `vm-map/globals.toml` — the named globals SCJUMP switches on (chapter_mode, progress counters, flags). diff --git a/engine/Age.Engine.Tests/GfxCommandBufferTests.cs b/engine/Age.Engine.Tests/GfxCommandBufferTests.cs index 72ee97c..1439588 100644 --- a/engine/Age.Engine.Tests/GfxCommandBufferTests.cs +++ b/engine/Age.Engine.Tests/GfxCommandBufferTests.cs @@ -36,19 +36,16 @@ public class GfxCommandBufferTests Assert.Equal(30, vm.Globals[7]); } - private static (int, Operand[]) Register(int handle) => (0x1a2, new[] { G(handle) }); - [Fact] public void QueryReturnsZeroForCreatedUnboundObject_ThenBoundSourceSlot() { // Op 0x215 returns obj+4 from the retained gfx object. Native initialization leaves an unbound object's - // field at zero; op 0x1a2 is unrelated. Draw-texture replaces it with the bound source slot. + // field at zero. Draw-texture replaces it with the bound source slot. var t = T(); var scene = ScriptAssembler.Assemble(t, "GFX", new List<(int, Operand[])> { MovGI(1, 0xcb2a), MovGI(2, 6), MovGI(3, 0), MovGI(4, 200), SetGeom3(1, 3, 3, 3), - Register(1), Query(10, 1), (0x1fb, new[] { G(1), G(2), I(0), I(0), G(4), G(4), G(3), G(3) }), Query(11, 1), Exit(), diff --git a/engine/Age.Engine.Tests/GfxStateTests.cs b/engine/Age.Engine.Tests/GfxStateTests.cs index 633a4e1..6a1e4ae 100644 --- a/engine/Age.Engine.Tests/GfxStateTests.cs +++ b/engine/Age.Engine.Tests/GfxStateTests.cs @@ -4,17 +4,14 @@ using Xunit; public class GfxStateTests { [Fact] - public void QueryReturnsBoundSourceSlot_NotOperandRegistryValue() + public void QueryReturnsBoundSourceSlot() { // Native op 0x215 queries the retained-object map and returns obj+4. The default initializer zeroes - // that field, while draw-texture replaces it with the bound slot. Op 0x1a2 is a separate registry. + // that field, while draw-texture replaces it with the bound slot. var g = new GfxState(); g.GetOrCreate(0xcb2a).V18 = (400, 600, 0); Assert.Equal(0, g.QuerySlot(0xcb2a)); - g.Register(0xcb2a); - Assert.Equal(0, g.QuerySlot(0xcb2a)); - g.BindDraw(0xcb2a, 6, 0, 0, 200, 200, 10, 20); Assert.Equal(6, g.QuerySlot(0xcb2a)); Assert.Equal(-1, g.QuerySlot(0x9999)); diff --git a/engine/Age.Engine/Model/GfxState.cs b/engine/Age.Engine/Model/GfxState.cs index d09f62d..78f2728 100644 --- a/engine/Age.Engine/Model/GfxState.cs +++ b/engine/Age.Engine/Model/GfxState.cs @@ -115,11 +115,6 @@ public sealed class GfxState // returns the object's live source slot (obj+4), or -1 when the handle has not been drawn/bound yet. private readonly Dictionary _objects = new(); - // ---- opcode 0x1a2's operand-descriptor registry. Native op 0x1a2 hashes the lvalue descriptor string; - // it is separate from the retained-object map queried by op 0x215. We retain membership for diagnostics - // and teardown parity, but it does not make an undrawn gfx object queryable as a surface slot. ---- - private readonly HashSet _operandRegistry = new(); - private readonly Dictionary _fieldTable = new(); // ctx+0x46d14 (0x216); no family writer -> default 0 public long CurrentObject { get; private set; } @@ -181,10 +176,6 @@ public sealed class GfxState } } - /// Op 0x1a2: retain the operand's current value in the separate descriptor registry. This does - /// not populate the retained-object map used by op 0x215. - public void Register(long handle) { lock (_lock) { _operandRegistry.Add(handle); } } - public GfxObject? TryGet(long handle) => _objects.TryGetValue(handle, out var o) ? o : null; /// Op 0x215: look up in the retained gfx-object map and return obj+4, @@ -204,7 +195,6 @@ public sealed class GfxState if (_objects.TryGetValue(handle, out var obj) && obj.SourceSlot == fromSlot) obj.SourceSlot = toSlot; } - public bool IsRegistered(long handle) { lock (_lock) { return _operandRegistry.Contains(handle); } } public long QueryField(long idx) => _fieldTable.TryGetValue(idx, out var v) ? v : 0; public void Release(long handle) @@ -212,7 +202,6 @@ public sealed class GfxState lock (_lock) // re-entrant: EraseRange already holds _lock { _objects.Remove(handle); - _operandRegistry.Remove(handle); } } diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 76b7365..75c0d62 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -1068,9 +1068,6 @@ public sealed class VirtualMachine { var o = Gfx.GetOrCreate(Read(a[0])); o.Field68 = Read(a[1]); o.Field6c = Read(a[2]); return pc + 1; } - case "gfx-cmd-register": // 0x1a2 (handle) — operand-descriptor hash insert; separate from - // op 0x215's retained gfx-object/source-slot lookup - Gfx.Register(Read(a[0])); return pc + 1; case "gfx-elem-erase": // 0x1f7 (handle)(count) — erase retained-object range Gfx.EraseRange(Read(a[0]), Read(a[1])); return pc + 1; case "gfx-elem-release": // 0x1fa (surface slot) diff --git a/tools/age_opcodes_himegari.py b/tools/age_opcodes_himegari.py index d3f6902..7561844 100644 --- a/tools/age_opcodes_himegari.py +++ b/tools/age_opcodes_himegari.py @@ -35,7 +35,6 @@ INFERRED: dict[int, dict] = { 0x140: dict(name='coroutine-label-yield', category='control', noop=False, confidence='med', source='investigation', summary="(out)(name_str)(sub_str)(in) — scene-coroutine LOOP ITERATOR / labeled yield. Handler copies name/sub strings + the int operand and calls the NATIVE video/transition service (*DAT_005c6018)(8, ctx[0x54fe8], &{name,sub,in}); writes the returned PC-like value to operand 1. In SC0000 label_462 'ループ開始' (@0x46d): `out=G[0x6be]=LABEL('J',G[0x6be])`; loop runs the intro-setup body (incl. call label_125bd = slot-table fill G[0x3239..0x324e]=4..11) and jmps back until out==G[0x6c3] (a per-scene exit-PC immediate) → mov aba5c 0 → content. The gate G[0xaba5c]==1 that opens this loop is NATIVE scene-entry state (no script sets it to 1). DAT_005c6018 is runtime-resolved (all xrefs READ) = SAME class as the DirectDraw workers we don't model. PORT = HOST-MODEL IMPLEMENTED: synthesize the ADV scene-entry gate, run the LABEL/J setup body once, then return the structurally discovered per-scene terminal; do not emulate the video service. See engine-re.md §Scene-coroutine framework."), 0x199: dict(name='yield-adv-coroutine', category='control', noop=False, confidence='high', source='investigation', summary='Yield/re-enter the registered ADV coroutine handler. The fifth standard chrome button uses this transition to enter the HIDEWIN/window-hidden flow.'), 0x19a: dict(name='get-message-skip', category='input', noop=False, confidence='high', source='investigation', summary='(out) - return the current all-message skip state set by op 0x88.'), - 0x1a2: dict(name='register-lvalue-value', category='control', noop=False, confidence='high', source='investigation', summary="0x1a2 (value) — register operand 1's current value under a key derived from its lvalue descriptor in the open-addressing table at ctx+0x5190. The write of 3 at ctx+0x53d88 is only this instruction's encoded dword length, not a graphics command type. This structure is separate from op 0x215's retained gfx-object map; op 0x215 does not query it. NOT save/scene."), 0x1b6: dict(name='get-auto-message', category='input', noop=False, confidence='high', source='investigation', summary='(out) - return whether automatic message advance is enabled.'), 0x1b7: dict(name='set-auto-message', category='input', noop=False, confidence='high', source='investigation', summary='(enabled) - enable or disable automatic message advance.'), 0x1b8: dict(name='get-auto-message-time', category='input', noop=False, confidence='high', source='investigation', summary='(selector)(out) - read an Auto-message delay from engine configuration: selector 0 = post-voice AutoMessageTime0, selector 1 = unvoiced AutoMessageTime1.'), diff --git a/vm-map/engine-ctx.toml b/vm-map/engine-ctx.toml index b397351..cbc2446 100644 --- a/vm-map/engine-ctx.toml +++ b/vm-map/engine-ctx.toml @@ -73,6 +73,16 @@ name = "query_table_46d14" type = "void*" note = "stride-0x14 table read by op 0x216" [[field]] +offset = 0x4d7c +name = "shared_profile_state" +type = "void*" +note = "embedded shared SAVE.DAT state object; owns profile integer/settings tables and container timing metadata" +[[field]] +offset = 0x5190 +name = "shared_profile_int_table" +type = "int" +note = "open-addressing 12-byte string-key to 32-bit value table; op 0x1a2 stores, 0x1a3 loads, shared SAVE.DAT serializes it" +[[field]] offset = 0x51b64 name = "frame_timer" type = "int" @@ -223,6 +233,11 @@ name = "timed_callback_script_resource_guard" type = "uint" note = "companion current-script identity checked before redirecting to a scheduled callback" [[field]] +offset = 0x5f6c0 +name = "value_switch_table" +type = "int" +note = "temporary formatted-value branch table reset by 0xa1, populated by 0xa2, queried by 0xa3; distinct from shared_profile_int_table" +[[field]] offset = 0x5f72c name = "adv_wait_indicator_frame" type = "int" diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index e330493..2d57c54 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -3459,45 +3459,46 @@ observed_types = ["l-int"] [[opcode]] op = 0x1a2 -label = "gfx-cmd-register" +label = "store-shared-profile-int" argc = 1 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "register-lvalue-value" +name = "store-shared-profile-int" category = "control" -summary = "0x1a2 (value) — register operand 1's current value under a key derived from its lvalue descriptor in the open-addressing table at ctx+0x5190. The write of 3 at ctx+0x53d88 is only this instruction's encoded dword length, not a graphics command type. This structure is separate from op 0x215's retained gfx-object map; op 0x215 does not query it. NOT save/scene." -details = "Port status (2026-07-20): not faithfully implemented. The legacy gfx-cmd-register dispatch currently adds only the operand's numeric value to an otherwise runtime-unread HashSet in GfxState; it neither preserves the native descriptor key nor models a known consumer. Do not treat its presence in the VM switch or coverage's impl bucket as semantic completion. The native table's consumer must be identified before choosing the correct host subsystem." +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." noop_headless = false source = "investigation" confidence = "high" depends_on = [] -evidence = "Ghidra: handler 0x42d360 fetches operand 1's value and lvalue descriptor separately, formats the descriptor key, then calls FUN_0042cf70. By contrast op 0x215 passes ctx+0x46614 to gfx_object_query_source_slot@0x47f280, which searches the retained object map and returns obj+4." +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." [[opcode.semantics.args]] i = 1 -role = "value" +role = "global integer cell to snapshot" observed_types = ["g-int", "l-ptr"] [[opcode]] op = 0x1a3 -label = "string-lookup-set" +label = "load-shared-profile-int" argc = 1 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "string-lookup-set" -category = "unknown" -summary = "" +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." noop_headless = false -source = "kelebek" -confidence = "med" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +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. DATA1 corpus: 73 calls in 12 scripts, 42 local-ptr and 31 global-int." [[opcode.semantics.args]] i = 1 -role = "" +role = "global integer cell to restore" observed_types = ["g-int", "l-ptr"] [[opcode]]