From f2797d5ab75651adbc5efe04e36658c1d3df6587 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 10 Jul 2026 09:45:43 -0400 Subject: [PATCH] feat: model ADV coroutines and retained effect teardown --- docs/engine-ctx-reference.md | 2 +- docs/engine-re.md | 153 +++++++++--------- docs/opcode-reference.md | 47 +++--- docs/phase-a-slice-plan.md | 48 +++++- ...07-09-re-tooling-handler-labels-and-iat.md | 2 +- .../plans/2026-07-09-stl-crt-naming.md | 20 ++- ...e-tooling-handler-labels-and-iat-design.md | 2 +- .../specs/2026-07-09-stl-crt-naming-design.md | 5 +- .../CoroutineHostModelTests.cs | 114 +++++++++++++ .../Age.Engine.Tests/GfxCommandBufferTests.cs | 22 +-- engine/Age.Engine.Tests/GfxStateTests.cs | 50 ++++-- engine/Age.Engine.Tests/TextureOpsTests.cs | 22 ++- engine/Age.Engine/Model/GfxState.cs | 43 ++--- engine/Age.Engine/Vm/ExecFrame.cs | 3 + engine/Age.Engine/Vm/VirtualMachine.cs | 74 ++++++++- godot/Main.cs | 12 +- tools/age_opcodes_himegari.py | 3 + vm-map/engine-ctx.toml | 2 +- vm-map/opcodes.toml | 72 ++++----- 19 files changed, 474 insertions(+), 222 deletions(-) create mode 100644 engine/Age.Engine.Tests/CoroutineHostModelTests.cs diff --git a/docs/engine-ctx-reference.md b/docs/engine-ctx-reference.md index cfa5f78..a7412fc 100644 --- a/docs/engine-ctx-reference.md +++ b/docs/engine-ctx-reference.md @@ -6,7 +6,7 @@ Struct `EngineCtx`, size `0xa1000`. Applied to the Ghidra `/v2` image (dispatch- | offset | name | type | note | |---|---|---|---| -| `0x408` | `gfx_obj_registry` | `int` | gfx object registry (std::map handle->object); op 0x1a2 insert / 0x215 find | +| `0x408` | `gfx_obj_registry` | `int` | retained gfx-object map (std::map handle->object); geometry/draw get-or-create, 0x215 returns obj+4 source slot, 0x1f7 erases | | `0x40c` | `sys4ini_count` | `int` | SYS4INI record count | | `0x410` | `archive_name_table` | `void*` | archive-name table base (arc_id*0x100 indexes it) | | `0x414` | `sys4ini_records` | `void*` | SYS4INI 80-byte record base {name[64],arc_id,file_number,offset,size}; record = base + id*0x50 | diff --git a/docs/engine-re.md b/docs/engine-re.md index f02d22c..4bf1346 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -123,7 +123,7 @@ companion index/pointer accessor. **Hot-helper naming pass (2026-07-09, lever #2).** Ghidra's Function ID analyzer names **0** functions on this image (the bundled FidDbs don't cover the VC9/VS2008 static runtime; ~3,660 of 4,428 funcs stay -`FUN_`), and the library workers we actually touch were already hand-named (`gfx_registry_map_find`, +`FUN_`), and the library workers we actually touch were already hand-named (`gfx_object_query_source_slot`, etc.). So "STL/CRT auto-naming" had little to add — but the recon (rank unnamed funcs by call-count) surfaced the real win: ubiquitous **documented-but-unnamed helpers**. Named the top 5 (~2,400 call sites): `vm_operand_fetch`@`0x41b940` (1021 refs), `vm_operand_write`@`0x425fb0` (188), @@ -138,7 +138,7 @@ Ghidra name is the record. `vm-map/engine-ctx.toml` → generated `docs/engine-ctx-reference.md`; a `run_script_inline` pass created an `EngineCtx` Ghidra struct and retyped **all 419 dispatch handlers' `this` to `EngineCtx *`**, so they decompile `ctx->cur_ctx_index` / `ctx->cmd_type_table` / `ctx->run_state_flags` instead of `param_1 + 0x…` -(verified: `sleep_op_0xc8`, `gfx_op_0x215_register_query`). Add a field: edit `engine-ctx.toml`, run +(verified: `sleep_op_0xc8`, `gfx_op_0x215_query_source_slot`). Add a field: edit `engine-ctx.toml`, run `engine_ctx_build.py --build`, re-apply the struct. (The VM global bank `G[…]` is separate — `globals.toml`.) --- @@ -250,45 +250,39 @@ used. Absence in that capture ≠ absence of the native path. **The fix is tractable and Frida-free.** (b) does *not* mean an opaque native state machine. The subsystem is a **modelable data structure**: an object-record array (slot / geometry / cmd-type per object) plus a -handle→object registry (a hash map). The gfx ops are inserts/queries/writes against these, and the inserts -are **bytecode-driven** — so a faithful host-side model, with the gfx ops (`0x1a2`, `0x215`, and the -`0x212–0x21a` family) *executed* instead of stubbed, rebuilds the state from the same scripts. The opcode- -level summary lives in `vm-map/opcodes.toml` op `0x215`. +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 an operand-descriptor hash, +but that is a separate structure and is not what op `0x215` queries. The opcode-level source of truth is +`vm-map/opcodes.toml`. -#### The query registry is SEPARATE from the geometry object store (2026-07-07) — the retained-mode "2nd CG off-screen" fix +#### Op `0x215` queries the retained gfx-object's source slot (corrected 2026-07-09) -Modelling the gfx ops (above) exposed a subtle but decisive point that the first retained-mode -implementation got wrong. There are **two distinct native structures**, and they must stay distinct: +The decisive caller/callee detail is the owner pointer. Op `0x215`'s handler passes +`ECX = ctx+0x46614` to `gfx_object_query_source_slot` (`0x47f280`); that worker searches +`ECX+0x408`. `draw-texture` passes the same owner to `gfx_object_bind_draw` (`0x47e870`), whose +`gfx_object_get_or_create` uses the same `owner+0x408` map and writes the source surface slot to +`obj+4`. Therefore: -1. **The op-`0x215` query registry** — a `std::map` **populated ONLY by op `0x1a2`** - (`FUN_0042cf70` hash insert; native stores `map[handle] = handle`). `0x215` does `map.find(handle)` - → the found value (which equals the handle, and for small system/UI handles doubles as their surface - slot) or `0xffffffff` = **-1**. -2. **The geometry object store** — per-handle V18/V24/V16c/color/draw-bind, touched lazily by the - geometry SET ops and `draw-texture` (`gfx_object_get_or_create`). This feeds the compositor. +- absent handle → `0x215` returns `0xffffffff` (-1); +- geometry-only/unbound object → its default source slot is -1; +- draw-bound object → `0x215` returns the live source slot from `obj+4`. -The first `GfxState` conflated them: `GetOrCreate` (called by *every* geometry/draw op) also assigned a -fabricated per-object slot via an `AcquireSlot()` allocator, and `QuerySlot` (op `0x215`) returned it. -That is a fiction with **no basis in the engine** — the native `0x215` never allocates a slot. +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 earlier fix remains: `GetOrCreate` must not fabricate a slot. A fresh object stays unbound (-1) until +`draw-texture` supplies its real source slot. -Consequence, traced end-to-end in `SC0000` `label_12649` (the CG-load subroutine): a CG handle -(`0xcb2a` = `INIT2`'s `G[0x62456]`, idx 1) is **never `0x1a2`-registered**. Real engine → `0x215` returns -`-1` → the **fresh branch** runs → anchor comes from the `INIT2` arrays (`G[0x62469+idx]=400`, -`G[0x6247d+idx]=600`) → `dst = anchor − (w/2, h) = (0,0)`. Correct. But with the fabricated allocator the -*second* pass over the same handle found it "existing" (slot `4`) → the **existing branch** ran -`get-texture-size(4)` on a slot whose surface was never loaded (the bytecode's own slot table -`rec[s3]`/`G[0x3239]` gave slot `0`) → size `0` → `anchor = pos(0,0) + 0` → `dst = (0−400, 0−600) = -(−400,−600)` — the CG rendered off-screen. This is the bug that had been mis-attributed to "geometry -accumulation / drift" several times. - -**Fix (branch `feat/gfx-command-buffer`):** `GfxState` keeps a separate `_registry` (a `HashSet`) -populated only by `Register(handle)` (op `0x1a2`); `QuerySlot` returns `handle` if registered else `-1`, -and no longer consults the geometry store or invents slots. Verified: `Age.Cli gfx --boot SC0000.BIN` -→ all event CGs `dst=(0,0)`, zero `(−400,−600)` draws; Godot `--boot --shot` pages 1/2/4 render the -opening event CGs full-screen; engine 44/44; sweep parity 284 exit / 13 STEP-LIMIT unchanged. +This also explains the reported magic-circle retention end-to-end. `AE001H.AGF` (resource `0x37`) is +bound to the ritual object's surface slot. At the post-effect cleanup (SC0000 `0x3321`), the script queries that object with +`0x215`, sign-tests the returned slot, then executes `0x1f7(handle, 10)` followed by +`0x1fa(returned_slot)`. Native `0x1f7` removes the retained object group from this same map; `0x1fa` +releases the surface slot. The port's old separate-registry model returned -1, skipped the guarded cleanup, +and left the circle compositing. `GfxState.QuerySlot` now returns `GfxObject.SourceSlot`; VM op `0x1fa` +clears that surface slot. The booted SC0000 regression ends with no visible resource `0x37`; live +clicked-path validation confirmed the corrected disappearance on 2026-07-10. Note a **second, still-latent** gap this uncovered: `label_125bd` (which fills `rec[s3]`/`G[0x3239]` with -the per-object slots 4..13, called at `SC0000` `0x50f`) does **not** execute in a cold single-scene run — +the eight per-object slots 4..11, called at `SC0000` `0x50f`) does **not** execute in a cold single-scene run — the scene coroutine framework (ops `0x7b`/`0x140` + the `G[0xaba5c]==1` re-entry gate) routes cold flow past it, so every fresh CG is assigned slot `0`. It doesn't break the *opening* (one full-screen CG shown at a time, so sharing slot 0 is harmless and the fresh-branch geometry is correct regardless), but a scene @@ -296,7 +290,7 @@ with several simultaneous distinct-slot objects would need the setup to run. Tra work, separate from this fix. **⇒ Scene-coroutine framework — INVESTIGATION COMPLETE (2026-07-09).** The mechanism behind the slot-0 -collapse is now fully understood; what remains is a *host-model design choice*, not more RE. Summary: +collapse is fully understood; the native finding and the implemented host-model disposition follow: **The gate `G[0xaba5c]` is NATIVE scene-entry state — no script sets it to 1.** Across the whole corpus (429 references in 150 files) *every* `aba5c` reference is a read or a write of `0`; nothing anywhere writes @@ -306,7 +300,7 @@ handle array** (native entry-state a cold single-scene harness skips), NOT a sto **Corrected roles of the two branches** (the earlier head-start had them inverted). At SC0000 `0x450` `eq local0 = (aba5c==1)`; `0x457 jcc local0 label_462 `: - **`aba5c==1` → `label_462` "ループ開始" (loop start)** = the scene's **intro/setup LOOP**. Its body - `label_491` runs `call label_125bd` (@`0x50f`, the slot-table fill `G[0x3239..0x3256]=4..13`) plus ADV + `label_491` runs `call label_125bd` (@`0x50f`, the slot-table fill `G[0x3239..0x324e]=4..11`) plus ADV state init, UI-slot clears (a loop over `G[0x3239]`), intro draw — then `jmp label_462` (@`0x711`). A real loop, exited only when its iterator makes `G[0x6be]==exit-PC` (→ `mov aba5c 0`, `jmp label_45e`). - **`aba5c!=1` → `label_45e → call label_71b`** = the **scene CONTENT**: `label_71b` is a `switch(G[0x62ccf])` @@ -343,15 +337,18 @@ slot fix:** the same `0x7b`/`0x7c` + handler machinery is the ADV frame loop, so the seam for the interactive-ADV backlog (`0x90` hotspots / EMPTY scenes) and likely fixes the stuck magic circle (scene-phase cleanup). **Permanently out of static scope:** the real intro-transition *timing/pacing* inside the loop (it lives in the native video service) — we approximate it host-side, as everywhere else. -**Open for the spec (a choice, not RE):** how `G[0x6be]` initialises before the loop and the exact "run body -once then terminate" mechanic (the terminal must satisfy `G[0x6be]==G[0x6c3]`, and `G[0x6c3]` is a per-scene -immediate = that scene's exit-PC, so the host model can't hardcode `0x45e`). Context records = the 0x78-byte -coroutine records at `ctx+0x53d14`/`0x53d88` (see §"Frame cadence"). +**Host model implemented (2026-07-09).** `VirtualMachine` recognizes only the ADV `"LABEL","J"` form +(138 corpus scripts; all have the same terminal-check shape), synthesizes `G[0xaba5c]=1` on top-level +scene entry, forces one setup-body pass even if `G[0x6be]` is stale, then returns the terminal immediate +discovered from the following `mov`/`eq` pair. Thus no SC0000 offset is hardcoded. Op `0x7b` retains the +saved handler PCs as frame metadata; op `0x7c` is a host-scheduler marker because `IHost.FrameYield` and +`FrameClock` already own per-frame pacing. `TITLE.BIN`'s unrelated `"BIN","SC????.BIN"` service remains +stubbed. The real video-service timing remains intentionally unmodeled. -**Revealed issue (2026-07-08, post slot-fix): a magic-circle effect persists across the scene transition** -(screenshot: opening ritual circle still overlaid on the arena BG). A retained object not released at the -phase change — plausibly the same coroutine/lifecycle gap (scene-phase cleanup), or a separate release/clear -op. Verify once the coroutine framework runs. +**Magic-circle retention fixed in the host model (2026-07-09).** The effect is `AE001H.AGF` +(resource `0x37`). SC0000 already contains the correct teardown, but the port's wrong `0x215` query +returned -1 and skipped it. The corrected source-slot query now reaches `0x1f7(handle,10)` object erasure +and `0x1fa(slot)` surface release; the booted regression ends with no visible `0x37` object. #### gfx command-buffer — op contract table (2026-07-07, full family reversed) @@ -363,15 +360,15 @@ the dispatch table (`ctx[0x26c93+op]`); all renamed in the Ghidra project `gfx_o | op | handler | cmd | dir | argc | contract | |---|---|---|---|---|---| -| `0x1a2` | `0x42d360` | 3 | set | 1 | registry **insert**: key `"%c%8.8x"(3, operand-desc)` → `FUN_0042cf70` | -| `0x1f7` | `0x422270` | 5 | erase | 2 | registry **erase** (teardown, NOT create): `op2>1` → `gfx_registry_erase_range(op1,op2)` erases `[op1,op1+op2)`, else `gfx_registry_erase(op1)`. Objects are created lazily by the geometry SET ops. | -| `0x1fa` | `0x4224a0` | 3 | set | 1 | release element `[ctx+0x52bd4 + op1*4]` (vtbl free) + `FUN_00474e40(op1)` | +| `0x1a2` | `0x42d360` | 3 | set | 1 | operand-descriptor hash insert: key `"%c%8.8x"(3, operand-desc)` → `FUN_0042cf70`; separate from the retained object map | +| `0x1f7` | `0x422270` | 5 | erase | 2 | retained-object erase: `op2>1` → `gfx_object_erase_range(op1,op2)` erases `[op1,op1+op2)`, else `gfx_object_erase(op1)` | +| `0x1fa` | `0x4224a0` | 3 | set | 1 | release **surface slot** `ctx+0x52bd4[op1]` (vtbl free) + `FUN_00474e40(op1)` | | `0x1ff` | `0x4227b0` | 9 | set | 4 | 3 int→float params on obj op1 → `FUN_0047e800(op1,f2,f3,f4)` | | `0x202` | `0x4228d0` | 0xb | set | 5 | blit obj op1 with (op2,op3) + **packed ARGB** from op4(alpha)/op5(color) → `FUN_0047ea00` | | `0x203` | `0x4229a0` | 9 | set | 4 | draw obj op1 with op2 + packed color(op3/op4) → `FUN_0047e9b0` | | `0x212` | `0x4230c0` | 5 | set | 2 | `obj[ctx+0x14d54 + op1*4] -> +0x64 = op2` | | `0x213` | `0x423110` | 7 | set | 3 | `obj[0x14d54+op1*4] -> +0x68 = op2 ; +0x6c = op3` (an (x,y) pair) | -| `0x215` | `0x42a0b0` | 5 | **query** | 2 | registry **find**(op2 handle) → op1 (value / `0xffffffff`). **Drives slot-select.** | +| `0x215` | `0x42a0b0` | 5 | **query** | 2 | retained-object **find**(op2 handle) → op1 = obj+4 source slot / `0xffffffff`. **Drives setup and teardown.** | | `0x216` | `0x42a0f0` | 5 | **query** | 2 | read `[ctx+0x46d14 + op2*0x14]` → op1 | | `0x217` | `0x4231b0` | 9 | set | 4 | 3 int→float on obj op1 → `FUN_0047e960` (SETS a geom 3-vector) | | `0x218` | `0x42a130` | 9 | **query** | 4 | `FUN_0047f360(obj op1)` → op2,op3,op4 (GETS a geom 3-vector) | @@ -395,7 +392,7 @@ anchor math reads garbage). Both read object state the SET ops (`0x217`/`0x219`/ bytecode-driven, all host-modelable. **Model implication for the host-side reimplementation (Phase 2 input).** The subsystem is a set of -per-object records keyed by handle, carrying: a **slot** (from the `0x215` registry), a **position 3-vector** +per-object records keyed by handle, carrying: a live source **slot** (written by draw-texture, queried by `0x215`), a **position 3-vector** (`0x21a` get / a matching set), and an **anchor 3-vector** (`0x218` get / `0x217` set), plus color/blit params (`0x202`/`0x203`). The native workers (`FUN_0047xxxx` = the DirectDraw/surface layer) need **not** be modelled — only the object-record data model, so the QUERY ops return what the SET ops stored. That makes @@ -403,8 +400,9 @@ modelled — only the object-record data model, so the QUERY ops return what the correct `dst`/`w`/`h`. Ancillary per-object tables observed: `ctx+0x14d54` (obj pointers, fields `+0x64/ +0x68/+0x6c`), `ctx+0x46d14` (stride `0x14`), `ctx+0x52bd4` (element pointers), plus the `0x408` registry. -Worker functions decoded + annotated in the Ghidra project (2026-07-07): `gfx_registry_erase`(`0x47d850`), -`gfx_registry_erase_range`(`0x47d8b0`), `gfx_object_get_or_create`(`0x47ddb0`, inserts a zeroed default via +Worker functions decoded + annotated in the Ghidra project (updated 2026-07-09): `gfx_object_erase`(`0x47d850`), +`gfx_object_erase_range`(`0x47d8b0`), `gfx_object_query_source_slot`(`0x47f280`), +`gfx_object_get_or_create`(`0x47ddb0`, inserts a zeroed default via `gfx_object_init_default`@`0x472810`), the setters `gfx_set_vec18/24/16c`(`0x47e960/e910/e800`), the getters `gfx_get_vec18/24`(`0x47f360/f2e0`). @@ -438,25 +436,22 @@ animation/tween** — and two members were already named in prior RE (`0x234 gfx (`0x2bd`→`0x4251c0`, `0x2bf`→`0x425240`. The handful of `0x417xxx` handlers are trivial/marker-shaped — the default-handler neighbourhood — and are almost certainly no-ops or arg-poppers; triage before modelling.) -**Contract (decoded, representative ops `0x220`/`0x21e`, both `argc 6`, annotated in Ghidra):** same shape as -the geometry family — write **cmd-type `0xd`** into the current object record, fetch operands 1..6, call a -transform worker with `(int op1=handle, int op2, int op3, float op4, float op5, float op6)`. `0x220` uses raw -floats (worker `0x47ecc0`); **`0x21e` normalizes the 3 floats by `/_DAT_00571c28`** (runtime-init divisor, -static 0) so operand `0x64`=100 → a fraction → **scale/percentage** (worker `gfx_anim_set_channel`@`0x47eaa0`). -The worker calls the SAME `gfx_object_get_or_create` our `GfxState` already models, then arms an animation -channel on the object record: `obj+0x3c = op2`, `obj+0x50 = op3`, `obj+0x68 = 1` (enable), `obj+0xac = -vec3(op4,op5,op6)` (the transform target), and raises global dirty flags `ctx+0xb558/+0xb560`. Corpus idiom: -`0x220 (handle=0xcb20+k) 800 500 0 0 0` (size a CG object), `0x21e (handle) (val) 100 100 100 100` (scale/color -channels). **`0x234 anim_start` + `0x238 set_anim_clock` imply a per-frame clock that interpolates these -targets over time** — i.e. this is what makes `AE*` fades/effects *animate* rather than snap. +**Contract (corrected 2026-07-09, representative ops `0x21e`/`0x220`, both `argc 6`):** these are +independent matrix channels, not two encodings of one vec3 property. -**Model implication (Phase-2 input, mirrors the geometry family):** the DirectDraw workers need NOT be -modelled — extend the host `GfxState` object with the transform/anim fields (a transform `vec3` target + the -two scalar params + enable + an animation clock), have the SET ops (`0x21e/0x220/0x234/0x238/…`) write them and -the compositor apply the transform per-frame, stepping the clock on `anim_start`/`set_anim_clock`. This is a -spec/plan-worthy chunk (~18 effectful handlers + workers `0x47eaa0/0x47ecc0` + the per-frame stepping); the op -map above is the de-risked starting point. `tools/scene_opcode_coverage.py SC0000` measures the GAP shrink as -each lands. +- `0x21e` normalizes operands 4–6, then `gfx_object_set_scale_channel` (`0x47eaa0`) stores timing at + `obj+0x3c/+0x50` and calls `0x48af1d`, which writes the three values onto a 4×4 matrix diagonal at + `obj+0xac`: a **scale matrix**. +- `0x220` passes raw operands 4–6 to `gfx_object_set_translation_channel` (`0x47ecc0`), stores timing at + `obj+0x44/+0x58`, and calls `0x48afb1`, which writes them into matrix entries 12–14 at + `obj+0x1ac`: a **translation matrix**. +- `gfx_object_apply_transform_channels` (`0x472f00`) interpolates and combines both matrices separately. + Neither third component is opacity. + +**Port implication:** `GfxState` must ultimately retain separate scale and translation matrices/timing. +The current single `AnimTarget` plus Godot `TZ/100 = opacity` approximation is native-inaccurate and is +now tracked as transform-compositor debt. It was not the cause of the lingering circle: that was the skipped +`0x215`/`0x1f7`/`0x1fa` teardown above. ##### `anim_start`/`set_anim_clock` decoded + opening confirmed (2026-07-07, animation-slice Task 1) @@ -481,11 +476,10 @@ both annotated) and grepping the SC0000 opening settles the animation model and **Corrected host model (supersedes the "per-object clock" wording above):** - **Global clock** (from `0x238`): one `AnimClockDurationTicks` + a generation/reset marker the host watches to reset its wall-clock `elapsed` to 0. The host tweens all armed objects over this duration. -- **Per-object** (from `0x21e`/`0x220` = set transform directly; `0x234` = animate toward a target): the object's - transform target vec3 + the two scalar params + enable + a per-object generation (bumped by `anim_start`). -- **Residual (empirical, Task 6):** *which* vec3 component is opacity vs scale vs position lives in the DirectDraw - draw-worker we deliberately don't model. Determine it empirically from the animating channel + screenshot, not - by RE'ing the surface layer. +- **Per-object correction:** `0x21e` is scale and `0x220` is translation; they occupy distinct matrices and + timing fields. No component of either channel is opacity. +- **Residual:** finish separating these channels in `GfxState`/Godot and identify the remaining color/alpha + channel consumers. Do not reuse transform Z as alpha. ##### The opening render path is RETAINED, not immediate-mode (2026-07-08, ground-truth correction) @@ -778,14 +772,11 @@ Diagnosed with the new `--gfx-log` compositor/op trace (docs/tools-reference.md) (`0x215`) which returns **-1** for the (correctly-unregistered) CG/effect handles → the fallback at SC0000 `label_12649` does `G[0x62452] = lookup-array-2d(rec[s3]=G[0x3239], G[0x62450], 3, 0)` = **0** because the slot table `rec[s3]`/`G[0x3239]` is **empty**. That table is filled by `call label_125bd` - (SC0000 `0x50f`, slots 4..13), which is reached **only through the scene-coroutine framework** — the + (SC0000 `0x50f`, slots 4..11), which is reached **only through the scene-coroutine framework** — the `G[0xaba5c]` gate (`0x450`) + op `0x140` (`u0041F9C0`, coroutine LABEL/yield `"LABEL" "J"` @ `0x46d`). - **Op `0x140` is stubbed** (SC0000 GAP list) → the coroutine re-entry never routes through `label_125bd` - → slot table stays 0 → all layers collapse into slot 0. This is exactly the "second still-latent gap" - flagged above (§"the render drift's second half"), now confirmed as the cause of the visible grey BG on - multi-object pages. **Fix = implement the scene-coroutine framework (`0x7b`/`0x7c`/`0x140` + `G[0xaba5c]` - gate) so `label_125bd` runs** (or, as a targeted unblock, run `label_125bd`/seed `G[0x3239]` directly). - NOT a compositor/z-order/blend bug. Diagnostics: `AGE_DIAG_SETTEX=1` env → VM logs each `set-texture` + **Fixed by the bounded scene-coroutine host model:** `0x140` runs the setup body once, `label_125bd` + fills the eight slot records, and SC0000 resource `0x23` loads into assigned slot 5 instead of slot 0. + This was not a compositor/z-order/blend bug. Diagnostics: `AGE_DIAG_SETTEX=1` env → VM logs each `set-texture` slot operand + `query-gfx-object?` result. ### Differential offset-path oracle — engine-vs-VM control-flow diff (2026-07-09) diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 0662456..dee8da5 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -52,6 +52,16 @@ Companion op 0x8f `call` is INTRA-script (a local JSR), not cross-script -- see This also names the whole call graph statically (build/callscript-names.json). +### 0x7b `coroutine-save-yield-handlers` (u0041ADB0, argc 2) +- **summary:** (handler1_pc)(handler2_pc) — scene-coroutine: save the two per-frame yield/resume handler PCs. Native writes op1→ctx[0x6da88+idx*4], op2→ctx[0x6db28+idx*4] (idx=ctx[0x53d14] script-context index) + gfx cmd-type 5. SC0000 0x79: `0x7b label_3c9 label_41e` registers the ADV per-frame render→poll→yield handlers. Part of the scene-coroutine framework (see engine-re.md §Scene-coroutine framework); pairs with 0x7c (resume) + 0x140 (loop iterator). +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra: handler FUN_0041ebf0 (dispatch ctx[0x26c93+0x7b]) = {*(ctx+0x53d88+ctx[0x53d14]*0x78)=5; ctx[0x6da88+idx*4]=op1; ctx[0x6db28+idx*4]=op2}. Both operands are code PCs (handler labels). + +### 0x7c `coroutine-resume` (u00416A90, argc 0) +- **summary:** () — scene-coroutine RESUME point. Native requires run-state bit 0x2000000 (ctx[0x6dbc8]) set — THROWS (__CxxThrowException) if unset, so it is only ever reached on a scheduler-driven re-entry, NEVER on a cold first pass (cold flow jmps over it). Restores PC=ctx[0x53d28]+ctx[0x6dbcc]*4, clears the run-bit (ctx+0xa0ce4 &= ~0x2000000), resets input/line state. SC0000 0x443 (falls into the main loop label_444). See engine-re.md §Scene-coroutine framework. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra: handler FUN_00417cb0 (dispatch ctx[0x26c93+0x7c]). Guards on (ctx[0x6dbc8] & 0x2000000)==0 → throw; else restores PC = ctx[0x53d2c-slot] = ctx[0x53d28]+ctx[0x6dbcc]*4, ctx[0xa0ce4]=ctx[0x6dbc8]&0xfdffffff, clears input state (ctx[0x13bdc]/0xc6f8=-1 etc.). + ### 0x8f `call` (call, argc 1) - **summary:** intra-script subroutine call (local JSR): PC = frame.codebase + operand*4; pushes a return address on the per-frame return stack. NOT cross-script (that is call-script 0x03). - **grounding:** source=investigation, confidence=high @@ -64,17 +74,22 @@ This also names the whole call graph statically (build/callscript-names.json). Native handler sleep_op_0xc8 @0x420ec0 is NON-BLOCKING: it arms a timer (sleep_timer_arm @0x44cff0 at ctx+0x5f304 = active flag + start tick + duration) that the engine main loop polls, resuming the script when elapsed. Operand UNIT = MILLISECONDS (start = ms tick source DAT_0056f3d4, timeGetTime/GetTickCount class). duration<10 fast-paths via [0x56f0b8]; all real scene sleeps (100/750/1000) are >=10. The handler also writes gfx cmd-type 3 + runs anti-tamper checks, neither needed host-side. Port equivalent: the Godot host blocks the VM background thread ms while the per-frame compositor keeps presenting -> correctly reproduces the explicit one-shot dramatic pauses. NOTE: does NOT pace the rapid opening AE* burst (those draws have no sleep between them; their real pacer is unknown). Headless hosts no-op it (parity). +### 0x140 `coroutine-label-yield` (u0041F9C0, argc 4) +- **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. +- **grounding:** source=investigation, confidence=med +- **evidence:** Ghidra: handler 0x4299c0 (dispatch ctx[0x9b74c]=0x4299c0; created+typed EngineCtx*+annotated; Kelebek u0041F9C0 = VA-drift). Writes gfx cmd-type 9; op2→local_204, op3→local_104, op4→local_208; (*DAT_005c6018)(8, ctx[0x54fe8], &local_210) → FUN_00425fb0(1,ret). DAT_005c6018: 6 xrefs all READ, no static writer; FUN_00405740 (screen-fade) calls it w/ cmd 3, branches on ret 1/2 = transition progress = native video service. + ## draw ### 0x1a2 `gfx-cmd-register` (gfx-cmd-register, argc 1) -- **summary:** 0x1a2 (handle) — gfx cmd-type 3. Handler gfx_op_0x1a2_registry_insert @0x42d360: builds key '%c%8.8x'(3, operand-desc) and INSERTS operand 1 into the op-0x215 query registry (FUN_0042cf70, open-addressing hash; native stores map[handle]=handle). This is the SOLE populator of the registry op 0x215 queries — the geometry SET/draw ops (0x217/0x219/0x1ff/0x1fb/0x202/...) do NOT register. VM impl: GfxState.Register(handle) (a separate set from the geometry object store). Conflating the two (registering on every GetOrCreate) was the retained-mode geometry bug: CG handles wrongly read back as 'existing' and collapsed to (-400,-600). NOT save/scene (raw Kelebek VA 0x428010 drifted to op 0x1ac save handler). See docs/engine-re.md gfx op-contract table. +- **summary:** 0x1a2 (value) — gfx cmd-type 3. Handler gfx_op_0x1a2_descriptor_register@0x42d360 builds a key from operand 1's lvalue descriptor and inserts its value into an open-addressing descriptor hash (vm_lvalue_descriptor_hash_insert@0x42cf70). This structure is separate from op 0x215's retained gfx-object map; op 0x215 does not query this hash. NOT save/scene. - **grounding:** source=investigation, confidence=high -- **evidence:** Ghidra: real handler FUN_0042d360 (via dispatch table ctx[0x26c93+op]); sets *(ctx+0x53d88+ctx[0x53d14]*0x78)=3, sprintf("%c%8.8x",3,op1), FUN_0042cf70 (hash insert; counterpart of op 0x215 find). NOT save/scene (raw Kelebek VA 0x428010 drifted to op 0x1ac save handler). See docs/engine-re.md +- **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. ### 0x1f7 `gfx-elem-erase` (gfx-elem-erase, argc 2) -- **summary:** 0x1f7 (handle)(count) — gfx cmd-type 5. Handler gfx_op_0x1f7_elem_erase @0x422270: ERASES registry handles — if count>1 → gfx_registry_erase_range(handle,count) [erase [handle, handle+count)], else gfx_registry_erase(handle). It is a TEARDOWN/erase, NOT a create (corrects the earlier 'gfx-elem-create' reading). In label_12649 it runs after a 0x215 slot-query, before 0x1fa releases the slot. Objects are created lazily by the geometry SET ops (gfx_object_get_or_create). See docs/engine-re.md gfx op-contract table. +- **summary:** 0x1f7 (handle)(count) — erase retained gfx objects. Handler 0x422270 calls gfx_object_erase_range@0x47d8b0 for [handle,handle+count) when count>1, else gfx_object_erase@0x47d850. This removes entries from the same object map queried by op 0x215, so erased objects stop compositing. SC0000 uses it before op 0x1fa releases the returned surface slot. - **grounding:** source=investigation, confidence=high -- **evidence:** Ghidra handler 0x422270 (dispatch ctx[0x26c93+0x1f7]); count>1 → gfx_registry_erase_range @0x47d8b0 (loops gfx_registry_erase @0x47d850 over [op1,op1+op2)), else gfx_registry_erase(op1). gfx_registry_erase does map.find+erase on the ctx+0x408 registry. +- **evidence:** Ghidra handler 0x422270; gfx_object_erase_range@0x47d8b0 loops gfx_object_erase@0x47d850. Both operate on owner+0x408, the retained-object map also used by gfx_object_get_or_create/draw and gfx_object_query_source_slot. ### 0x1f8 `create-texture` (create-texture, argc 4) - **summary:** Allocate/prepare a texture slot: (slot, width, height, flag). e.g. `create-texture 0xd 0x190 0x1e 0x0` = slot 13, 400x30. @@ -87,7 +102,7 @@ Native handler sleep_op_0xc8 @0x420ec0 is NON-BLOCKING: it arms a timer (sleep_t - **evidence:** SC0000 Frida-confirmed 17/17 (0x25->EV052CA, 0x2e->EV052DB, 0x36->BG030A background); resolution rule validated on 586/595 captured loads. Traced in CG-load subroutine label_12649 as `set-texture G[0x62424] -1`. ### 0x1fa `gfx-elem-release` (gfx-elem-release, argc 1) -- **summary:** 0x1fa (idx) — gfx cmd-type 3. Handler gfx_op_0x1fa_elem_release @0x4224a0: releases the element at [ctx+0x52bd4 + idx*4] (virtual free, then nulls the slot) + FUN_00474e40(idx). In label_12649 it clears the working slot G[0x62452] after a 0x215/0x1f7 pair. See docs/engine-re.md gfx op-contract table. +- **summary:** 0x1fa (surface_slot) — release the surface at ctx+0x52bd4[slot] (virtual free, then null) and call FUN_00474e40(slot). It releases a surface slot, not a retained object handle. SC0000 feeds it the slot returned by op 0x215 after op 0x1f7 erases the associated object group. - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra handler 0x4224a0 (dispatch ctx[0x26c93+0x1fa]); frees ctx+0x52bd4[operand1*4] via vtbl, then FUN_00474e40(operand1). @@ -134,9 +149,9 @@ Native handler gfx_op_0x20c_present_frame (dispatch ctx[0x26c93+0x20c]) -> gfx_r - **evidence:** Ghidra handler 0x423110; writes obj+0x68/+0x6c from operands 2/3, obj from ctx+0x14d54[operand1*4]. ### 0x215 `query-gfx-object?` (query-gfx-object?, argc 2) -- **summary:** 0x215 (out)(handle_id) — native graphics command-buffer op. Real handler FUN_0042a0b0 (Ghidra-resolved via the dispatch table ctx[0x26c93+op]; Kelebek's 0x421160 is VA-drift, lands in an unrelated fn). Does TWO things: (1) writes cmd-type 5 into the CURRENT gfx-object record `[ctx+0x53d88 + ctx[0x53d14]*0x78]` (a command-buffer registration, parallel to op 0x1a2→type 3); (2) returns `out = map.find(handle_id)` over an engine-internal associative registry (found value, else 0xffffffff=not-found), sign-tested (gre/lt 0) to drive label_12649's slot-select branch + set working slot G[0x62452]. So `out` is NATIVE COMMAND-BUFFER STATE (the registry is populated by sibling gfx ops — op 0x1a2→FUN_0042cf70 is the hash insert), NOT the VM global bank → seeding story-state CANNOT reproduce it. Stubbed → constant return → every draw collapses to slot 0 → anchor-preserve reads foreign-sized textures → the cumulative bg/sprite drift. SETTLES the drift as (b) a genuine native op, NOT (a) state-divergence. Faithful fix = model the gfx command-buffer (record array + handle→object registry) and run the gfx ops instead of stubbing — static/Frida-free (handlers now readable; inserts are bytecode-driven). Full decode + verdict: docs/engine-re.md (op 0x215 section). RETAINED-MODE FIX (2026-07-07): the registry MUST be separate from the geometry object store — it is populated ONLY by op 0x1a2, never by the geometry SET/draw ops. VM: QuerySlot returns the registered value (=handle) or -1, NOT a fabricated per-object slot. CG handles are never 0x1a2-registered → query returns -1 → label_12649 takes its FRESH branch (anchor from the INIT2 arrays) → dst=(0,0). The prior GfxState.GetOrCreate-assigns-AcquireSlot model made CG handles read as 'existing' → existing branch called get-texture-size on the wrong slot (0) → dst=(-400,-600) off-screen (the '2nd CG off-screen' bug). +- **summary:** 0x215 (out_slot)(handle) — query the retained gfx-object map. Handler gfx_op_0x215_query_source_slot@0x42a0b0 calls gfx_object_query_source_slot@0x47f280 with owner ctx+0x46614. The worker searches owner+0x408, the same map populated by geometry/draw workers, and returns obj+4: the live source-surface slot written by draw-texture, or -1 if absent. A geometry-only object remains unbound and returns -1. SC0000 uses a successful result for existing-object geometry and query-guarded teardown: op 0x1f7 erases the object group and op 0x1fa releases this slot. VM fix 2026-07-09 restores AE001H magic-circle cleanup. - **grounding:** source=investigation, confidence=high -- **evidence:** Ghidra: real handler FUN_0042a0b0 = {*(ctx+0x53d88+ctx[0x53d14]*0x78)=5; out=FUN_0047f280(FUN_0041b940(2))}. FUN_0047f280 = std::map::find (returns mapped value or 0xffffffff); FUN_0041b940(2) = operand-fetch of operand 2 (the handle key); FUN_00425fb0(1,val) = operand-write to `out`. Registry populated by op 0x1a2 handler FUN_0042d360 → FUN_0042cf70 (open-addressing hash insert). Bytecode sites: SC0000 label_12649 (0x12670) + label_123ef (0x12419/0x12450), handle-ids from 0x62455[idx] (±offset); result gre/lt 0 branches slot-select. Record table 0x3239 (label_125bd @0x0050f) assigns per-object slots 4..13. +- **evidence:** Ghidra asm: handler 0x42a0b0 passes ECX=ctx+0x46614 to 0x47f280. That worker searches ECX+0x408 and returns resolved object+4. gfx_object_bind_draw@0x47e870, called with the same owner, get-or-creates in owner+0x408 and writes source slot to object+4. SC0000 post-effect cleanup 0x3321 queries G[0x62457], sign-tests, then executes 0x1f7(handle,10) + 0x1fa(returned_slot); AE001H res 0x37 was bound to that handle. ### 0x216 `query-gfx-field?` (query-gfx-field?, argc 2) - **summary:** 0x216 (out)(idx) — gfx cmd-type 5. Handler gfx_op_0x216_query_table46d14 @0x42a0f0: out = *(ctx+0x46d14 + idx*0x14). A per-object field query over a stride-0x14 table. See docs/engine-re.md gfx op-contract table. @@ -164,12 +179,14 @@ Native handler gfx_op_0x20c_present_frame (dispatch ctx[0x26c93+0x20c]) -> gfx_r - **evidence:** Ghidra handler 0x42a1b0; FUN_0047f2e0(obj op1) + 3x→FUN_00425fb0(2/3/4). label_12649 site 0x00c86 handle=G[0x62457] → G[0x62498/9/a]. ### 0x21e `set-anim-transform-norm` (set-anim-transform-norm, argc 6) -- **summary:** (handle)(p1)(p2)(x)(y)(z) — set sprite transform channel, NORMALIZED (float operands /_DAT_00571c28 ~percent); cmd-type 0xd, worker gfx_anim_set_channel@0x47eaa0. SC0000 opening @0xf73+ on INIT2 CG handles. Cluster 0x21c-0x243. Handler 0x423350; Kelebek VA 0x421450 is drift. +- **summary:** (handle)(delay)(duration)(sx)(sy)(sz) — set the normalized SCALE-matrix channel. Handler 0x423350 normalizes sx/sy/sz; gfx_object_set_scale_channel@0x47eaa0 stores timing at obj+0x3c/+0x50 and constructs a diagonal 4x4 scale matrix at obj+0xac. Separate from op 0x220 translation; neither channel is opacity. - **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra 0x47eaa0 calls matrix builder 0x48af1d, which writes sx/sy/sz to diagonal entries 0/5/10 and identity entry 15. gfx_object_apply_transform_channels@0x472f00 consumes this independently of obj+0x1ac. ### 0x220 `set-anim-transform-abs` (set-anim-transform-abs, argc 6) -- **summary:** (handle)(p1)(p2)(x)(y)(z) — set sprite transform channel, ABSOLUTE (raw floats); cmd-type 0xd, worker 0x47ecc0. Twin of 0x21e. SC0000 opening @0x18a5+ on INIT2 CG handles. Cluster 0x21c-0x243. Handler 0x4234e0; Kelebek VA 0x4215D0 is drift. +- **summary:** (handle)(delay)(duration)(tx)(ty)(tz) — set the absolute TRANSLATION-matrix channel. Handler 0x4234e0 passes raw floats to gfx_object_set_translation_channel@0x47ecc0, which stores timing at obj+0x44/+0x58 and constructs an identity 4x4 matrix with translation at obj+0x1ac. Separate from op 0x21e scale; neither channel is opacity. - **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra 0x47ecc0 calls matrix builder 0x48afb1, which writes identity diagonal and tx/ty/tz to entries 12/13/14. gfx_object_apply_transform_channels@0x472f00 combines this independently of obj+0xac. ### 0x228 `u00421940` (u00421940, argc 5) - **summary:** 0x228 query-position (succ)(handle)(outX)(outY)(outZ): read the object's current computed position into vars (worker FUN_0047cdd0). C# VM: writes V24 + success flag. See docs/engine-re.md §SC0000 anim cluster. @@ -444,14 +461,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 -### 0x7b `u0041ADB0` (u0041ADB0, argc 2) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - -### 0x7c `u00416A90` (u00416A90, argc 0) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x7f `u00414C60` (u00414C60, argc 1) - **summary:** — - **grounding:** source=kelebek, confidence=low @@ -684,10 +693,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=med -### 0x140 `u0041F9C0` (u0041F9C0, argc 4) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x141 `u0041FAA0` (u0041FAA0, argc 1) - **summary:** — - **grounding:** source=kelebek, confidence=low diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index c50ff4d..53c60fa 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -429,7 +429,7 @@ anchor doubling — a geometry issue independent of timing.) **Open (the real burst pacer):** what advances the rapid opening CG/AE\* burst frame-to-frame is **unknown** — not `sleep`, not `present-frame` (only 2× in the whole scene), not the coroutine ops (absent from the burst). Next: profile the **real Godot run** (`--trace-histogram`) of SC0000's `0x3958–0x3973` loop + gfx-op sequence. -The scene-coroutine framework (`0x7b`/`0x7c`/`0x140` + `G[0xaba5c]` gate + `label_125bd`) remains deferred. +The scene-coroutine framework was deferred here; the bounded host model is completed below (2026-07-09). ### Diagnostics framework extended (2026-07-08) @@ -598,7 +598,7 @@ fixed" with "opening looks right." **Next candidates (deferred).** (a) Graphics geometry/blend fidelity — AE* alpha/blend + per-frame compositing + cold-object anchors (the thing that makes the paced opening actually *look* right). (b) Wire the Ctrl `Speed` multiplier (ADV-mode-scope RE). (c) Full scene-coroutine framework (`0x7b`/`0x7c`/`0x140`) -for interactive multi-object scenes. (d) Model `0xcd get-input-type` (name-entry interactivity, the separate +for interactive multi-object scenes (**completed below, 2026-07-09**). (d) Model `0xcd get-input-type` (name-entry interactivity, the separate input gap noted above). ### A2b — Blend & transparency (slice A) ✅ DONE (2026-07-08) @@ -651,3 +651,47 @@ plan `.../plans/2026-07-08-sc0000-anim-transform-cluster.md`, branch `feat/anim- `0x236` timed/movie op, and the rare `0x21c/0x21d/0x224/0x242/0x243/0x23d/0x20a/0x20e` tail. Adjacent non-cluster gaps remain: `draw-string 0x204`×205 (on-screen text) and `play-sound-effect`. **Whole-scene visual validation is the user's call** (they deferred confirmation until the scene is coherent). + +### A2b -- Scene-coroutine host model (2026-07-09) -- DONE + +The native mechanism is fully reversed in `docs/engine-re.md` under Scene-coroutine framework. +The port deliberately models its observable ADV lifecycle instead of emulating the runtime-resolved +video service behind op `0x140`. + +**Bounded model for this slice:** + +1. Detect only the corpus-wide ADV form `0x140 out "LABEL" "J" in`. `TITLE.BIN`'s unrelated + `"BIN" "SC????.BIN"` use remains unmodeled and must not acquire ADV scene-entry behavior. +2. On a top-level entry at offset zero, synthesize native scheduler state `G[0xaba5c]=1` for a script + containing that ADV form. A captured global-write snapshot cannot supply it because the native scene + loader does not set it through the script operand-write helper. +3. At each ADV labeled-yield site, force exactly one setup-body iteration, then return the terminal value + encoded by the site's following `mov terminal, immediate` + `eq terminal, out` sequence. This handles a + stale prior-scene `out` value and avoids hardcoding SC0000's `0x45e`; all 138 corpus ADV sites share the + same shape. +4. Record op `0x7b`'s two saved handler PCs as frame metadata. Consume op `0x7c` as the host-scheduler + resume marker: the port's existing `IHost.FrameYield`/`FrameClock` path supplies per-frame pacing, so it + does not recursively execute the native render/poll/yield bytecode handlers. + +**Acceptance gates:** a synthetic stale-terminal scene runs its setup body once and reaches content; a +non-ADV `0x140` remains unchanged/stub-reported; real SC0000 executes op `0x140` twice, clears +`G[0xaba5c]`, and fills the slot-table columns (`G[0x3239..0x324e] = 4..11`) before content. Then run the engine +suite, corpus sweep, Godot self-test, and live/pixel validation of the previously grey multi-layer page. + +**Result.** The bounded model landed in `VirtualMachine`/`ExecFrame` with four focused tests. SC0000 now +executes `0x140` twice, runs `label_125bd` once, clears the native entry gate, and loads resource `0x23` +into assigned slot 5 rather than the broken slot 0. Static corpus validation found 138 ADV sites and zero +shape mismatches; the one non-ADV `TITLE.BIN` site remains stub-reported. + +**Verified:** engine **85/85**; full sweep unchanged at **284 exit / 13 STEP-LIMIT**; Godot threaded +self-test `SELFTEST OK` (3 lines, full handling). The native transition timing remains host-approximated. +SC0000 coverage is now **77/129 handled (59.7%)**, with 52 GAP ops / 613 GAP instructions. + +**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 +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 +asserts no visible resource `0x37` remains; engine suite **86/86**. **Live clicked-path validation +confirmed the fix on 2026-07-10:** the magic circle now disappears at the intended transition. diff --git a/docs/superpowers/plans/2026-07-09-re-tooling-handler-labels-and-iat.md b/docs/superpowers/plans/2026-07-09-re-tooling-handler-labels-and-iat.md index d13a847..13279ac 100644 --- a/docs/superpowers/plans/2026-07-09-re-tooling-handler-labels-and-iat.md +++ b/docs/superpowers/plans/2026-07-09-re-tooling-handler-labels-and-iat.md @@ -240,7 +240,7 @@ Expected: named imports carry parameter types. - [ ] **Step B8: Save, verify annotations intact, update docs, commit** -Confirm a sample of pre-existing renames/plate comments still present (`get_function_by_address` on `gfx_op_0x215_register_query`, `sleep_op_0xc8`). Run `mcp__ghidra__save_program`. +Confirm a sample of pre-existing renames/plate comments still present (`get_function_by_address` on `gfx_op_0x215_query_source_slot`, `sleep_op_0xc8`). Run `mcp__ghidra__save_program`. Edit `docs/engine-re.md` (runbook: IAT reconstruction is done; note graft vs fallback) and `docs/tools-reference.md` (`apply_imports.py`). ```bash diff --git a/docs/superpowers/plans/2026-07-09-stl-crt-naming.md b/docs/superpowers/plans/2026-07-09-stl-crt-naming.md index 496e1d2..c3e87a7 100644 --- a/docs/superpowers/plans/2026-07-09-stl-crt-naming.md +++ b/docs/superpowers/plans/2026-07-09-stl-crt-naming.md @@ -71,21 +71,18 @@ Expected: an explicit STRONG/WEAK verdict with the number. - [ ] **Step 1: Seed `vm-map/lib-functions.toml` with the known library functions** -Include the library functions we've identified in RE (grep `engine-re.md` for `std::map`, `FUN_0047f280`, `FUN_0042cf70`, hash insert/find, `operator new`, etc.). Start conservative — only addresses we're confident about: +Include only actual library functions identified in RE. **Correction (2026-07-09): do not seed +`0x47f280` or `0x42cf70` here** — later caller/field analysis proved they are engine-purpose-specific +`gfx_object_query_source_slot` and `vm_lvalue_descriptor_hash_insert`, not generic STL helpers. +Start conservative — only addresses and roles we're confident about: ```toml # vm-map/lib-functions.toml -- CANONICAL registry of identified statically-linked library functions. # Generated: build/lib-functions.json + docs/lib-functions-reference.md via tools/lib_functions_build.py --build. # Applied to /v2 via run_script_inline (rename; never clobbers USER_DEFINED). Grows as we identify more. [[func]] -address = 0x47f280 -name = "std_map_find" -note = "std::map::find over the gfx object registry (op 0x215 handler calls it; returns value or 0xffffffff)" -source = "native-RE" -confidence = "high" -[[func]] -address = 0x42cf70 -name = "gfx_registry_hash_insert" -note = "open-addressing hash insert into the op-0x215 query registry (op 0x1a2 handler calls it)" +address = 0x5502be +name = "operator_new" +note = "VC9 operator new; malloc + new-handler retry + bad_alloc" source = "native-RE" confidence = "high" ``` @@ -111,7 +108,8 @@ Expected: N renamed (or skipped if we'd already named them), 0 clobbers. - [ ] **Step 6: Validate + commit** -`decompile_function 0x42a0b0` (`gfx_op_0x215_register_query`) — its registry-find call should read `std_map_find(...)`. +`decompile_function 0x42a0b0` (`gfx_op_0x215_query_source_slot`) — verify its purpose-specific +`gfx_object_query_source_slot(...)` call was not clobbered by the library pass. ```bash git add vm-map/lib-functions.toml tools/lib_functions_build.py tools/test_lib_functions.py docs/lib-functions-reference.md git commit -m "re: curated library-function registry (lib-functions.toml) + apply" diff --git a/docs/superpowers/specs/2026-07-09-re-tooling-handler-labels-and-iat-design.md b/docs/superpowers/specs/2026-07-09-re-tooling-handler-labels-and-iat-design.md index c93a74a..41940f6 100644 --- a/docs/superpowers/specs/2026-07-09-re-tooling-handler-labels-and-iat-design.md +++ b/docs/superpowers/specs/2026-07-09-re-tooling-handler-labels-and-iat-design.md @@ -35,7 +35,7 @@ where `disp = 0x9b24c + op*4` (word index `0x26c93 + op`; e.g. `ctx[0x26e3f]=0x4 3. For each real `(op, handler_va)`: - `create_function` at `handler_va` if none exists. - **Preserve good names:** if the function already has a non-`FUN_`/`LAB_` name (e.g. - `gfx_op_0x215_register_query`, `sleep_op_0xc8`), do **not** rename — only ensure a plate comment + `gfx_op_0x215_query_source_slot`, `sleep_op_0xc8`), do **not** rename — only ensure a plate comment records the opcode. Rename only raw `FUN_xxxx`/`LAB_xxxx` → `op_0xNN_handler`. - `set_plate_comment`: `opcode 0xNN dispatch handler; resolved via ctx[0x26c93+op] in FUN_00413860`. 4. Emit `build/op-handler-map.json` (`{ "0x1ac": {"handler": "0x427fb0", "name": "..."}, ... }`). diff --git a/docs/superpowers/specs/2026-07-09-stl-crt-naming-design.md b/docs/superpowers/specs/2026-07-09-stl-crt-naming-design.md index 973a064..282b879 100644 --- a/docs/superpowers/specs/2026-07-09-stl-crt-naming-design.md +++ b/docs/superpowers/specs/2026-07-09-stl-crt-naming-design.md @@ -51,8 +51,9 @@ is a known library function." **Small by design** (a handful to low-dozens), not - Task 1: report the newly-named count; spot-check 2–3 (e.g. does `FUN_0047f280` now carry a `std::map`-family name? does a known `operator new` site read named?). -- Task 2 (if taken): decompile `gfx_op_0x215_register_query` — its `gfx_registry_map_find(...)` / - `FUN_0047f280` call should read as the curated `std_map_find`; `--lint` clean; 0 clobbers. +- Task 2 (if taken): do not classify `0x47f280` or `0x42cf70` as generic STL helpers. Later RE proved + they are purpose-specific `gfx_object_query_source_slot` and `vm_lvalue_descriptor_hash_insert`; + decompile `gfx_op_0x215_query_source_slot` to verify the former remains named; `--lint` clean; 0 clobbers. - `save_program` succeeds. ## Scope & boundaries diff --git a/engine/Age.Engine.Tests/CoroutineHostModelTests.cs b/engine/Age.Engine.Tests/CoroutineHostModelTests.cs new file mode 100644 index 0000000..b5888f3 --- /dev/null +++ b/engine/Age.Engine.Tests/CoroutineHostModelTests.cs @@ -0,0 +1,114 @@ +using System.Collections.Generic; +using System.Linq; +using Age.Engine.Diagnostics; +using Age.Engine.Hosting; +using Age.Engine.Model; +using Age.Engine.Sys4; +using Age.Engine.Vm; +using Xunit; + +public class CoroutineHostModelTests +{ + private const int T_IMM = 0, T_STR = 2, T_GINT = 3, T_LINT = 9; + private const int SceneEntryGate = 0xaba5c; + private static readonly OpcodeTable Table = OpcodeTableJson.Load(Paths.OpcodesJson); + + private static int Op(string label) => Table.ByLabel(label)!.Value; + private static Instruction Ins(int offset, int opcode, params Operand[] args) => new(offset, opcode, args); + + private static Script Script(params Instruction[] instructions) => new() + { + Name = "COROUTINE-TEST", + Header = new ScriptHeader(0, 0, 0, 0, 0, 0), + Instructions = instructions, + IndexByOffset = instructions.Select((ins, i) => (ins.Offset, i)).ToDictionary(x => x.Offset, x => x.i), + Strings = new Dictionary { [0x1000] = "LABEL", [0x1001] = "J" }, + }; + + [Fact] + public void AdvLabeledYieldRunsSetupExactlyOnceThenUsesStructuralTerminal() + { + const int output = 0x6be, terminal = 0x6c3, setupCount = 0x7000, slot = 0x7001, observed = 0x7002; + var script = Script( + Ins(0x00, Op("eq"), new(T_LINT, 0), new(T_GINT, SceneEntryGate), new(T_IMM, 1)), + Ins(0x07, Op("jcc"), new(T_LINT, 0), new(T_IMM, 0x20), new(T_IMM, 0x80)), + Ins(0x20, 0x140, new(T_GINT, output), new(T_STR, 0x1000), new(T_STR, 0x1001), new(T_GINT, output)), + Ins(0x29, Op("mov"), new(T_GINT, terminal), new(T_IMM, 0x77)), + Ins(0x2e, Op("eq"), new(T_LINT, 1), new(T_GINT, terminal), new(T_GINT, output)), + Ins(0x35, Op("jcc"), new(T_LINT, 1), new(T_IMM, 0x60), new(T_IMM, 0x40)), + Ins(0x40, Op("add"), new(T_GINT, setupCount), new(T_GINT, setupCount), new(T_IMM, 1)), + Ins(0x47, Op("mov"), new(T_GINT, slot), new(T_IMM, 4)), + Ins(0x4c, Op("jmp"), new Operand(T_IMM, 0x20)), + Ins(0x60, Op("mov"), new(T_GINT, SceneEntryGate), new(T_IMM, 0)), + Ins(0x65, Op("jmp"), new Operand(T_IMM, 0x80)), + Ins(0x80, Op("mov"), new(T_GINT, observed), new(T_GINT, slot)), + Ins(0x85, Op("exit"))); + + var sink = new RecordingTraceSink { TracingSteps = true }; + var vm = new VirtualMachine(script, Table, new CaptureHost(), sink: sink); + vm.Globals[output] = 0x77; // stale value from a previous scene: already equal to this scene's terminal + vm.Run(); + + Assert.Equal("exit", vm.HaltReason); + Assert.Equal(1, vm.Globals[setupCount]); + Assert.Equal(4, vm.Globals[observed]); + Assert.Equal(0, vm.Globals[SceneEntryGate]); + Assert.Equal(0x77, vm.Globals[output]); + Assert.Equal(2, sink.Events.Count(e => e.Kind == TraceEventKind.Step && e.Opcode == 0x140)); + } + + [Fact] + public void NonAdvLabeledServiceIsLeftStubbedAndDoesNotInjectSceneEntryGate() + { + var script = new Script + { + Name = "NON-ADV-140", + Header = new ScriptHeader(0, 0, 0, 0, 0, 0), + Instructions = new[] + { + Ins(0, 0x140, new(T_GINT, 0x699), new(T_STR, 0x2000), new(T_STR, 0x2001), new(T_GINT, 0x699)), + Ins(9, Op("exit")), + }, + IndexByOffset = new Dictionary { [0] = 0, [9] = 1 }, + Strings = new Dictionary { [0x2000] = "BIN", [0x2001] = "SC????.BIN" }, + }; + var sink = new RecordingTraceSink { TracingSteps = true }; + var vm = new VirtualMachine(script, Table, new CaptureHost(), sink: sink); + vm.Globals[0x699] = 123; + vm.Run(); + + Assert.Equal(123, vm.Globals[0x699]); + Assert.False(vm.Globals.ContainsKey(SceneEntryGate)); + Assert.Contains(sink.Events, e => e.Kind == TraceEventKind.Stub && e.Opcode == 0x140); + } + + [Fact] + public void CoroutineHandlerOpsAreConsumedByTheHostSchedulerModel() + { + var script = Script( + Ins(0, 0x7b, new(T_IMM, 0x30), new(T_IMM, 0x40)), + Ins(5, 0x7c), + Ins(6, Op("exit"))); + var sink = new RecordingTraceSink { TracingSteps = true }; + var vm = new VirtualMachine(script, Table, new CaptureHost(), sink: sink); + vm.Run(); + + Assert.DoesNotContain(sink.Events, e => e.Kind == TraceEventKind.Stub && (e.Opcode == 0x7b || e.Opcode == 0x7c)); + } + + [Fact] + public void Sc0000EntryRunsSetupAndFillsDistinctTextureSlots() + { + var script = Sys4Loader.Load(Paths.Scripts()["SC0000.BIN"], Table); + var sink = new RecordingTraceSink { TracingSteps = true }; + var vm = new VirtualMachine(script, Table, new CaptureHost(), + new VmOptions(HaltAtWaitForInput: true), sink: sink); + vm.Run(); + + Assert.Equal("wait-for-input", vm.HaltReason); + Assert.Equal(0, vm.Globals[SceneEntryGate]); + Assert.Equal(new long[] { 4, 5, 6, 7, 8, 9, 10, 11 }, + Enumerable.Range(0, 8).Select(i => vm.Globals[0x3239 + i * 3]).ToArray()); + Assert.Equal(2, sink.Events.Count(e => e.Kind == TraceEventKind.Step && e.Opcode == 0x140)); + } +} diff --git a/engine/Age.Engine.Tests/GfxCommandBufferTests.cs b/engine/Age.Engine.Tests/GfxCommandBufferTests.cs index 52e3219..edf647f 100644 --- a/engine/Age.Engine.Tests/GfxCommandBufferTests.cs +++ b/engine/Age.Engine.Tests/GfxCommandBufferTests.cs @@ -39,24 +39,24 @@ public class GfxCommandBufferTests private static (int, Operand[]) Register(int handle) => (0x1a2, new[] { G(handle) }); [Fact] - public void QueryReturnsMinusOneUntilRegistered_ThenTheHandle() + public void QueryReturnsMinusOneUntilDrawBound_ThenSourceSlot() { - // Native contract (docs/engine-re.md op 0x215/0x1a2): the query registry is populated ONLY by op 0x1a2 - // (gfx-cmd-register). Giving a handle geometry via set-geom (0x217) must NOT register it — query stays -1 - // so a CG handle takes label_12649's fresh branch. After 0x1a2, query returns the handle (native - // map[handle]=handle; small system handles double as their surface slot). + // Op 0x215 returns obj+4 from the retained gfx object. Geometry creates the object but leaves it unbound; + // op 0x1a2's descriptor registry is unrelated. Draw-texture binds the source slot returned by the query. var t = T(); var scene = ScriptAssembler.Assemble(t, "GFX", new List<(int, Operand[])> { - MovGI(1, 0xcb2a), MovGI(2, 0xd), MovGI(3, 0), - SetGeom3(1, 3, 3, 3), // 0xcb2a: geometry only, NOT registered - Register(2), // 0xd: op 0x1a2 registers it - Query(10, 1), Query(11, 2), Exit(), + 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(), }, System.Array.Empty()); var vm = new VirtualMachine(scene, t, new RecordingHost()); vm.Run(); - Assert.Equal(-1, vm.Globals[10]); // geometry-only CG handle -> -1 -> fresh branch (the bug fix) - Assert.Equal(0xd, vm.Globals[11]); // 0x1a2-registered handle -> its value (== handle) + Assert.Equal(-1, vm.Globals[10]); + Assert.Equal(6, vm.Globals[11]); } private static (int, Operand[]) BlitColor(int h, int x, int y, int alpha, int color) diff --git a/engine/Age.Engine.Tests/GfxStateTests.cs b/engine/Age.Engine.Tests/GfxStateTests.cs index 09160af..5e7a9c0 100644 --- a/engine/Age.Engine.Tests/GfxStateTests.cs +++ b/engine/Age.Engine.Tests/GfxStateTests.cs @@ -4,19 +4,20 @@ using Xunit; public class GfxStateTests { [Fact] - public void QueryRegistryIsPopulatedOnlyByRegister_NotByGeometryOps() + public void QueryReturnsBoundSourceSlot_NotOperandRegistryValue() { - // Native contract (docs/engine-re.md op 0x215/0x1a2): the op-0x215 query registry is populated ONLY by - // op 0x1a2 (gfx-cmd-register). Merely giving a handle geometry (GetOrCreate, as the set-geom ops do) - // must NOT make query-gfx-object return a slot for it — otherwise a CG handle (never 0x1a2-registered) - // wrongly takes label_12649's existing branch and collapses off-screen. + // Native op 0x215 queries the retained-object map and returns obj+4, the source slot set by draw-texture. + // Geometry alone creates an object but leaves obj+4 at -1. Op 0x1a2 is a separate descriptor registry. var g = new GfxState(); - g.GetOrCreate(0xcb2a).V18 = (400, 600, 0); // geometry only, like the fresh CG-load branch - Assert.Equal(-1, g.QuerySlot(0xcb2a)); // NOT registered => -1 => fresh branch (correct) + g.GetOrCreate(0xcb2a).V18 = (400, 600, 0); + Assert.Equal(-1, g.QuerySlot(0xcb2a)); - g.Register(0xd); // op 0x1a2 registers a small system/UI handle - Assert.Equal(0xd, g.QuerySlot(0xd)); // native map[handle]=handle; the value doubles as its slot - Assert.Equal(-1, g.QuerySlot(0x9999)); // unknown -> -1 (matches native 0xffffffff) + g.Register(0xcb2a); + Assert.Equal(-1, 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)); } [Fact] @@ -31,12 +32,12 @@ public class GfxStateTests } [Fact] - public void ReleaseRemovesTheHandleFromTheQueryRegistry() + public void ReleaseRemovesTheRetainedObject() { var g = new GfxState(); - g.Register(0x10); - Assert.Equal(0x10, g.QuerySlot(0x10)); - g.Release(0x10); // op 0x1fa / 0x1f7 tear down the registration too + g.BindDraw(0x10, 4, 0, 0, 10, 10, 0, 0); + Assert.Equal(4, g.QuerySlot(0x10)); + g.Release(0x10); Assert.Equal(-1, g.QuerySlot(0x10)); } @@ -48,7 +49,8 @@ public class GfxStateTests public void EraseRangeRemovesHandlesInRange() { var g = new GfxState(); - g.Register(0x10); g.Register(0x11); g.Register(0x12); g.Register(0x20); + g.BindDraw(0x10, 1, 0, 0, 1, 1, 0, 0); g.BindDraw(0x11, 2, 0, 0, 1, 1, 0, 0); + g.BindDraw(0x12, 3, 0, 0, 1, 1, 0, 0); g.BindDraw(0x20, 4, 0, 0, 1, 1, 0, 0); g.EraseRange(0x10, 3); // count>1 → erase [0x10, 0x13) Assert.Equal(-1, g.QuerySlot(0x10)); Assert.Equal(-1, g.QuerySlot(0x12)); @@ -59,7 +61,7 @@ public class GfxStateTests public void EraseRangeCountLeOneErasesSingleHandle() { var g = new GfxState(); - g.Register(0x10); g.Register(0x11); + g.BindDraw(0x10, 1, 0, 0, 1, 1, 0, 0); g.BindDraw(0x11, 2, 0, 0, 1, 1, 0, 0); g.EraseRange(0x10, 1); // count<=1 → single handle Assert.Equal(-1, g.QuerySlot(0x10)); Assert.NotEqual(-1, g.QuerySlot(0x11)); @@ -100,4 +102,20 @@ public class GfxStateTests g.EraseRange(0x10, 1); Assert.Empty(g.SnapshotVisibleObjects()); // erased => gone from the registry => not composited } + + [Fact] + public void Sc0000EffectCleanupQueryEnablesObjectEraseAndSurfaceRelease() + { + var g = new GfxState(); + g.SetSurface(6, 0x37, 0); + g.BindDraw(0xcb8e, 6, 0, 0, 200, 200, 300, 180); + + int slot = g.QuerySlot(0xcb8e); // mirrors post-effect cleanup at SC0000 0x3321 + Assert.Equal(6, slot); + g.EraseRange(0xcb8e, 10); // op 0x1f7 + g.ClearSurface(slot); // op 0x1fa + + Assert.Empty(g.SnapshotVisibleObjects()); + Assert.Equal(-1, g.QuerySlot(0xcb8e)); + } } diff --git a/engine/Age.Engine.Tests/TextureOpsTests.cs b/engine/Age.Engine.Tests/TextureOpsTests.cs index 96e16e6..67cbf4f 100644 --- a/engine/Age.Engine.Tests/TextureOpsTests.cs +++ b/engine/Age.Engine.Tests/TextureOpsTests.cs @@ -24,15 +24,27 @@ public class TextureOpsTests } [Fact] - public void SC0000FiresTextureOpsWithSlot0FullScreenSlideshow() + public void SC0000FiresTextureOpsWithAssignedFullScreenSlot() { var table = OpcodeTableJson.Load(Paths.OpcodesJson); + var provider = Sys4ScriptProvider.Load(table); + var session = new GameSession(); + foreach (var name in new[] { "INITCONFIG.BIN", "INIT2.BIN", "INIT.BIN" }) + session.RunScene(Sys4Loader.Load(Paths.Scripts()[name], table), table, new CaptureHost(), provider: provider); + var script = Sys4Loader.Load(Paths.Scripts()["SC0000.BIN"], table); var host = new RecHost(); - new VirtualMachine(script, table, host).Run(); + var vm = new VirtualMachine(script, table, host, new VmOptions(MaxSteps: 20_000_000), provider); + foreach (var kv in session.Globals) vm.Globals[kv.Key] = kv.Value; + foreach (var kv in session.GlobalStrings) vm.GlobalStrings[kv.Key] = kv.Value; + vm.Run(); + Assert.True(host.Creates > 0, "create-texture should fire"); - // The intro loads a sequence of full-screen images into slot 0; res 0x23 is the first bg. - Assert.Contains(host.Sets, s => s.resId == 0x23 && s.slot == 0); - Assert.Contains(host.Draws, d => d.slot == 0 && d.w == 0x320 && d.h == 0x258); + // Boot + coroutine setup fill the handle/slot tables, so the first bg uses its assigned slot. + Assert.Contains(host.Sets, s => s.resId == 0x23 && s.slot == 5); + Assert.Contains(host.Draws, d => d.slot == 5 && d.w == 0x320 && d.h == 0x258); + // AE001H is the ritual/magic-circle sheet. At the post-effect transition SC0000 explicitly queries its + // retained object, erases the object group, and releases the returned slot; it must not survive the scene. + Assert.DoesNotContain(vm.Gfx.SnapshotVisibleObjects(), o => o.SurfaceResId == 0x37); } } diff --git a/engine/Age.Engine/Model/GfxState.cs b/engine/Age.Engine/Model/GfxState.cs index 52b3d42..7d0ef2e 100644 --- a/engine/Age.Engine/Model/GfxState.cs +++ b/engine/Age.Engine/Model/GfxState.cs @@ -63,16 +63,14 @@ public sealed class GfxState } // ---- geometry/draw object store (V18/V24/draw bind, the compositor's input) ---- - // Populated lazily by the geometry SET ops and draw-texture. Membership here does NOT mean the object is - // in the op-0x215 query registry (that is a SEPARATE native structure; see _registry below). + // Populated lazily by the geometry SET ops and draw-texture. Op 0x215 queries this same native map and + // 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(); - // ---- op-0x215 query registry (native std::map queried by gfx_op_0x215, populated ONLY by op 0x1a2 - // gfx-cmd-register -> FUN_0042cf70 hash insert). map[handle] = handle (native stores operand1 as the value; - // small system/UI handles double as their surface slot). CG handles are NEVER 0x1a2-registered, so - // query-gfx-object returns -1 for them and label_12649 takes its fresh branch (correct anchor from the - // INIT2 arrays) instead of collapsing onto a fabricated slot. See docs/engine-re.md op 0x215/0x1a2. ---- - private readonly HashSet _registry = 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; } @@ -103,35 +101,38 @@ public sealed class GfxState } } - /// Op 0x1a2 (gfx-cmd-register, native FUN_0042d360 -> FUN_0042cf70 hash insert): add the handle to - /// the op-0x215 query registry. Native inserts map[handle]=handle; QuerySlot returns that value (handle) or - /// -1. Only this op populates the query registry — geometry/draw ops do not. - public void Register(long handle) { lock (_lock) { _registry.Add(handle); } } + /// 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 (query-gfx-object): native returns std::map::find(handle) — the registered value (=handle), - /// or 0xffffffff (=-1) when the handle was never 0x1a2-registered. NOT a fabricated slot allocator. - public int QuerySlot(long handle) => _registry.Contains(handle) ? (int)handle : -1; - public bool IsRegistered(long handle) { lock (_lock) { return _registry.Contains(handle); } } + /// Op 0x215: look up in the retained gfx-object map and return obj+4, + /// the live source-surface slot written by draw-texture, or -1 when absent/unbound. + public int QuerySlot(long handle) + { + lock (_lock) + return _objects.TryGetValue(handle, out var o) ? o.SourceSlot : -1; + } + 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) { - lock (_lock) // re-entrant: EraseRange already holds _lock; op 0x1fa calls this directly + lock (_lock) // re-entrant: EraseRange already holds _lock { _objects.Remove(handle); - _registry.Remove(handle); // op 0x1fa/0x1f7 also tear down the query registration + _operandRegistry.Remove(handle); } } - /// Op 0x1f7 semantics (native gfx_registry_erase_range @0x47d8b0): erase handles in + /// Op 0x1f7 semantics (native gfx_object_erase_range @0x47d8b0): erase handles in /// [handle, handle+count) when count>1, else just . It is a teardown/erase, /// NOT a create — objects are created lazily by the geometry SET ops (gfx_object_get_or_create). public void EraseRange(long handle, long count) { - // Registry/slot cleanup (native gfx_registry_erase): removes the object from the registry, so it stops - // compositing next frame. Faithful to the engine (the render loop iterates the registry). + // Retained-object cleanup (native gfx_object_erase): removes the object from the map, so it stops + // compositing next frame. Faithful to the engine (the render loop iterates the retained-object map). lock (_lock) { if (count > 1) for (long i = handle; i < handle + count; i++) Release(i); diff --git a/engine/Age.Engine/Vm/ExecFrame.cs b/engine/Age.Engine/Vm/ExecFrame.cs index 361267d..5533dad 100644 --- a/engine/Age.Engine/Vm/ExecFrame.cs +++ b/engine/Age.Engine/Vm/ExecFrame.cs @@ -11,5 +11,8 @@ internal sealed class ExecFrame public readonly Frame Locals = new(); public readonly List CallStack = new(); // intra-script `call` (op 0x8f) returns public readonly Dictionary EmitSeen = new(); + public int? CoroutineYieldHandlerA; // op 0x7b: native per-frame handler PCs + public int? CoroutineYieldHandlerB; + public readonly Dictionary CoroutineYieldVisits = new(); // instruction index -> visits public ExecFrame(Script script, int pc) { Script = script; Pc = pc; } } diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 992ae98..644ba65 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -8,6 +8,7 @@ public sealed class VirtualMachine private const long NoJump = 0xFFFFFFFF; private const int HALT = int.MinValue; private const int FRAME_RETURN = int.MinValue + 1; + private const int SceneEntryCoroutineGate = 0xaba5c; private const int T_IMM = 0, T_STR = 2, T_GINT = 3, T_GFLOAT = 4, T_GSTR = 5, T_GPTR = 6, T_LINT = 9, T_LFLOAT = 10, T_LSTR = 11, T_LPTR = 12; @@ -40,6 +41,33 @@ public sealed class VirtualMachine private static long PyMod(long a, long b) { if (b == 0) return 0; long r = a % b; if (r != 0 && (r < 0) != (b < 0)) r += b; return r; } private static bool IsStr(Operand o) => o.Type == T_STR || o.Type == T_GSTR || o.Type == T_LSTR; + private static bool SameOperand(Operand a, Operand b) => a.Type == b.Type && a.Value == b.Value; + + private static bool IsAdvLabeledYield(Script script, Instruction ins) + => ins.Opcode == 0x140 && ins.Args.Count >= 4 + && ins.Args[1].Type == T_STR && ins.Args[2].Type == T_STR + && script.GetString((int)ins.Args[1].Value) == "LABEL" + && script.GetString((int)ins.Args[2].Value) == "J"; + + private bool TryGetAdvYieldTerminal(int pc, Operand output, out long terminal) + { + terminal = 0; + if (pc + 2 >= _cur.Script.Instructions.Count) return false; + var setTerminal = _cur.Script.Instructions[pc + 1]; + var compare = _cur.Script.Instructions[pc + 2]; + if (_t.Label(setTerminal.Opcode) != "mov" || setTerminal.Args.Count < 2 + || setTerminal.Args[1].Type != T_IMM + || _t.Label(compare.Opcode) != "eq" || compare.Args.Count < 3) + return false; + + var terminalOperand = setTerminal.Args[0]; + bool comparesTerminalToOutput = + (SameOperand(compare.Args[1], terminalOperand) && SameOperand(compare.Args[2], output)) + || (SameOperand(compare.Args[2], terminalOperand) && SameOperand(compare.Args[1], output)); + if (!comparesTerminalToOutput) return false; + terminal = Read(setTerminal.Args[1]); + return true; + } private long Read(Operand op) => op.Type switch { @@ -103,6 +131,11 @@ public sealed class VirtualMachine public void Run(int entryOffset = 0) { + // The native scheduler supplies this scene-entry state outside script-visible global writes. + // Restrict it to the byte-identical ADV LABEL/J idiom; op 0x140 also has an unrelated TITLE use. + if (entryOffset == 0 && _s.Instructions.Any(ins => IsAdvLabeledYield(_s, ins))) + Globals[SceneEntryCoroutineGate] = 1; + var top = new ExecFrame(_s, _s.IndexByOffset.TryGetValue(entryOffset, out var idx) ? idx : 0); var outcome = RunFrame(top, FrameCause.TopScene); if (outcome == FrameOutcome.RanOff) HaltReason ??= "pc-out-of-range"; @@ -177,6 +210,35 @@ public sealed class VirtualMachine long tgt = Read(a[0]) != 0 ? a[1].Value : a[2].Value; return tgt == NoJump ? pc + 1 : _cur.Script.IndexByOffset.GetValueOrDefault((int)tgt, pc + 1); } + case "u0041ADB0": + case "coroutine-save-yield-handlers": // 0x7b: retain native handler metadata + _cur.CoroutineYieldHandlerA = (int)Read(a[0]); + _cur.CoroutineYieldHandlerB = (int)Read(a[1]); + return pc + 1; + case "u00416A90": + case "coroutine-resume": // 0x7c: host FrameYield/FrameClock owns re-entry + return pc + 1; + case "u0041F9C0": + case "coroutine-label-yield": // 0x140: bounded host model for LABEL/J only + { + if (!IsAdvLabeledYield(_cur.Script, ins)) + { + if (_sink.TracingSteps) _sink.Emit(TraceEvent.Stub(op, pc)); + return pc + 1; + } + if (!TryGetAdvYieldTerminal(pc, a[0], out long terminal)) + { + HaltReason ??= $"coroutine-yield-pattern@0x{ins.Offset:x}"; + return HALT; + } + + int visits = _cur.CoroutineYieldVisits.GetValueOrDefault(pc); + _cur.CoroutineYieldVisits[pc] = visits + 1; + // First visit must enter setup even if out retained this same terminal from a prior scene. + // Every later visit returns the script-encoded terminal and exits the bounded loop. + Write(a[0], visits == 0 ? (terminal == 0 ? 1 : 0) : terminal); + return pc + 1; + } case "exit": case "exit-script": return FRAME_RETURN; case "call-script": @@ -246,7 +308,7 @@ public sealed class VirtualMachine { long h = Read(a[1]); System.Console.Error.WriteLine($"[query] handle=0x{h:x} handleOp=(type={a[1].Type} val=0x{a[1].Value:x}) " + - $"-> QuerySlot={Gfx.QuerySlot(h)} registered={Gfx.IsRegistered(h)}"); + $"-> QuerySlot={Gfx.QuerySlot(h)} objectPresent={Gfx.TryGet(h) != null}"); } Write(a[0], Gfx.QuerySlot(Read(a[1]))); return pc + 1; case "query-gfx-field?": // 0x216 (out)(idx) @@ -293,13 +355,13 @@ 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) — insert into the op-0x215 query registry (native - // FUN_0042d360 -> FUN_0042cf70 hash insert; the ONLY populator of that map) + 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 registry range (teardown, NOT create) + 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 (handle) - Gfx.Release(Read(a[0])); return pc + 1; + case "gfx-elem-release": // 0x1fa (surface slot) + Gfx.ClearSurface((int)Read(a[0])); return pc + 1; case "gfx-blit-color": // 0x202 (handle)(x)(y)(alpha)(color) — static alpha/tint (anim interp deferred) Gfx.SetObjectColor(Read(a[0]), GfxState.PackColor(Read(a[3]), Read(a[4]))); return pc + 1; case "gfx-draw-color": // 0x203 (handle)(v)(alpha)(color) — static alpha/tint diff --git a/godot/Main.cs b/godot/Main.cs index e78bce9..f6c6055 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -238,9 +238,10 @@ public partial class Main : Godot.Control // is applied by the alpha-aware BlitLayer. See docs/engine-re.md "sprite transform / ANIMATION cluster". private readonly System.Collections.Generic.Dictionary<(string Path, long Key), Image?> _imgCache = new(); - // Per-handle wall-clock tween of the animation channel. The engine's clock (op 0x238) is a GLOBAL, - // non-blocking clock; the host advances it here while the VM is parked at wait-for-input. Opacity comes - // from the 3rd anim vec component (TZ), data-driven from the opening (100=full, 0=clear). + // Legacy host approximation: per-handle wall-clock tween using Anim.TZ as opacity. Native RE now proves + // op 0x21e is a scale matrix and 0x220 is a separate translation matrix, so TZ is NOT opacity. Keep this + // behavior isolated here until the transform compositor is split; it is unrelated to retained-object + // teardown (0x215/0x1f7/0x1fa), which now removes the magic-circle object correctly. private sealed class TweenState { public long Generation = long.MinValue; @@ -325,9 +326,8 @@ public partial class Main : Godot.Control foreach (var kv in curr) _lastGfxDecision[kv.Key] = kv.Value; } - // Current opacity for a visible object: 1.0 unless it has an active anim channel, in which case tween the - // 3rd vec component (TZ, ~percent) over the global clock. Start opaque on first sight so a CG never - // begins invisible (the safe direction); re-arm whenever the object's or the clock's generation bumps. + // Current legacy opacity approximation. TODO(transform compositor): replace this with independent native + // scale/translation matrices and source opacity only from the actual color/blend channel. private float AlphaFor(Age.Engine.Model.RenderObject v, bool clockReset, double clockDur) { if (!v.Anim.Enabled) return 1f; diff --git a/tools/age_opcodes_himegari.py b/tools/age_opcodes_himegari.py index 30f265e..5c60c5e 100644 --- a/tools/age_opcodes_himegari.py +++ b/tools/age_opcodes_himegari.py @@ -6,9 +6,12 @@ from __future__ import annotations INFERRED: dict[int, dict] = { 0x71: dict(name='label-def', category='structural', noop=True, confidence='high', source='investigation', summary='1 imm; count == T1 table size -> the label/anchor T1 indexes. v1 no-op; revisit if menu/callback dispatch looks up by id'), 0x7a: dict(name='text-param?', category='adv', noop=False, confidence='med', source='inference', summary='3 args (imm/computed/imm); sub computes a value then 0x7a then show-text — text speed/wait/window param'), + 0x7b: dict(name='coroutine-save-yield-handlers', category='control', noop=False, confidence='high', source='investigation', summary='(handler1_pc)(handler2_pc) — scene-coroutine: save the two per-frame yield/resume handler PCs. Native writes op1→ctx[0x6da88+idx*4], op2→ctx[0x6db28+idx*4] (idx=ctx[0x53d14] script-context index) + gfx cmd-type 5. SC0000 0x79: `0x7b label_3c9 label_41e` registers the ADV per-frame render→poll→yield handlers. Part of the scene-coroutine framework (see engine-re.md §Scene-coroutine framework); pairs with 0x7c (resume) + 0x140 (loop iterator).'), + 0x7c: dict(name='coroutine-resume', category='control', noop=False, confidence='high', source='investigation', summary='() — scene-coroutine RESUME point. Native requires run-state bit 0x2000000 (ctx[0x6dbc8]) set — THROWS (__CxxThrowException) if unset, so it is only ever reached on a scheduler-driven re-entry, NEVER on a cold first pass (cold flow jmps over it). Restores PC=ctx[0x53d28]+ctx[0x6dbcc]*4, clears the run-bit (ctx+0xa0ce4 &= ~0x2000000), resets input/line state. SC0000 0x443 (falls into the main loop label_444). See engine-re.md §Scene-coroutine framework.'), 0x90: dict(name='hotspot-branch', category='input', noop=True, confidence='high', source='investigation', summary='cursor/input hotspot hit-test: rect (x,y,w,h) -> 3-way branch on interaction, else fall through to pc+1'), 0x97: dict(name='hotspot-reg?', category='input', noop=True, confidence='med', source='inference', summary='companion register-hotspot / set-widget-action (argc5: v1 v2 1 1 ; NO code targets)'), 0xb6: dict(name='snd-ctrl?', category='audio', noop=False, confidence='low', source='inference', summary='1 imm; self-chains, 0x41D family near play-sound-effect/0xb5 — sound channel/volume/stop control'), + 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."), 0x1bc: dict(name='block-mark', category='marker', noop=True, confidence='high', source='inference', summary='zero-arg; follows jcc/mov, precedes mov/ret — block boundary'), 0x1bf: dict(name='call-end', category='marker', noop=True, confidence='med', source='inference', summary='zero-arg; call->0x1bf->stmt-end — end-of-call-statement marker'), 0x1d2: dict(name='stmt-desc?', category='marker', noop=True, confidence='med', source='harness', summary='2 imm; immediately after stmt-begin 0x1f4 — statement descriptor?'), diff --git a/vm-map/engine-ctx.toml b/vm-map/engine-ctx.toml index 648fa75..4b868a8 100644 --- a/vm-map/engine-ctx.toml +++ b/vm-map/engine-ctx.toml @@ -11,7 +11,7 @@ size = 0xa1000 offset = 0x408 name = "gfx_obj_registry" type = "int" -note = "gfx object registry (std::map handle->object); op 0x1a2 insert / 0x215 find" +note = "retained gfx-object map (std::map handle->object); geometry/draw get-or-create, 0x215 returns obj+4 source slot, 0x1f7 erases" [[field]] offset = 0x40c name = "sys4ini_count" diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 0f5b9f0..1f51d1d 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -1246,23 +1246,23 @@ argc = 2 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u0041ADB0" -category = "unknown" -summary = "" +name = "coroutine-save-yield-handlers" +category = "control" +summary = "(handler1_pc)(handler2_pc) — scene-coroutine: save the two per-frame yield/resume handler PCs. Native writes op1→ctx[0x6da88+idx*4], op2→ctx[0x6db28+idx*4] (idx=ctx[0x53d14] script-context index) + gfx cmd-type 5. SC0000 0x79: `0x7b label_3c9 label_41e` registers the ADV per-frame render→poll→yield handlers. Part of the scene-coroutine framework (see engine-re.md §Scene-coroutine framework); pairs with 0x7c (resume) + 0x140 (loop iterator)." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra: handler FUN_0041ebf0 (dispatch ctx[0x26c93+0x7b]) = {*(ctx+0x53d88+ctx[0x53d14]*0x78)=5; ctx[0x6da88+idx*4]=op1; ctx[0x6db28+idx*4]=op2}. Both operands are code PCs (handler labels)." [[opcode.semantics.args]] i = 1 -role = "" +role = "yield handler-1 PC" observed_types = ["imm"] [[opcode.semantics.args]] i = 2 -role = "" +role = "yield handler-2 PC" observed_types = ["imm"] [[opcode]] @@ -1272,14 +1272,14 @@ argc = 0 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00416A90" -category = "unknown" -summary = "" +name = "coroutine-resume" +category = "control" +summary = "() — scene-coroutine RESUME point. Native requires run-state bit 0x2000000 (ctx[0x6dbc8]) set — THROWS (__CxxThrowException) if unset, so it is only ever reached on a scheduler-driven re-entry, NEVER on a cold first pass (cold flow jmps over it). Restores PC=ctx[0x53d28]+ctx[0x6dbcc]*4, clears the run-bit (ctx+0xa0ce4 &= ~0x2000000), resets input/line state. SC0000 0x443 (falls into the main loop label_444). See engine-re.md §Scene-coroutine framework." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra: handler FUN_00417cb0 (dispatch ctx[0x26c93+0x7c]). Guards on (ctx[0x6dbc8] & 0x2000000)==0 → throw; else restores PC = ctx[0x53d2c-slot] = ctx[0x53d28]+ctx[0x6dbcc]*4, ctx[0xa0ce4]=ctx[0x6dbc8]&0xfdffffff, clears input state (ctx[0x13bdc]/0xc6f8=-1 etc.)." [[opcode]] op = 0x7f @@ -2879,33 +2879,33 @@ argc = 4 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u0041F9C0" -category = "unknown" -summary = "" +name = "coroutine-label-yield" +category = "control" +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." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "med" depends_on = [] -evidence = "" +evidence = "Ghidra: handler 0x4299c0 (dispatch ctx[0x9b74c]=0x4299c0; created+typed EngineCtx*+annotated; Kelebek u0041F9C0 = VA-drift). Writes gfx cmd-type 9; op2→local_204, op3→local_104, op4→local_208; (*DAT_005c6018)(8, ctx[0x54fe8], &local_210) → FUN_00425fb0(1,ret). DAT_005c6018: 6 xrefs all READ, no static writer; FUN_00405740 (screen-fade) calls it w/ cmd 3, branches on ret 1/2 = transition progress = native video service." [[opcode.semantics.args]] i = 1 -role = "" +role = "out: returned label/resume PC (SC0000 G[0x6be])" observed_types = ["g-int"] [[opcode.semantics.args]] i = 2 -role = "" +role = "label name string (e.g. \"LABEL\")" observed_types = ["string"] [[opcode.semantics.args]] i = 3 -role = "" +role = "sub-label string (e.g. \"J\")" observed_types = ["string"] [[opcode.semantics.args]] i = 4 -role = "" +role = "in: current label/resume PC fed back (SC0000 G[0x6be])" observed_types = ["g-int"] [[opcode]] @@ -3458,12 +3458,12 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "gfx-cmd-register" category = "draw" -summary = "0x1a2 (handle) — gfx cmd-type 3. Handler gfx_op_0x1a2_registry_insert @0x42d360: builds key '%c%8.8x'(3, operand-desc) and INSERTS operand 1 into the op-0x215 query registry (FUN_0042cf70, open-addressing hash; native stores map[handle]=handle). This is the SOLE populator of the registry op 0x215 queries — the geometry SET/draw ops (0x217/0x219/0x1ff/0x1fb/0x202/...) do NOT register. VM impl: GfxState.Register(handle) (a separate set from the geometry object store). Conflating the two (registering on every GetOrCreate) was the retained-mode geometry bug: CG handles wrongly read back as 'existing' and collapsed to (-400,-600). NOT save/scene (raw Kelebek VA 0x428010 drifted to op 0x1ac save handler). See docs/engine-re.md gfx op-contract table." +summary = "0x1a2 (value) — gfx cmd-type 3. Handler gfx_op_0x1a2_descriptor_register@0x42d360 builds a key from operand 1's lvalue descriptor and inserts its value into an open-addressing descriptor hash (vm_lvalue_descriptor_hash_insert@0x42cf70). This structure is separate from op 0x215's retained gfx-object map; op 0x215 does not query this hash. NOT save/scene." noop_headless = false source = "investigation" confidence = "high" depends_on = [] -evidence = "Ghidra: real handler FUN_0042d360 (via dispatch table ctx[0x26c93+op]); sets *(ctx+0x53d88+ctx[0x53d14]*0x78)=3, sprintf(\"%c%8.8x\",3,op1), FUN_0042cf70 (hash insert; counterpart of op 0x215 find). NOT save/scene (raw Kelebek VA 0x428010 drifted to op 0x1ac save handler). See docs/engine-re.md" +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." [[opcode.semantics.args]] i = 1 @@ -4508,12 +4508,12 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "gfx-elem-erase" category = "draw" -summary = "0x1f7 (handle)(count) — gfx cmd-type 5. Handler gfx_op_0x1f7_elem_erase @0x422270: ERASES registry handles — if count>1 → gfx_registry_erase_range(handle,count) [erase [handle, handle+count)], else gfx_registry_erase(handle). It is a TEARDOWN/erase, NOT a create (corrects the earlier 'gfx-elem-create' reading). In label_12649 it runs after a 0x215 slot-query, before 0x1fa releases the slot. Objects are created lazily by the geometry SET ops (gfx_object_get_or_create). See docs/engine-re.md gfx op-contract table." +summary = "0x1f7 (handle)(count) — erase retained gfx objects. Handler 0x422270 calls gfx_object_erase_range@0x47d8b0 for [handle,handle+count) when count>1, else gfx_object_erase@0x47d850. This removes entries from the same object map queried by op 0x215, so erased objects stop compositing. SC0000 uses it before op 0x1fa releases the returned surface slot." noop_headless = false source = "investigation" confidence = "high" depends_on = [] -evidence = "Ghidra handler 0x422270 (dispatch ctx[0x26c93+0x1f7]); count>1 → gfx_registry_erase_range @0x47d8b0 (loops gfx_registry_erase @0x47d850 over [op1,op1+op2)), else gfx_registry_erase(op1). gfx_registry_erase does map.find+erase on the ctx+0x408 registry." +evidence = "Ghidra handler 0x422270; gfx_object_erase_range@0x47d8b0 loops gfx_object_erase@0x47d850. Both operate on owner+0x408, the retained-object map also used by gfx_object_get_or_create/draw and gfx_object_query_source_slot." [[opcode.semantics.args]] i = 1 @@ -4601,7 +4601,7 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "gfx-elem-release" category = "draw" -summary = "0x1fa (idx) — gfx cmd-type 3. Handler gfx_op_0x1fa_elem_release @0x4224a0: releases the element at [ctx+0x52bd4 + idx*4] (virtual free, then nulls the slot) + FUN_00474e40(idx). In label_12649 it clears the working slot G[0x62452] after a 0x215/0x1f7 pair. See docs/engine-re.md gfx op-contract table." +summary = "0x1fa (surface_slot) — release the surface at ctx+0x52bd4[slot] (virtual free, then null) and call FUN_00474e40(slot). It releases a surface slot, not a retained object handle. SC0000 feeds it the slot returned by op 0x215 after op 0x1f7 erases the associated object group." noop_headless = false source = "investigation" confidence = "high" @@ -4610,7 +4610,7 @@ evidence = "Ghidra handler 0x4224a0 (dispatch ctx[0x26c93+0x1fa]); frees ctx+0x5 [[opcode.semantics.args]] i = 1 -role = "" +role = "surface slot" observed_types = ["imm", "g-int", "l-int", "l-ptr"] [[opcode]] @@ -5251,12 +5251,12 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "query-gfx-object?" category = "draw" -summary = "0x215 (out)(handle_id) — native graphics command-buffer op. Real handler FUN_0042a0b0 (Ghidra-resolved via the dispatch table ctx[0x26c93+op]; Kelebek's 0x421160 is VA-drift, lands in an unrelated fn). Does TWO things: (1) writes cmd-type 5 into the CURRENT gfx-object record `[ctx+0x53d88 + ctx[0x53d14]*0x78]` (a command-buffer registration, parallel to op 0x1a2→type 3); (2) returns `out = map.find(handle_id)` over an engine-internal associative registry (found value, else 0xffffffff=not-found), sign-tested (gre/lt 0) to drive label_12649's slot-select branch + set working slot G[0x62452]. So `out` is NATIVE COMMAND-BUFFER STATE (the registry is populated by sibling gfx ops — op 0x1a2→FUN_0042cf70 is the hash insert), NOT the VM global bank → seeding story-state CANNOT reproduce it. Stubbed → constant return → every draw collapses to slot 0 → anchor-preserve reads foreign-sized textures → the cumulative bg/sprite drift. SETTLES the drift as (b) a genuine native op, NOT (a) state-divergence. Faithful fix = model the gfx command-buffer (record array + handle→object registry) and run the gfx ops instead of stubbing — static/Frida-free (handlers now readable; inserts are bytecode-driven). Full decode + verdict: docs/engine-re.md (op 0x215 section). RETAINED-MODE FIX (2026-07-07): the registry MUST be separate from the geometry object store — it is populated ONLY by op 0x1a2, never by the geometry SET/draw ops. VM: QuerySlot returns the registered value (=handle) or -1, NOT a fabricated per-object slot. CG handles are never 0x1a2-registered → query returns -1 → label_12649 takes its FRESH branch (anchor from the INIT2 arrays) → dst=(0,0). The prior GfxState.GetOrCreate-assigns-AcquireSlot model made CG handles read as 'existing' → existing branch called get-texture-size on the wrong slot (0) → dst=(-400,-600) off-screen (the '2nd CG off-screen' bug)." +summary = "0x215 (out_slot)(handle) — query the retained gfx-object map. Handler gfx_op_0x215_query_source_slot@0x42a0b0 calls gfx_object_query_source_slot@0x47f280 with owner ctx+0x46614. The worker searches owner+0x408, the same map populated by geometry/draw workers, and returns obj+4: the live source-surface slot written by draw-texture, or -1 if absent. A geometry-only object remains unbound and returns -1. SC0000 uses a successful result for existing-object geometry and query-guarded teardown: op 0x1f7 erases the object group and op 0x1fa releases this slot. VM fix 2026-07-09 restores AE001H magic-circle cleanup." noop_headless = false source = "investigation" confidence = "high" depends_on = [] -evidence = "Ghidra: real handler FUN_0042a0b0 = {*(ctx+0x53d88+ctx[0x53d14]*0x78)=5; out=FUN_0047f280(FUN_0041b940(2))}. FUN_0047f280 = std::map::find (returns mapped value or 0xffffffff); FUN_0041b940(2) = operand-fetch of operand 2 (the handle key); FUN_00425fb0(1,val) = operand-write to `out`. Registry populated by op 0x1a2 handler FUN_0042d360 → FUN_0042cf70 (open-addressing hash insert). Bytecode sites: SC0000 label_12649 (0x12670) + label_123ef (0x12419/0x12450), handle-ids from 0x62455[idx] (±offset); result gre/lt 0 branches slot-select. Record table 0x3239 (label_125bd @0x0050f) assigns per-object slots 4..13." +evidence = "Ghidra asm: handler 0x42a0b0 passes ECX=ctx+0x46614 to 0x47f280. That worker searches ECX+0x408 and returns resolved object+4. gfx_object_bind_draw@0x47e870, called with the same owner, get-or-creates in owner+0x408 and writes source slot to object+4. SC0000 post-effect cleanup 0x3321 queries G[0x62457], sign-tests, then executes 0x1f7(handle,10) + 0x1fa(returned_slot); AE001H res 0x37 was bound to that handle." [[opcode.semantics.args]] i = 1 @@ -5510,12 +5510,12 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "set-anim-transform-norm" category = "draw" -summary = "(handle)(p1)(p2)(x)(y)(z) — set sprite transform channel, NORMALIZED (float operands /_DAT_00571c28 ~percent); cmd-type 0xd, worker gfx_anim_set_channel@0x47eaa0. SC0000 opening @0xf73+ on INIT2 CG handles. Cluster 0x21c-0x243. Handler 0x423350; Kelebek VA 0x421450 is drift." +summary = "(handle)(delay)(duration)(sx)(sy)(sz) — set the normalized SCALE-matrix channel. Handler 0x423350 normalizes sx/sy/sz; gfx_object_set_scale_channel@0x47eaa0 stores timing at obj+0x3c/+0x50 and constructs a diagonal 4x4 scale matrix at obj+0xac. Separate from op 0x220 translation; neither channel is opacity." noop_headless = false source = "investigation" confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra 0x47eaa0 calls matrix builder 0x48af1d, which writes sx/sy/sz to diagonal entries 0/5/10 and identity entry 15. gfx_object_apply_transform_channels@0x472f00 consumes this independently of obj+0x1ac." [[opcode.semantics.args]] i = 1 @@ -5607,12 +5607,12 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "set-anim-transform-abs" category = "draw" -summary = "(handle)(p1)(p2)(x)(y)(z) — set sprite transform channel, ABSOLUTE (raw floats); cmd-type 0xd, worker 0x47ecc0. Twin of 0x21e. SC0000 opening @0x18a5+ on INIT2 CG handles. Cluster 0x21c-0x243. Handler 0x4234e0; Kelebek VA 0x4215D0 is drift." +summary = "(handle)(delay)(duration)(tx)(ty)(tz) — set the absolute TRANSLATION-matrix channel. Handler 0x4234e0 passes raw floats to gfx_object_set_translation_channel@0x47ecc0, which stores timing at obj+0x44/+0x58 and constructs an identity 4x4 matrix with translation at obj+0x1ac. Separate from op 0x21e scale; neither channel is opacity." noop_headless = false source = "investigation" confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra 0x47ecc0 calls matrix builder 0x48afb1, which writes identity diagonal and tx/ty/tz to entries 12/13/14. gfx_object_apply_transform_channels@0x472f00 combines this independently of obj+0xac." [[opcode.semantics.args]] i = 1