From ae157da04c75c026d4b6cc16ac850898c298b8ac Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 20 Jul 2026 14:29:41 -0400 Subject: [PATCH] Implement retained graphics lifecycle ops --- docs/engine-ctx-reference.md | 18 ++-- docs/engine-re.md | 72 ++++++++++++++-- docs/opcode-reference.md | 64 +++++++++------ docs/phase-a-slice-plan.md | 39 ++++++++- .../Age.Engine.Tests/GfxLifecycleOpsTests.cs | 82 +++++++++++++++++++ engine/Age.Engine.Tests/TestSupport.cs | 4 + engine/Age.Engine/Hosting/IHost.cs | 6 ++ engine/Age.Engine/Model/GfxState.cs | 42 +++++++++- engine/Age.Engine/Vm/VirtualMachine.cs | 9 ++ godot/GodotAdvHost.cs | 43 ++++++++++ vm-map/engine-ctx.toml | 66 +++++++++++---- vm-map/opcodes.toml | 80 +++++++++--------- 12 files changed, 423 insertions(+), 102 deletions(-) create mode 100644 engine/Age.Engine.Tests/GfxLifecycleOpsTests.cs diff --git a/docs/engine-ctx-reference.md b/docs/engine-ctx-reference.md index 54e4540..9503fad 100644 --- a/docs/engine-ctx-reference.md +++ b/docs/engine-ctx-reference.md @@ -6,7 +6,6 @@ Struct `EngineCtx`, size `0xa1000`. Applied to the Ghidra `/v2` image (dispatch- | offset | name | type | note | |---|---|---|---| -| `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 | @@ -15,15 +14,22 @@ Struct `EngineCtx`, size `0xa1000`. Applied to the Ghidra `/v2` image (dispatch- | `0x3028` | `alt_pack_table` | `int` | call-script high-byte alternate pack table (unused by corpus) | | `0x4d7c` | `shared_profile_state` | `void*` | embedded shared SAVE.DAT state object; owns profile integer/settings tables and container timing metadata | | `0x5190` | `shared_profile_int_table` | `int` | open-addressing 12-byte string-key to 32-bit value table; op 0x1a2 stores, 0x1a3 loads, shared SAVE.DAT serializes it | -| `0xb558` | `gfx_dirty_a` | `int` | gfx dirty flag (anim set raises) | -| `0xb560` | `gfx_dirty_b` | `int` | gfx dirty flag | | `0x14d54` | `gfx_obj_ptr_table` | `void*` | per-object pointer table (ops 0x212/0x213 write obj+0x64/0x68/0x6c) | | `0x14ea0` | `text_line_spacing` | `int` | extra pixel leading between text lines; defaults to 6, op 0x8b writes it | | `0x14f45` | `script_frame_index` | `int` | call-script frame index (0x1e-dword frames) | +| `0x46188` | `renderer_device_state` | `int` | embedded D3D9 device/render state; op 0x20e passes this base to the target/depth clear worker | +| `0x46598` | `d3d9_device` | `void*` | IDirect3DDevice9 pointer at renderer_device_state+0x410; op 0x20e invokes vtable Clear +0xac | +| `0x46614` | `retained_gfx_manager` | `int` | embedded retained graphics manager passed by gfx opcode handlers; worker offsets are relative to this base | +| `0x46a1c` | `gfx_obj_registry` | `int` | retained_gfx_manager+0x408 std::map handle->object; 0x1f6 clears, geometry/draw get-or-create, 0x215 queries, 0x1f7 erases | | `0x46d14` | `query_table_46d14` | `void*` | stride-0x14 table read by op 0x216 | -| `0x51b64` | `frame_timer` | `int` | frame timer (present updates 0x51b64/0x51b68) | -| `0x51b78` | `anim_clock_elapsed` | `int` | global anim clock elapsed (op 0x238 zeroes) | -| `0x51b7c` | `anim_clock_duration` | `int` | global anim clock total duration (op 0x238 sets) | +| `0x50ba4` | `gfx_render_target_table` | `void*` | retained_gfx_manager+0xa590 array of per-slot D3D texture wrappers selected by op 0x20d | +| `0x51b44` | `gfx_current_render_target` | `int` | retained_gfx_manager+0xb530 selected surface slot; -1 denotes device backbuffer | +| `0x51b64` | `frame_timer` | `int` | frame timer at retained_gfx_manager+0xb550; present updates 0x51b64/0x51b68 and object animation samples it | +| `0x51b6c` | `gfx_dirty_a` | `int` | retained_gfx_manager+0xb558 dirty flag; op 0x1f6 clears it | +| `0x51b70` | `gfx_force_complete` | `int` | retained_gfx_manager+0xb55c requests forced completion of one-shot channels; op 0x1f6 clears it | +| `0x51b74` | `gfx_dirty_b` | `int` | retained_gfx_manager+0xb560 dirty flag | +| `0x51b78` | `anim_clock_elapsed` | `int` | global anim clock elapsed at retained_gfx_manager+0xb564; op 0x238 and object reset 0x1f6 zero it | +| `0x51b7c` | `anim_clock_duration` | `int` | global anim clock duration at retained_gfx_manager+0xb568; op 0x238 sets and object reset 0x1f6 zeroes it | | `0x52bd4` | `surfaces` | `void*` | surface array base [~1000 slots]; create/set-texture (0x1f8/0x1f9) allocate | | `0x53d14` | `cur_ctx_index` | `uint` | current script-context index (curCtx); indexes 0x78-byte coroutine/frame records | | `0x53d28` | `frame_codebase` | `void*` | current frame codebase (PC = codebase + off*4) | diff --git a/docs/engine-re.md b/docs/engine-re.md index b507e01..9b1a8ca 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -498,7 +498,8 @@ independent matrix channels, not two encodings of one vec3 property. at `obj+0x208`, and the target axis-angle matrix at `obj+0x12c`. Current axis/angle are `obj+0x1ec..0x1f4/+0x204`, with current matrix `obj+0xec`. - `gfx_object_apply_transform_channels` (`0x472f00`) supplies the timing contract. All three channels use - shared start timestamp `obj+0x34` and global frame-time `ctx+0xb550`, but have independent delay/duration: + shared start timestamp `obj+0x34` and retained-gfx frame-time `owner+0xb550` + (`EngineCtx+0x51b64`), but have independent delay/duration: scale `obj+0x3c/+0x50`, rotation `obj+0x40/+0x54`, translation `obj+0x44/+0x58`. Each holds current through the delay, linearly interpolates current→target for its duration, then commits the target and clears its own timing. Neither third component is opacity. @@ -556,7 +557,8 @@ both annotated) and grepping the SC0000 opening settles the animation model and real and verifiable on screen. **Corrected host model:** `0x21e` scale and `0x220` translation run directly from frame-time -`ctx+0xb550` using their own delay/duration; they do not use op `0x238` as their duration. +`retained-gfx owner+0xb550` (`EngineCtx+0x51b64`) using their own delay/duration; they do not use op +`0x238` as their duration. Op `0x234` is the independent rotation cycle above. Op `0x238` still configures the separate `ctx+0x51b78/+0x51b7c` animation service used by its own family. @@ -742,7 +744,8 @@ per-frame bytecode. Reversed + annotated in Ghidra: - **Retained objects carry cyclic rotation state:** flag value `4` at `obj+0`, start timestamp `obj+0x214`, period `obj+0x228`, and axis `obj+0x244/248/24c`. - **`gfx_object_set_rotation_cycle`** (`0x47f060`, worker for legacy op **`0x234`**) - configures that channel. `gfx_object_anim_interpolate` consumes it from frame-time `ctx+0xb550` + configures that channel. `gfx_object_anim_interpolate` consumes it from retained-gfx frame-time + `owner+0xb550` (`EngineCtx+0x51b64`) as a repeating 0..360-degree axis rotation. Op **`0x1fd`** (`gfx_op_0x1fd_set_vec_scaled@0x422650` → `gfx_object_set_scale_current@0x47e6b0`) is an immediate current-scale setter: it divides integer X/Y/Z percentages by 100, marks scale state at `obj+0x68`, and @@ -772,7 +775,8 @@ and snapshotted textures at draw time; symptoms: alternating grey, glow over bac set-texture) resolves `resId` via the SYS4INI resolver (`FUN_0044f390`) and loads the file into the slot's surface **with a colorkey/chromakey** (op arg 3 — never modelled before), also releasing the old surface. A surface persists at its slot until the next set-texture overwrites it. -- **Objects** — the `ctx+0x408` registry, keyed by handle (a `std::map`). `gfx_op_0x1fb_draw_bind` (`0x422510`, +- **Objects** — the retained-gfx `owner+0x408` registry (`EngineCtx+0x46a1c`), keyed by handle (a + `std::map`). `gfx_op_0x1fb_draw_bind` (`0x422510`, op `0x1fb` draw-texture) → `gfx_object_bind_draw` (`0x47e870`): sets the object's **source slot** (`obj+4`), **source rect** (`obj+8..0x14` = left,top,right,bottom), **position** (`obj+0x24/28/2c` = V24), and the **visible** flag (bit 0). The object references its surface **by slot index, live** (re-resolved each frame), @@ -807,7 +811,8 @@ Ghidra functions renamed + plate-commented, saved). is loaded/cached. - **`0x202` (`gfx_op_0x202_worker_set_color_anim` `0x47ea00`)**: sets an **animated** color/alpha target `obj+0x64 = packedARGB`, the color-anim active bit, and resets shared start `obj+0x34=0`. Operands 2/3 - are delay/duration at `obj+0x38/+0x4c`; sampling uses frame clock `ctx+0xb550`, not op `0x238`. + are delay/duration at `obj+0x38/+0x4c`; sampling uses retained-gfx frame clock `owner+0xb550` + (`EngineCtx+0x51b64`), not op `0x238`. - **`0x203` (`gfx_op_0x203_worker_set_color` `0x47e9b0`)**: sets a **static** color/alpha `obj+0x60`, no anim bit. Immediate per-object modulation. - **Blit** (`gfx_object_blit_d3d9` `0x4774c0`): selects a **blend mode** (`local_2c`: 0 opaque, 1 alpha @@ -841,10 +846,12 @@ visible glow on the ordinary interactive path. This glow is a retained scaled/ro fade animates so smooth ramping could join slice A. Findings (Ghidra `gfx_object_anim_interpolate` `0x473ed0`, annotated+saved): the bit-2 anim family (op `0x234`) interpolates **5 independent sub-channels** (color `obj+0x240`/period `obj+0x220`, two matrices, rotation `0x168`=360°, src-rect scroll), each on the -**global frame clock `ctx+0xb550`** (advanced per present, NOT the op-`0x238` clock `ctx+0x51b7c`), and each +**global frame clock `retained-gfx owner+0xb550` (`EngineCtx+0x51b64`)** (advanced per present, NOT the +op-`0x238` clock `ctx+0x51b7c`), and each **ping-pongs** (triangle wave, folded at period/2) — i.e. these are *oscillating/pulsing* effects, not one-shot fades. The `0x202`/`0x203` color (`obj+0x60` static / `obj+0x64` animated, sets -`ctx+0xb558`/`0xb560`) is a **separate channel whose blit consumer was not located** in this pass. So a +`owner+0xb558`/`0xb560` = `EngineCtx+0x51b6c`/`0x51b74`) is a **separate channel whose blit consumer was +not located** in this pass. So a one-shot fade's exact source→target→easing is still unresolved and would take a dedicated dig (find the `obj+0x64` consumer + the color→`obj+0x240` path + the clock advance). ⇒ **smooth color-anim interpolation stays deferred**; slice A ships the static end-state (which reaches the correct final alpha/tint and fixes @@ -852,9 +859,11 @@ the stuck-opaque bug), with interpolation as a scoped follow-up. **Resolution (2026-07-10 — supersedes the deferral above).** The missing consumer was the bit-1 branch in `gfx_object_apply_transform_channels` (`0x472f00`), before its matrix channels. It seeds shared start -`obj+0x34` from `ctx+0xb550`; holds current packed ARGB `obj+0x60` through delay `+0x38`; then performs an +`obj+0x34` from retained-gfx `owner+0xb550` (`EngineCtx+0x51b64`); holds current packed ARGB `obj+0x60` +through delay `+0x38`; then performs an integer, bytewise linear interpolation to target `+0x64` for duration `+0x4c`. At natural completion—or -when `ctx+0xb55c == 1` requests forced completion—the target commits to current, delay/duration clear, +when retained-gfx `owner+0xb55c == 1` (`EngineCtx+0x51b70`) requests forced completion—the target commits +to current, delay/duration clear, target becomes `0xffffffff`, and the one-shot active bit clears when no color/matrix/src-rect sibling remains. The object-local override bit at `+0x2d0` suppresses the global force. Negative alpha/RGB target operands independently preserve their bytes from current `+0x60`. @@ -1845,6 +1854,51 @@ the saved `/v2` image. --- +### Retained graphics lifecycle and render targets -- opcodes `0x1f6` / `0x23d` / `0x20d` / `0x20e` (2026-07-20) + +The initially suspected `0x1f6` / `0x20e` pair is actually two independent lifecycle pairs. Opcode +`0x1f6` clears retained object records, while `0x23d` releases transient surface resources. Separately, +`0x20d` selects a Direct3D render target and `0x20e` clears the selected target to black. Their frequent +adjacency comes from scene setup and teardown, not from one shared service. + +`op_0x1f6_clear_retained_gfx_objects@0x417430` passes the embedded retained-gfx manager at +`EngineCtx+0x46614` to `retained_gfx_objects_clear@0x47cab0`. The worker destroys the complete +handle-to-object map at manager `+0x408` (`EngineCtx+0x46a1c`), resets its sentinel/count and transient +dirty/force-completion/animation-clock state, but leaves the surface table and queued surface commands +intact. The port consequently clears `GfxState` objects without releasing their source slots, allowing a +later bind to recreate an object from an existing surface. + +`op_0x23d_release_transient_surfaces@0x4175c0` loops over slots 42 through 999. For each slot it stops and +releases any movie-to-texture object at `EngineCtx+0x52bd4[slot]`, then calls +`retained_gfx_release_surface@0x474e40`. Slots 0 through 41 are deliberately preserved. The native release +worker can also retain a slot protected by its per-slot ownership guard; the port has no corresponding +external owner and releases the complete transient range. In the corpus, 146 of 149 `0x23d` calls directly +follow `0x1f6`, forming the full object-plus-resource reset. + +`op_0x20d_select_render_target@0x422e10` forwards its operand to +`retained_gfx_select_render_target@0x479660`. Values below 1000 resolve a retained texture wrapper, acquire +texture level zero, and call `IDirect3DDevice9::SetRenderTarget(0, surface)`. Values at or above 1000 acquire +backbuffer zero instead and record current target `-1` at manager `+0xb530` (`EngineCtx+0x51b44`). Opcode +`0x20e` then calls `d3d_clear_render_target_black@0x471460`, which invokes `IDirect3DDevice9::Clear` with no +rectangles, `D3DCLEAR_TARGET | D3DCLEAR_ZBUFFER`, color zero, depth 1.0, and stencil zero. The port tracks +the selected target and forwards the clear to the host; the retained compositor reconstructs its main +backbuffer from black at publication boundaries, while offscreen clears also discard modeled text pixels. + +This trace also corrects an important base-pointer assumption in earlier graphics notes. Offsets `+0x408` +and `+0xb550` are relative to the retained-gfx manager at `EngineCtx+0x46614`, not to `EngineCtx` itself. +Their absolute locations are therefore `EngineCtx+0x46a1c` (object registry) and `EngineCtx+0x51b64` +(frame timer). The same correction moves the dirty/force-completion fields to `EngineCtx+0x51b6c` through +`+0x51b74`. This changes native field provenance and the Ghidra structure, but not the already implemented +host-side object/animation behavior, which was based on worker semantics and runtime traces rather than +directly reading those native addresses. + +Corpus totals are 293 `0x1f6` calls in 146 scripts, 149 `0x23d` calls in 146 scripts, 113 `0x20d` calls in +24 scripts, and 345 `0x20e` calls in 167 scripts. In SC0000, the full reset appears as `0x1f6` then `0x23d` +at `0x4ed`; other paths use `0x1f6` then `0x20e` to discard retained objects and clear the already selected +backbuffer. Fresh offscreen surfaces commonly use `0x20d` then `0x20e` before drawing. + +--- + ## Native walls backlog (targets for this loop) - ~~**call-script dispatch**~~ — **SOLVED** (above): `call-script ` = raw SYS4INI file index. diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 9ae95a5..a536fd4 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -329,6 +329,13 @@ SC0000 label_1235a reaches this when 0x1c7/0x1cc are zero. Native run-state bit - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2: op_0x131_handler@0x4295e0 calls the settings getter with `message:MesWinAlpha` and writes the result. HISTORY.BIN and the shared ADV redraw path compute (16-value)<<4 for the control-strip alpha. +### 0x1f6 `clear-retained-gfx-objects` (clear-retained-gfx-objects, argc 0) +- **summary:** Clear the complete retained gfx-object registry while preserving allocated surface resources. Subsequent object queries return absent until draw/geometry operations recreate records. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x1f6_clear_retained_gfx_objects@0x417430 passes ctx+0x46614 to retained_gfx_objects_clear@0x47cab0. The worker destroys every node in owner+0x408, resets its sentinel/count, and clears owner+0xb558/+0xb55c/+0xb564/+0xb568 plus bit 0x10000 state. DATA1: 293 calls in 146 scripts; 146 continue to 0x23d and 143 to 0x20e. + +The handler clears the map embedded at retained-gfx owner+0x408, resets its count and transient dirty/force flags, but does not release the 1000 surface slots or clear the separate queued-surface-command list. Corpus call sites pair it either with 0x23d for a full transient-resource reset or with 0x20e for a backbuffer clear. + ### 0x1f7 `gfx-elem-erase` (gfx-elem-erase, argc 2) - **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 @@ -365,9 +372,9 @@ SC0000 label_1235a reaches this when 0x1c7/0x1cc are zero. Native run-state bit - **evidence:** Ghidra /v2: handler gfx_op_0x1ff_set_geom3@0x4227b0 converts operands 2..4 to float and calls gfx_object_set_translation_current@0x47e800. The worker gets/creates the object, marks obj+0x168, and calls matrix4_make_translation on obj+0x16c. SC0000 uses (0,0,0) before animating BG001A. Annotated and saved 2026-07-11. ### 0x202 `gfx-blit-color` (gfx-blit-color, argc 5) -- **summary:** 0x202 (handle)(delay_ms)(duration_ms)(alpha)(color) — arm the one-shot packed-ARGB channel. Worker gfx_op_0x202_worker_set_color_anim @0x47ea00 resets shared start obj+0x34, writes delay +0x38, duration +0x4c, and target +0x64. gfx_object_apply_transform_channels @0x472f00 linearly interpolates each byte from current +0x60 on frame clock ctx+0xb550, commits the target, clears timing, writes target -1, and clears the one-shot active bit when all sibling channels finish. Negative alpha/RGB independently preserve current bytes. Implemented in GfxState with synchronized current/target timeline evidence; draw-string 0x204/0x7a remains separate. +- **summary:** 0x202 (handle)(delay_ms)(duration_ms)(alpha)(color) — arm the one-shot packed-ARGB channel. Worker gfx_op_0x202_worker_set_color_anim @0x47ea00 resets shared start obj+0x34, writes delay +0x38, duration +0x4c, and target +0x64. gfx_object_apply_transform_channels @0x472f00 linearly interpolates each byte from current +0x60 on retained-gfx owner+0xb550 (EngineCtx+0x51b64), commits the target, clears timing, writes target -1, and clears the one-shot active bit when all sibling channels finish. Negative alpha/RGB independently preserve current bytes. Implemented in GfxState with synchronized current/target timeline evidence; draw-string 0x204/0x7a remains separate. - **grounding:** source=investigation, confidence=high -- **evidence:** Ghidra handler 0x4228d0 packs operands 4/5 and calls worker 0x47ea00(handle,delay,duration,packed). Consumer 0x472f00: shared start +0x34; color delay/duration +0x38/+0x4c; current/target +0x60/+0x64; frame clock ctx+0xb550; bytewise integer LERP; natural or ctx+0xb55c forced completion. /v2 annotated and saved 2026-07-10. +- **evidence:** Ghidra handler 0x4228d0 packs operands 4/5 and calls worker 0x47ea00(handle,delay,duration,packed). Consumer 0x472f00: shared start +0x34; color delay/duration +0x38/+0x4c; current/target +0x60/+0x64; frame clock retained-gfx owner+0xb550 (EngineCtx+0x51b64); bytewise integer LERP; natural or owner+0xb55c (EngineCtx+0x51b70) forced completion. /v2 annotated and saved 2026-07-10. ### 0x203 `gfx-draw-color` (gfx-draw-color, argc 4) - **summary:** 0x203 (handle)(mode)(alpha)(color) — worker stores the D3D blend selector at obj+0x30 and STATIC packed color at obj+0x60; the handler's ctx+0x53d88 write is the generic 9-dword instruction length. Negative alpha/RGB preserve current static bytes. Mode 0 is the opaque textured path: preserved 0xffffffff is identity (the alpha byte is not tint strength); mode 1 is SRCALPHA/INVSRCALPHA with ARGB alpha opacity and multiplicative RGB modulation; mode 2 is the 0x223 transition-source identity path. Surfaceless mode-0 fill consumption remains a distinct case. @@ -393,6 +400,21 @@ SC0000 label_1235a reaches this when 0x1c7/0x1cc are zero. Native run-state bit Native handler gfx_op_0x20c_present_frame -> gfx_render_frame @0x4820b0. This is an explicit retained-state publication boundary, not a continuously visible object-store mutation. The read/message-skip branch resets the animation service then presents; the port publishes and snaps pending 0x223 state here. Normal playback branches to 0x21c, which owns repeated render/wait/resume. Headless hosts remain non-blocking. +### 0x20d `select-render-target` (select-render-target, argc 1) +- **summary:** Select an offscreen surface slot as Direct3D render target 0, or restore the device backbuffer when the operand is at least 1000. +- **grounding:** source=investigation, confidence=high +- **depends on:** 0x20c, 0x1c7, 0x1cc +- **evidence:** Ghidra /v2: op_0x20d_select_render_target@0x422e10 passes operand 1 and retained-gfx owner ctx+0x46614 to retained_gfx_select_render_target@0x479660. Its D3D calls resolve texture level 0 or backbuffer 0, then invoke device vtable +0x94 SetRenderTarget and store selected slot at owner+0xb530. DATA1: 113 calls in 24 scripts. + +For slots below 1000 the native worker obtains that surface's level-0 D3D texture surface and calls IDirect3DDevice9::SetRenderTarget(0,...). The >=1000 path obtains backbuffer 0 and records current target -1. Himegari commonly follows a freshly created blank surface selection with 0x20e before drawing into it. + +### 0x20e `clear-render-target` (clear-render-target, argc 0) +- **summary:** Clear the currently selected render target to black and reset its depth buffer to 1.0. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x20e_clear_render_target@0x4174f0 passes renderer ctx+0x46188 to d3d_clear_render_target_black@0x471460. The worker calls device pointer +0x410, vtable +0xac with (0,NULL,3,0,1.0f,0). DATA1: 345 calls in 167 scripts; 57 directly follow 0x20d and 143 directly follow 0x1f6. + +This is a target-pixel operation, not retained-object teardown. It invokes IDirect3DDevice9::Clear with zero rectangles, flags D3DCLEAR_TARGET|D3DCLEAR_ZBUFFER (3), color 0, depth 1.0, and stencil 0. The port tracks 0x20d target ownership and forwards the clear to the host; its retained compositor already rebuilds the backbuffer from black. + ### 0x212 `set-gfx-field64` (set-gfx-field64, argc 2) - **summary:** 0x212 (obj_idx)(val) — handler gfx_op_0x212_set_field64 @0x4230c0: obj=[ctx+0x14d54 + obj_idx*4]; if obj: *(obj+0x64)=val. The generic instruction length is 5 dwords. See docs/engine-re.md gfx op-contract table. - **grounding:** source=investigation, confidence=high @@ -437,12 +459,12 @@ Native handler gfx_op_0x20c_present_frame -> gfx_render_frame @0x4820b0. This is - **summary:** (source_handle)(destination_handle) - clone the complete retained gfx object record (0xb5 dwords / 0x2d4 bytes). SC0000 uses destination=source+1 as range A's old-frame snapshot immediately before 0x223 crossfades range B's updated source into target handle source+2. - **grounding:** source=investigation, confidence=high - **depends on:** 0x223 -- **evidence:** Ghidra handler 0x423310 -> gfx_object_clone@0x47e4f0. Worker requires source in ctx+0x408 map, creates destination, copies exactly 0xb5 dwords from source object record, then marks ctx+0xb558 dirty. SC0000 site 0x128fc passes current handle and handle+1 before queueing 0x223 at 0x129e7. +- **evidence:** Ghidra handler 0x423310 -> gfx_object_clone@0x47e4f0. Worker requires source in retained-gfx owner+0x408 (EngineCtx+0x46a1c), creates destination, copies exactly 0xb5 dwords from source object record, then marks owner+0xb558 (EngineCtx+0x51b6c) dirty. SC0000 site 0x128fc passes current handle and handle+1 before queueing 0x223 at 0x129e7. ### 0x21e `set-anim-transform-norm` (set-anim-transform-norm, argc 6) -- **summary:** (handle)(delay_ms)(duration_ms)(sx)(sy)(sz) — set the normalized SCALE-matrix channel (100=identity). Target obj+0xac is linearly sampled from current obj+0x6c by gfx_object_apply_transform_channels@0x472f00 on frame-time ctx+0xb550, after delay and for duration, then committed. Shares only start timestamp obj+0x34 with op 0x220; neither Z is opacity. +- **summary:** (handle)(delay_ms)(duration_ms)(sx)(sy)(sz) — set the normalized SCALE-matrix channel (100=identity). Target obj+0xac is linearly sampled from current obj+0x6c by gfx_object_apply_transform_channels@0x472f00 on retained-gfx frame time owner+0xb550 (EngineCtx+0x51b64), after delay and for duration, then committed. Shares only start timestamp obj+0x34 with op 0x220; neither Z is opacity. - **grounding:** source=investigation, confidence=high -- **evidence:** Ghidra 0x47eaa0 calls matrix builder 0x48af1d for target obj+0xac. Consumer 0x472f00 uses delay obj+0x3c, duration obj+0x50, current obj+0x6c, target obj+0xac, shared start obj+0x34, and frame-time ctx+0xb550. +- **evidence:** Ghidra 0x47eaa0 calls matrix builder 0x48af1d for target obj+0xac. Consumer 0x472f00 uses delay obj+0x3c, duration obj+0x50, current obj+0x6c, target obj+0xac, shared start obj+0x34, and retained-gfx frame time owner+0xb550 (EngineCtx+0x51b64). ### 0x21f `set-anim-rotation-axis-angle` (set-anim-rotation-axis-angle, argc 7) - **summary:** (handle)(delay_ms)(duration_ms)(axis_x)(axis_y)(axis_z)(angle_deg) — set the delayed one-shot axis-angle rotation channel. Handler converts axis/angle integers to floats; worker stores target axis obj+0x1f8 and angle obj+0x208 and builds target matrix obj+0x12c. gfx_object_apply_transform_channels samples current axis/angle linearly on shared start obj+0x34 and composes T(-anchor)*scale*rotation*translation*T(anchor). @@ -450,9 +472,9 @@ Native handler gfx_op_0x20c_present_frame -> gfx_render_frame @0x4820b0. This is - **evidence:** Ghidra handler 0x423410 -> gfx_object_set_rotation_channel@0x47eb70; consumer gfx_object_apply_transform_channels@0x472f00 uses delay +0x40, duration +0x54, current axis +0x1ec/angle +0x204, target axis +0x1f8/angle +0x208, current matrix +0xec and target +0x12c. Native SC0000 handle 0xcb8e sample at 11/390 of axis (0,0,1), 30deg matches matrix [0.9055,0.0134;-0.0134,0.9055] and translation (74.1449,47.3127). ### 0x220 `set-anim-transform-abs` (set-anim-transform-abs, argc 6) -- **summary:** (handle)(delay_ms)(duration_ms)(tx)(ty)(tz) — set the absolute TRANSLATION-matrix channel. Target obj+0x1ac is linearly sampled from current obj+0x16c by gfx_object_apply_transform_channels@0x472f00 on frame-time ctx+0xb550, after delay and for duration, then committed. Independent of op 0x21e scale; neither Z is opacity. +- **summary:** (handle)(delay_ms)(duration_ms)(tx)(ty)(tz) — set the absolute TRANSLATION-matrix channel. Target obj+0x1ac is linearly sampled from current obj+0x16c by gfx_object_apply_transform_channels@0x472f00 on retained-gfx frame time owner+0xb550 (EngineCtx+0x51b64), after delay and for duration, then committed. Independent of op 0x21e scale; neither Z is opacity. - **grounding:** source=investigation, confidence=high -- **evidence:** Ghidra 0x47ecc0 calls matrix builder 0x48afb1 for target obj+0x1ac. Consumer 0x472f00 uses delay obj+0x44, duration obj+0x58, current obj+0x16c, target obj+0x1ac, shared start obj+0x34, and frame-time ctx+0xb550. +- **evidence:** Ghidra 0x47ecc0 calls matrix builder 0x48afb1 for target obj+0x1ac. Consumer 0x472f00 uses delay obj+0x44, duration obj+0x58, current obj+0x16c, target obj+0x1ac, shared start obj+0x34, and retained-gfx frame time owner+0xb550 (EngineCtx+0x51b64). ### 0x222 `present-gfx-object-range` (present-gfx-object-range, argc 2) - **summary:** (first_handle)(count) - flush/present retained graphics objects in the selected handle range and clear their pending update flags. @@ -464,7 +486,7 @@ Native handler gfx_op_0x20c_present_frame -> gfx_render_frame @0x4820b0. This is - **grounding:** source=investigation, confidence=high - **depends on:** 0x20c, 0x21c, 0x1c7, 0x1cc - **depended on by:** 0x20c, 0x21c, 0x21d -- **evidence:** Ghidra handler 0x423620 -> gfx_queue_surface_alpha_transition@0x47f440. Record fields: type +0=0, start +4=0, delay +8=arg7, duration +0xc=arg8, slot +0x10=arg2, range A +0x14/+0x1c=args3/4, range B +0x18/+0x20=args5/6. gfx_render_frame@0x47fbc0 initializes start from ctx+0xb550 and consumes type 0 as an alpha ramp. SC0000 executes one shared-helper site at 0x129e7. +- **evidence:** Ghidra handler 0x423620 -> gfx_queue_surface_alpha_transition@0x47f440. Record fields: type +0=0, start +4=0, delay +8=arg7, duration +0xc=arg8, slot +0x10=arg2, range A +0x14/+0x1c=args3/4, range B +0x18/+0x20=args5/6. gfx_render_frame@0x47fbc0 initializes start from retained-gfx owner+0xb550 (EngineCtx+0x51b64) and consumes type 0 as an alpha ramp. SC0000 executes one shared-helper site at 0x129e7. ### 0x224 `clear-gfx-command-queue` (clear-gfx-command-queue, argc 0) - **summary:** Clear the native gfx command queue rooted at ctx+0x418. Host-implicit because the port composites retained state directly. @@ -497,7 +519,7 @@ Native handler gfx_op_0x20c_present_frame -> gfx_render_frame @0x4820b0. This is ### 0x234 `anim-start` (anim-start, argc 5) - **summary:** (handle)(period_ms)(axis_x)(axis_y)(axis_z) — configure cyclic rotation. Worker stores period obj+0x228, start obj+0x214=0, and float axis obj+0x244; each frame uses integer degrees floor(((now-start)%period)*360/period). gfx_object_composite right-multiplies this separately anchored transform after the one-shot scale/rotation/translation product, so cyclic rotation also rotates the translation vector. - **grounding:** source=investigation, confidence=high -- **evidence:** Ghidra handler 0x423da0 converts axis ints to floats -> worker 0x47f060. gfx_object_anim_interpolate@0x473ed0 consumes obj+0x228/+0x214/+0x244 on ctx+0xb550 and matrix4_make_axis_angle@0x48b215. gfx_object_composite@0x47f650 calls one-shot transform first, cyclic animation second. +- **evidence:** Ghidra handler 0x423da0 converts axis ints to floats -> worker 0x47f060. gfx_object_anim_interpolate@0x473ed0 consumes obj+0x228/+0x214/+0x244 on retained-gfx owner+0xb550 (EngineCtx+0x51b64) and matrix4_make_axis_angle@0x48b215. gfx_object_composite@0x47f650 calls one-shot transform first, cyclic animation second. ### 0x236 `play-movie-to-surface` (play-movie-to-surface, argc 4) - **summary:** (resource_id)(surface_slot)(movie_flags)(sync_mask) - synchronously resolve/open an archive movie and construct its DirectShow graph, then start asynchronous frame delivery into the retained destination surface. The opcode itself is non-blocking: the VM advances to the next instruction. SC0000's native site evaluates (0x33, 0, 2, 0) at 0x13c8 and resumes at bytecode 0x13d1; its later 0x21c service boundary yields until movie EOF before cleanup. @@ -515,6 +537,13 @@ The handler requires an existing destination texture, allocates/reuses a 0x478-b - **grounding:** source=investigation, confidence=high - **evidence:** Native /v2 worker and gfx_object_apply_transform_channels decompile. The consumer advances target_frame cells over duration after delay, preserves the existing source-rect dimensions, and commits the endpoint. +### 0x23d `release-transient-surfaces` (release-transient-surfaces, argc 0) +- **summary:** Stop movie bindings and release transient gfx surface slots 42 through 999 inclusive, preserving system-owned slots 0 through 41. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x23d_release_transient_surfaces@0x4175c0 loops EDI=42 while EDI<1000, stops/releases ctx+0x52bd4[EDI] movie objects, and calls retained_gfx_release_surface@0x474e40 on owner ctx+0x46614. DATA1: 149 calls in 146 scripts; 146 directly follow 0x1f6. + +For each fixed slot in [42,1000), the handler stops/releases the movie-to-texture object at ctx+0x52bd4[slot], then invokes the ordinary retained-gfx surface-release worker. Protected/externally owned slots may be retained by the worker's per-slot guard. This is the resource half of the common 0x1f6/0x23d full-reset sequence. + ### 0x23f `u00422930` (u00422930, argc 2) - **summary:** 0x23f query-object (out)(handle): return object status (FUN_0042a520; -1 if none). C# VM: 0 if the object exists else -1. See docs/engine-re.md §SC0000 anim cluster. - **grounding:** source=kelebek, confidence=low @@ -1094,10 +1123,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 -### 0x1f6 `u00416170` (u00416170, argc 0) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x1fe `u004206C0` (u004206C0, argc 5) - **summary:** — - **grounding:** source=kelebek, confidence=low @@ -1110,15 +1135,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 -### 0x20d `u00420E10` (u00420E10, argc 1) -- **summary:** — -- **grounding:** source=kelebek, confidence=low -- **depends on:** 0x20c, 0x1c7, 0x1cc - -### 0x20e `u00416250` (u00416250, argc 0) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x20f `u00420E40` (u00420E40, argc 3) - **summary:** — - **grounding:** source=kelebek, confidence=low @@ -1155,10 +1171,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 -### 0x23d `u004162F0` (u004162F0, argc 0) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x241 `u00422B80` (u00422B80, argc 5) - **summary:** — - **grounding:** source=kelebek, confidence=low diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index 9125123..f6f8fc9 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -954,10 +954,12 @@ The plain check reports only the generated reference's CRLF on its newly added r **Native contract.** `gfx_op_0x202_worker_set_color_anim@0x47ea00` writes target packed ARGB at `obj+0x64`, delay/duration at `+0x38/+0x4c`, and resets the one-shot family's shared start `+0x34`. The missing consumer is the bit-1 path in `gfx_object_apply_transform_channels@0x472f00`: it seeds start -from frame time `ctx+0xb550`, performs an integer bytewise current-`+0x60` to target-`+0x64` LERP, then +from frame time `retained-gfx owner+0xb550` (`EngineCtx+0x51b64`), performs an integer bytewise +current-`+0x60` to target-`+0x64` LERP, then commits target, clears timing, writes target `-1`, and clears the active bit once all sibling one-shot channels finish. This is ordinary presentation-clock coupling, not op `0x238`'s separate service clock. -`ctx+0xb55c == 1` forces completion unless the object-local `+0x2d0` override bit is set. `/v2` comments +`retained-gfx owner+0xb55c == 1` (`EngineCtx+0x51b70`) forces completion unless the object-local +`+0x2d0` override bit is set. `/v2` comments were corrected and saved. **Port and endpoint diagnosis.** `GfxState` now retains current/target/delay/duration separately, shares @@ -2041,3 +2043,36 @@ largest repeated non-persistence SC0000 cluster. Validation: all 206 engine tests pass; opcode and EngineCtx tests/lints, vm0 RECOVER, and `git diff --check` are clean. + +### Slice A2b retained-graphics lifecycle implemented (2026-07-20) + +Native RE split the apparent `0x1f6` / `0x20e` pair into two separate services. `0x1f6` clears the retained +handle-to-object registry without releasing surface resources, and the commonly adjacent `0x23d` stops +movie bindings and releases transient surface slots 42 through 999. Independently, `0x20d(slot)` selects +an offscreen Direct3D render target (or the backbuffer for values at least 1000), and `0x20e` clears the +selected color target to black plus its depth buffer to 1.0. + +The VM now models all four operations generically. `GfxState` owns the selected target and distinct +object/surface lifecycle; the host receives target clears and fixed-range surface release. Godot removes +modeled offscreen text pixels on clear, stops movies and clears slot-owned resources on the bulk release, +and continues to rebuild the main retained frame from black. Focused regressions prove object clear +preserves surfaces for rebinding, target selection reaches both an offscreen slot and the backbuffer, and +the bulk release preserves system-owned low slots while dropping slot 42. + +The trace corrected an older EngineCtx-base assumption: object registry `+0x408`, frame timer `+0xb550`, +and nearby animation flags are relative to the embedded retained-gfx manager at `EngineCtx+0x46614`. +Their actual absolute addresses are `EngineCtx+0x46a1c` and `EngineCtx+0x51b64` onward. Existing visible +graphics and animation implementations remain valid because they model the recovered worker behavior and +observed timing rather than reading native process addresses. + +The canonical opcode and EngineCtx sources are regenerated. `/v2` names/comments the four handlers and +five supporting workers, and the corrected structure is reapplied before saving. SC0000 is now **121/129 +distinct opcodes handled (93.8%)** and **16,037/16,257 instructions handled or safe-noop (98.6%)**; 206 of +the 220 remaining effectful-gap instructions are the deliberately deferred `0x1a2` shared-profile writes. + +**Next:** investigate/implement the two-call `0x242` retained-object field setter. It is already classified +as an object `+0x2d0` write and is the smallest coherent non-persistence gap; keep `0x1a2`/`0x1a3`, `0x1ad`, +and `0x1cb` behind the future profile/save ownership decision. + +Validation: all 209 engine tests pass; opcode and EngineCtx tests/lints, vm0 RECOVER, the Godot build, +threaded self-test, and `git diff --check` are clean. diff --git a/engine/Age.Engine.Tests/GfxLifecycleOpsTests.cs b/engine/Age.Engine.Tests/GfxLifecycleOpsTests.cs new file mode 100644 index 0000000..0002165 --- /dev/null +++ b/engine/Age.Engine.Tests/GfxLifecycleOpsTests.cs @@ -0,0 +1,82 @@ +using System.Collections.Generic; +using Age.Engine.Model; +using Age.Engine.Sys4; +using Age.Engine.Vm; +using Xunit; + +public class GfxLifecycleOpsTests +{ + private static OpcodeTable T() => OpcodeTableJson.Load(Paths.OpcodesJson); + private static Operand G(int address) => new(3, address); + private static Operand I(long value) => new(0, value); + private static (int, Operand[]) Mov(int address, long value) => (0x55, new[] { G(address), I(value) }); + private static (int, Operand[]) Exit() => (0x2, System.Array.Empty()); + + [Fact] + public void ClearRetainedObjectsPreservesSurfaceResourcesForLaterRebind() + { + var table = T(); + var scene = ScriptAssembler.Assemble(table, "GFX-LIFECYCLE", new List<(int, Operand[])> + { + Mov(1, 0x40), Mov(2, 7), Mov(3, 0x33), + (0x1f9, new[] { G(3), G(2), I(0) }), + (0x1fb, new[] { G(1), G(2), I(0), I(0), I(16), I(16), I(0), I(0) }), + (0x1f6, System.Array.Empty()), + (0x1fb, new[] { G(1), G(2), I(0), I(0), I(16), I(16), I(0), I(0) }), + Exit(), + }, System.Array.Empty()); + + var vm = new VirtualMachine(scene, table, new RecordingHost()); + vm.Run(); + + var visible = Assert.Single(vm.Gfx.SnapshotVisibleObjects()); + Assert.Equal(0x40, visible.Handle); + Assert.Equal(0x33, visible.SurfaceResId); + } + + [Fact] + public void RenderTargetSelectionAndClearReachTheHostIncludingBackbufferRestore() + { + var table = T(); + var host = new RecordingHost(); + var scene = ScriptAssembler.Assemble(table, "GFX-TARGET", new List<(int, Operand[])> + { + (0x20d, new[] { I(7) }), + (0x20e, System.Array.Empty()), + (0x20d, new[] { I(1000) }), + (0x20e, System.Array.Empty()), + Exit(), + }, System.Array.Empty()); + + var vm = new VirtualMachine(scene, table, host); + vm.Run(); + + Assert.Equal(new[] { 7, -1 }, host.ClearedRenderTargets); + Assert.Equal(-1, vm.Gfx.CurrentRenderTargetSlot); + } + + [Fact] + public void BulkReleaseDropsOnlyTransientSurfaceRange() + { + var table = T(); + var host = new RecordingHost(); + var scene = ScriptAssembler.Assemble(table, "GFX-RELEASE", new List<(int, Operand[])> + { + Mov(1, 1), Mov(2, 42), Mov(3, 0x11), Mov(4, 0x22), + (0x1f9, new[] { G(3), G(1), I(0) }), + (0x1f9, new[] { G(4), G(2), I(0) }), + (0x1fb, new[] { I(0x100), G(1), I(0), I(0), I(16), I(16), I(0), I(0) }), + (0x1fb, new[] { I(0x101), G(2), I(0), I(0), I(16), I(16), I(0), I(0) }), + (0x23d, System.Array.Empty()), + Exit(), + }, System.Array.Empty()); + + var vm = new VirtualMachine(scene, table, host); + vm.Run(); + + var visible = vm.Gfx.SnapshotVisibleObjects(); + Assert.Equal(0x11, Assert.Single(visible, item => item.Handle == 0x100).SurfaceResId); + Assert.Equal(0, Assert.Single(visible, item => item.Handle == 0x101).SurfaceResId); + Assert.Equal((42, 958), Assert.Single(host.ReleasedSurfaceRanges)); + } +} diff --git a/engine/Age.Engine.Tests/TestSupport.cs b/engine/Age.Engine.Tests/TestSupport.cs index 3acd360..0a77193 100644 --- a/engine/Age.Engine.Tests/TestSupport.cs +++ b/engine/Age.Engine.Tests/TestSupport.cs @@ -36,6 +36,8 @@ internal class RecordingHost : IHost public readonly List SfxReleases = new(); public readonly List<(int Target, long Duration)> BgmFades = new(); public readonly List<(long Resource, int Surface, long Flags, long SyncMask)> Movies = new(); + public readonly List ClearedRenderTargets = new(); + public readonly List<(int First, int Count)> ReleasedSurfaceRanges = new(); public readonly List MessageSkipChanges = new(); public readonly List CursorResources = new(); public readonly List AdvPagePresentationSuspended = new(); @@ -106,6 +108,8 @@ internal class RecordingHost : IHost } public void CreateTexture(int slot, int w, int h) { } public void SetTexture(long resId, int slot) { } + public void ClearRenderTarget(int surfaceSlot) => ClearedRenderTargets.Add(surfaceSlot); + public void ReleaseSurfaceRange(int firstSlot, int count) => ReleasedSurfaceRanges.Add((firstSlot, count)); public void DrawTexture(int slot, int sx, int sy, int w, int h, int dx, int dy) { } public (int Width, int Height) GetTextureSize(int slot) => (0, 0); public void PlayBgm(long id) { } diff --git a/engine/Age.Engine/Hosting/IHost.cs b/engine/Age.Engine/Hosting/IHost.cs index 13be7e6..59eb243 100644 --- a/engine/Age.Engine/Hosting/IHost.cs +++ b/engine/Age.Engine/Hosting/IHost.cs @@ -72,6 +72,12 @@ public interface IHost void CreateTexture(int slot, int width, int height); void SetTexture(long resourceId, int slot); void ReleaseSurface(int slot) { } + /// Clear the selected target's pixels; -1 denotes the main backbuffer. + void ClearRenderTarget(int surfaceSlot) { } + void ReleaseSurfaceRange(int firstSlot, int count) + { + for (int slot = firstSlot; slot < firstSlot + count; slot++) ReleaseSurface(slot); + } void DrawTexture(int slot, int srcX, int srcY, int width, int height, int dstX, int dstY); (int Width, int Height) GetTextureSize(int slot); void PlayBgm(long id); diff --git a/engine/Age.Engine/Model/GfxState.cs b/engine/Age.Engine/Model/GfxState.cs index 78f2728..608d473 100644 --- a/engine/Age.Engine/Model/GfxState.cs +++ b/engine/Age.Engine/Model/GfxState.cs @@ -42,7 +42,7 @@ public readonly record struct RenderObject(long Handle, long SurfaceResId, long /// Host-agnostic model of the AGE native gfx command-buffer (reversed in /// docs/engine-re.md, gfx op-contract table). One registry maps an object handle to a GfxObject — the -/// native ctx+0x408 map that op 0x215 queries and the geometry get/set ops share. Each object carries a +/// native retained-gfx owner+0x408 map (EngineCtx+0x46a1c) that op 0x215 queries and the geometry get/set ops share. Each object carries a /// slot (returned by 0x215) and three 3-vectors: V18 (set 0x217 / get 0x218, anchor), V24 (set 0x219 / /// get 0x21a, position), V16c (set 0x1ff). The native DirectDraw workers are NOT modelled — only the data /// the query ops read back, which is all the bytecode geometry math needs. @@ -100,7 +100,8 @@ public sealed class GfxState public (double X, double Y, double Z, double Angle) RotationTarget; public long RotationDelayMs, RotationDurationMs; public bool RotationChannelEnabled; - // Shared matrix-channel start timestamp obj+0x34, seeded from frame-time ctx+0xb550. + // Shared matrix-channel start timestamp obj+0x34, seeded from retained-gfx owner+0xb550 + // (EngineCtx+0x51b64). public long OneShotStartMs = -1; // Op 0x234 is a separate cyclic rotation channel (period obj+0x228, axis obj+0x244..0x24c). @@ -117,6 +118,8 @@ public sealed class GfxState private readonly Dictionary _fieldTable = new(); // ctx+0x46d14 (0x216); no family writer -> default 0 public long CurrentObject { get; private set; } + /// The D3D render target selected by op 0x20d. -1 denotes the main backbuffer. + public int CurrentRenderTargetSlot { get; private set; } = -1; // ---- Separate global animation service clock (op 0x238; ctx+0x51b7c total / +0x51b78 elapsed). // Retained for its opcode family; 0x21e scale and 0x220 translation use frame-time directly instead. ---- @@ -219,6 +222,16 @@ public sealed class GfxState } } + /// Op 0x1f6: clear every retained gfx-object record without releasing surface resources. + public void ClearRetainedObjects() + { + lock (_lock) + { + _objects.Clear(); + CurrentObject = 0; + } + } + private readonly object _lock = new(); // ---- surfaces (image buffers per slot): ctx+0x52bd4[slot], from create/set-texture ---- @@ -226,6 +239,29 @@ public sealed class GfxState private readonly Dictionary _surfaceTransitions = new(); public void SetSurface(int slot, long resId, long colorKey) { lock (_lock) { _surfaces[slot] = (resId, colorKey); } } + /// Op 0x20d: select a surface as the D3D render target; values at or above 1000 restore the + /// device backbuffer in the native engine. + public void SelectRenderTarget(long slot) + { + lock (_lock) CurrentRenderTargetSlot = slot is >= 0 and < 1000 ? (int)slot : -1; + } + + /// Op 0x23d: release the transient surface range while retaining system-owned low slots. + public void ReleaseSurfaceRange(int firstSlot, int count) + { + lock (_lock) + { + int end = checked(firstSlot + count); + for (int slot = firstSlot; slot < end; slot++) + { + _surfaces.Remove(slot); + _surfaceTransitions.Remove(slot); + } + if (CurrentRenderTargetSlot >= firstSlot && CurrentRenderTargetSlot < end) + CurrentRenderTargetSlot = -1; + } + } + /// Ops 0x202/0x203: record a packed 0xAARRGGBB color/alpha modulation on the object and mark it /// HasColor so the compositor applies alpha+tint (vs the opaque default). public void SetObjectColor(long handle, long packed) @@ -502,7 +538,7 @@ public sealed class GfxState } /// Op 0x234: retain the cyclic rotation period and axis separately. Native interpolation uses - /// frame-time ctx+0xb550 and rotates through 360 degrees per period; affine rendering is deferred. + /// frame-time retained-gfx owner+0xb550 (EngineCtx+0x51b64) and rotates through 360 degrees per period. public void SetRotationCycle(long handle, long periodMs, (long X, long Y, long Z) axis) { lock (_lock) diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 75c0d62..4321650 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -962,6 +962,15 @@ public sealed class VirtualMachine (int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2]), (int)Read(a[3]), (int)Read(a[4]), (int)System.Math.Min(Read(a[5]), 255), Read(a[6]) & 0x00ff_ffff)); return pc + 1; + case "clear-retained-gfx-objects": // 0x1f6: erase object records, but preserve surfaces + Gfx.ClearRetainedObjects(); return pc + 1; + case "select-render-target": // 0x20d: slot <1000 selects a surface; >=1000 restores backbuffer + Gfx.SelectRenderTarget(Read(a[0])); return pc + 1; + case "clear-render-target": // 0x20e: clear color to black and depth to one + _host.ClearRenderTarget(Gfx.CurrentRenderTargetSlot); return pc + 1; + case "release-transient-surfaces": // 0x23d: native fixed range [42,1000) + Gfx.ReleaseSurfaceRange(42, 1000 - 42); + _host.ReleaseSurfaceRange(42, 1000 - 42); return pc + 1; case "play-bgm": _host.PlayBgm(Read(a[0])); return pc + 1; case "play-voice": _autoVoicePending = true; diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index 355c3ec..de7874d 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -593,6 +593,49 @@ public sealed class GodotAdvHost : IHost _main.CallDeferred("StopMovie", resourceId); } + public void ClearRenderTarget(int surfaceSlot) + { + // The retained compositor rebuilds the backbuffer from black at the next publication boundary. + // For an offscreen target, discard separately retained text draws so its modeled pixel contents + // observe the native D3D clear as well. + if (surfaceSlot >= 0) + lock (_textLock) _surfaceText.Remove(surfaceSlot); + _timeline?.Event("render-target-clear", new() { ["surface"] = surfaceSlot }); + } + + public void ReleaseSurfaceRange(int firstSlot, int count) + { + var stoppedMovies = new System.Collections.Generic.HashSet(); + int end = checked(firstSlot + count); + lock (_imageLock) + { + for (int slot = firstSlot; slot < end; slot++) + { + if (_movieBySurface.Remove(slot, out long resourceId)) + { + stoppedMovies.Add(resourceId); + _movieFrames.Remove(resourceId); + _completedMovies.Remove(resourceId); + } + _slotDims.Remove(slot); + } + } + lock (_textLock) + { + for (int slot = firstSlot; slot < end; slot++) + { + _surfaceText.Remove(slot); + _surfaceResources.Remove(slot); + } + } + foreach (long resourceId in stoppedMovies) + { + _timeline?.Event("movie-stop", new() { ["resource"] = resourceId, ["range_release"] = true }); + _main.CallDeferred("StopMovie", resourceId); + } + _timeline?.Event("surface-range-release", new() { ["first"] = firstSlot, ["count"] = count }); + } + // Main-thread decoder handoff. Replacing the newest frame mirrors the native texture renderer's // sample callback: the retained object keeps its surface binding while only the surface pixels change. public void PublishMovieFrame(long resourceId, string name, int rawIndex, RgbaImage frame) diff --git a/vm-map/engine-ctx.toml b/vm-map/engine-ctx.toml index cbc2446..ba123aa 100644 --- a/vm-map/engine-ctx.toml +++ b/vm-map/engine-ctx.toml @@ -7,11 +7,6 @@ struct_name = "EngineCtx" size = 0xa1000 -[[field]] -offset = 0x408 -name = "gfx_obj_registry" -type = "int" -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" @@ -43,16 +38,6 @@ name = "alt_pack_table" type = "int" note = "call-script high-byte alternate pack table (unused by corpus)" [[field]] -offset = 0xb558 -name = "gfx_dirty_a" -type = "int" -note = "gfx dirty flag (anim set raises)" -[[field]] -offset = 0xb560 -name = "gfx_dirty_b" -type = "int" -note = "gfx dirty flag" -[[field]] offset = 0x14d54 name = "gfx_obj_ptr_table" type = "void*" @@ -68,6 +53,26 @@ name = "script_frame_index" type = "int" note = "call-script frame index (0x1e-dword frames)" [[field]] +offset = 0x46188 +name = "renderer_device_state" +type = "int" +note = "embedded D3D9 device/render state; op 0x20e passes this base to the target/depth clear worker" +[[field]] +offset = 0x46598 +name = "d3d9_device" +type = "void*" +note = "IDirect3DDevice9 pointer at renderer_device_state+0x410; op 0x20e invokes vtable Clear +0xac" +[[field]] +offset = 0x46614 +name = "retained_gfx_manager" +type = "int" +note = "embedded retained graphics manager passed by gfx opcode handlers; worker offsets are relative to this base" +[[field]] +offset = 0x46a1c +name = "gfx_obj_registry" +type = "int" +note = "retained_gfx_manager+0x408 std::map handle->object; 0x1f6 clears, geometry/draw get-or-create, 0x215 queries, 0x1f7 erases" +[[field]] offset = 0x46d14 name = "query_table_46d14" type = "void*" @@ -83,20 +88,45 @@ name = "shared_profile_int_table" type = "int" note = "open-addressing 12-byte string-key to 32-bit value table; op 0x1a2 stores, 0x1a3 loads, shared SAVE.DAT serializes it" [[field]] +offset = 0x50ba4 +name = "gfx_render_target_table" +type = "void*" +note = "retained_gfx_manager+0xa590 array of per-slot D3D texture wrappers selected by op 0x20d" +[[field]] offset = 0x51b64 name = "frame_timer" type = "int" -note = "frame timer (present updates 0x51b64/0x51b68)" +note = "frame timer at retained_gfx_manager+0xb550; present updates 0x51b64/0x51b68 and object animation samples it" [[field]] offset = 0x51b78 name = "anim_clock_elapsed" type = "int" -note = "global anim clock elapsed (op 0x238 zeroes)" +note = "global anim clock elapsed at retained_gfx_manager+0xb564; op 0x238 and object reset 0x1f6 zero it" [[field]] offset = 0x51b7c name = "anim_clock_duration" type = "int" -note = "global anim clock total duration (op 0x238 sets)" +note = "global anim clock duration at retained_gfx_manager+0xb568; op 0x238 sets and object reset 0x1f6 zeroes it" +[[field]] +offset = 0x51b44 +name = "gfx_current_render_target" +type = "int" +note = "retained_gfx_manager+0xb530 selected surface slot; -1 denotes device backbuffer" +[[field]] +offset = 0x51b6c +name = "gfx_dirty_a" +type = "int" +note = "retained_gfx_manager+0xb558 dirty flag; op 0x1f6 clears it" +[[field]] +offset = 0x51b70 +name = "gfx_force_complete" +type = "int" +note = "retained_gfx_manager+0xb55c requests forced completion of one-shot channels; op 0x1f6 clears it" +[[field]] +offset = 0x51b74 +name = "gfx_dirty_b" +type = "int" +note = "retained_gfx_manager+0xb560 dirty flag" [[field]] offset = 0x52bd4 name = "surfaces" diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 2d57c54..c8b6ce1 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -4495,19 +4495,20 @@ evidence = "" [[opcode]] op = 0x1f6 -label = "u00416170" +label = "clear-retained-gfx-objects" argc = 0 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00416170" -category = "unknown" -summary = "" +name = "clear-retained-gfx-objects" +category = "draw" +summary = "Clear the complete retained gfx-object registry while preserving allocated surface resources. Subsequent object queries return absent until draw/geometry operations recreate records." +details = "The handler clears the map embedded at retained-gfx owner+0x408, resets its count and transient dirty/force flags, but does not release the 1000 surface slots or clear the separate queued-surface-command list. Corpus call sites pair it either with 0x23d for a full transient-resource reset or with 0x20e for a backbuffer clear." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x1f6_clear_retained_gfx_objects@0x417430 passes ctx+0x46614 to retained_gfx_objects_clear@0x47cab0. The worker destroys every node in owner+0x408, resets its sentinel/count, and clears owner+0xb558/+0xb55c/+0xb564/+0xb568 plus bit 0x10000 state. DATA1: 293 calls in 146 scripts; 146 continue to 0x23d and 143 to 0x20e." [[opcode]] op = 0x1f7 @@ -4801,12 +4802,12 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "gfx-blit-color" category = "draw" -summary = "0x202 (handle)(delay_ms)(duration_ms)(alpha)(color) — arm the one-shot packed-ARGB channel. Worker gfx_op_0x202_worker_set_color_anim @0x47ea00 resets shared start obj+0x34, writes delay +0x38, duration +0x4c, and target +0x64. gfx_object_apply_transform_channels @0x472f00 linearly interpolates each byte from current +0x60 on frame clock ctx+0xb550, commits the target, clears timing, writes target -1, and clears the one-shot active bit when all sibling channels finish. Negative alpha/RGB independently preserve current bytes. Implemented in GfxState with synchronized current/target timeline evidence; draw-string 0x204/0x7a remains separate." +summary = "0x202 (handle)(delay_ms)(duration_ms)(alpha)(color) — arm the one-shot packed-ARGB channel. Worker gfx_op_0x202_worker_set_color_anim @0x47ea00 resets shared start obj+0x34, writes delay +0x38, duration +0x4c, and target +0x64. gfx_object_apply_transform_channels @0x472f00 linearly interpolates each byte from current +0x60 on retained-gfx owner+0xb550 (EngineCtx+0x51b64), commits the target, clears timing, writes target -1, and clears the one-shot active bit when all sibling channels finish. Negative alpha/RGB independently preserve current bytes. Implemented in GfxState with synchronized current/target timeline evidence; draw-string 0x204/0x7a remains separate." noop_headless = false source = "investigation" confidence = "high" depends_on = [] -evidence = "Ghidra handler 0x4228d0 packs operands 4/5 and calls worker 0x47ea00(handle,delay,duration,packed). Consumer 0x472f00: shared start +0x34; color delay/duration +0x38/+0x4c; current/target +0x60/+0x64; frame clock ctx+0xb550; bytewise integer LERP; natural or ctx+0xb55c forced completion. /v2 annotated and saved 2026-07-10." +evidence = "Ghidra handler 0x4228d0 packs operands 4/5 and calls worker 0x47ea00(handle,delay,duration,packed). Consumer 0x472f00: shared start +0x34; color delay/duration +0x38/+0x4c; current/target +0x60/+0x64; frame clock retained-gfx owner+0xb550 (EngineCtx+0x51b64); bytewise integer LERP; natural or owner+0xb55c (EngineCtx+0x51b70) forced completion. /v2 annotated and saved 2026-07-10." [[opcode.semantics.args]] i = 1 @@ -5129,40 +5130,42 @@ evidence = "Ghidra: dispatch table FUN_00413860 param_1[0x26e9f]=gfx_op_0x20c_pr [[opcode]] op = 0x20d -label = "u00420E10" +label = "select-render-target" argc = 1 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00420E10" -category = "unknown" -summary = "" +name = "select-render-target" +category = "draw" +summary = "Select an offscreen surface slot as Direct3D render target 0, or restore the device backbuffer when the operand is at least 1000." +details = "For slots below 1000 the native worker obtains that surface's level-0 D3D texture surface and calls IDirect3DDevice9::SetRenderTarget(0,...). The >=1000 path obtains backbuffer 0 and records current target -1. Himegari commonly follows a freshly created blank surface selection with 0x20e before drawing into it." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [0x20c, 0x1c7, 0x1cc] -evidence = "" +evidence = "Ghidra /v2: op_0x20d_select_render_target@0x422e10 passes operand 1 and retained-gfx owner ctx+0x46614 to retained_gfx_select_render_target@0x479660. Its D3D calls resolve texture level 0 or backbuffer 0, then invoke device vtable +0x94 SetRenderTarget and store selected slot at owner+0xb530. DATA1: 113 calls in 24 scripts." [[opcode.semantics.args]] i = 1 -role = "" +role = "surface slot; >=1000 selects the backbuffer" observed_types = ["imm", "l-int"] [[opcode]] op = 0x20e -label = "u00416250" +label = "clear-render-target" argc = 0 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00416250" -category = "unknown" -summary = "" +name = "clear-render-target" +category = "draw" +summary = "Clear the currently selected render target to black and reset its depth buffer to 1.0." +details = "This is a target-pixel operation, not retained-object teardown. It invokes IDirect3DDevice9::Clear with zero rectangles, flags D3DCLEAR_TARGET|D3DCLEAR_ZBUFFER (3), color 0, depth 1.0, and stencil 0. The port tracks 0x20d target ownership and forwards the clear to the host; its retained compositor already rebuilds the backbuffer from black." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x20e_clear_render_target@0x4174f0 passes renderer ctx+0x46188 to d3d_clear_render_target_black@0x471460. The worker calls device pointer +0x410, vtable +0xac with (0,NULL,3,0,1.0f,0). DATA1: 345 calls in 167 scripts; 57 directly follow 0x20d and 143 directly follow 0x1f6." [[opcode]] op = 0x20f @@ -5500,7 +5503,7 @@ noop_headless = false source = "investigation" confidence = "high" depends_on = [0x223] -evidence = "Ghidra handler 0x423310 -> gfx_object_clone@0x47e4f0. Worker requires source in ctx+0x408 map, creates destination, copies exactly 0xb5 dwords from source object record, then marks ctx+0xb558 dirty. SC0000 site 0x128fc passes current handle and handle+1 before queueing 0x223 at 0x129e7." +evidence = "Ghidra handler 0x423310 -> gfx_object_clone@0x47e4f0. Worker requires source in retained-gfx owner+0x408 (EngineCtx+0x46a1c), creates destination, copies exactly 0xb5 dwords from source object record, then marks owner+0xb558 (EngineCtx+0x51b6c) dirty. SC0000 site 0x128fc passes current handle and handle+1 before queueing 0x223 at 0x129e7." [[opcode.semantics.args]] i = 1 @@ -5521,12 +5524,12 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "set-anim-transform-norm" category = "draw" -summary = "(handle)(delay_ms)(duration_ms)(sx)(sy)(sz) — set the normalized SCALE-matrix channel (100=identity). Target obj+0xac is linearly sampled from current obj+0x6c by gfx_object_apply_transform_channels@0x472f00 on frame-time ctx+0xb550, after delay and for duration, then committed. Shares only start timestamp obj+0x34 with op 0x220; neither Z is opacity." +summary = "(handle)(delay_ms)(duration_ms)(sx)(sy)(sz) — set the normalized SCALE-matrix channel (100=identity). Target obj+0xac is linearly sampled from current obj+0x6c by gfx_object_apply_transform_channels@0x472f00 on retained-gfx frame time owner+0xb550 (EngineCtx+0x51b64), after delay and for duration, then committed. Shares only start timestamp obj+0x34 with op 0x220; neither Z is opacity." noop_headless = false source = "investigation" confidence = "high" depends_on = [] -evidence = "Ghidra 0x47eaa0 calls matrix builder 0x48af1d for target obj+0xac. Consumer 0x472f00 uses delay obj+0x3c, duration obj+0x50, current obj+0x6c, target obj+0xac, shared start obj+0x34, and frame-time ctx+0xb550." +evidence = "Ghidra 0x47eaa0 calls matrix builder 0x48af1d for target obj+0xac. Consumer 0x472f00 uses delay obj+0x3c, duration obj+0x50, current obj+0x6c, target obj+0xac, shared start obj+0x34, and retained-gfx frame time owner+0xb550 (EngineCtx+0x51b64)." [[opcode.semantics.args]] i = 1 @@ -5618,12 +5621,12 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "set-anim-transform-abs" category = "draw" -summary = "(handle)(delay_ms)(duration_ms)(tx)(ty)(tz) — set the absolute TRANSLATION-matrix channel. Target obj+0x1ac is linearly sampled from current obj+0x16c by gfx_object_apply_transform_channels@0x472f00 on frame-time ctx+0xb550, after delay and for duration, then committed. Independent of op 0x21e scale; neither Z is opacity." +summary = "(handle)(delay_ms)(duration_ms)(tx)(ty)(tz) — set the absolute TRANSLATION-matrix channel. Target obj+0x1ac is linearly sampled from current obj+0x16c by gfx_object_apply_transform_channels@0x472f00 on retained-gfx frame time owner+0xb550 (EngineCtx+0x51b64), after delay and for duration, then committed. Independent of op 0x21e scale; neither Z is opacity." noop_headless = false source = "investigation" confidence = "high" depends_on = [] -evidence = "Ghidra 0x47ecc0 calls matrix builder 0x48afb1 for target obj+0x1ac. Consumer 0x472f00 uses delay obj+0x44, duration obj+0x58, current obj+0x16c, target obj+0x1ac, shared start obj+0x34, and frame-time ctx+0xb550." +evidence = "Ghidra 0x47ecc0 calls matrix builder 0x48afb1 for target obj+0x1ac. Consumer 0x472f00 uses delay obj+0x44, duration obj+0x58, current obj+0x16c, target obj+0x1ac, shared start obj+0x34, and retained-gfx frame time owner+0xb550 (EngineCtx+0x51b64)." [[opcode.semantics.args]] i = 1 @@ -5695,7 +5698,7 @@ noop_headless = false source = "investigation" confidence = "high" depends_on = [0x20c, 0x21c, 0x1c7, 0x1cc] -evidence = "Ghidra handler 0x423620 -> gfx_queue_surface_alpha_transition@0x47f440. Record fields: type +0=0, start +4=0, delay +8=arg7, duration +0xc=arg8, slot +0x10=arg2, range A +0x14/+0x1c=args3/4, range B +0x18/+0x20=args5/6. gfx_render_frame@0x47fbc0 initializes start from ctx+0xb550 and consumes type 0 as an alpha ramp. SC0000 executes one shared-helper site at 0x129e7." +evidence = "Ghidra handler 0x423620 -> gfx_queue_surface_alpha_transition@0x47f440. Record fields: type +0=0, start +4=0, delay +8=arg7, duration +0xc=arg8, slot +0x10=arg2, range A +0x14/+0x1c=args3/4, range B +0x18/+0x20=args5/6. gfx_render_frame@0x47fbc0 initializes start from retained-gfx owner+0xb550 (EngineCtx+0x51b64) and consumes type 0 as an alpha ramp. SC0000 executes one shared-helper site at 0x129e7." [[opcode.semantics.args]] i = 1 @@ -6127,7 +6130,7 @@ noop_headless = false source = "investigation" confidence = "high" depends_on = [] -evidence = "Ghidra handler 0x423da0 converts axis ints to floats -> worker 0x47f060. gfx_object_anim_interpolate@0x473ed0 consumes obj+0x228/+0x214/+0x244 on ctx+0xb550 and matrix4_make_axis_angle@0x48b215. gfx_object_composite@0x47f650 calls one-shot transform first, cyclic animation second." +evidence = "Ghidra handler 0x423da0 converts axis ints to floats -> worker 0x47f060. gfx_object_anim_interpolate@0x473ed0 consumes obj+0x228/+0x214/+0x244 on retained-gfx owner+0xb550 (EngineCtx+0x51b64) and matrix4_make_axis_angle@0x48b215. gfx_object_composite@0x47f650 calls one-shot transform first, cyclic animation second." [[opcode.semantics.args]] i = 1 @@ -6353,19 +6356,20 @@ evidence = "" [[opcode]] op = 0x23d -label = "u004162F0" +label = "release-transient-surfaces" argc = 0 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u004162F0" -category = "unknown" -summary = "" +name = "release-transient-surfaces" +category = "draw" +summary = "Stop movie bindings and release transient gfx surface slots 42 through 999 inclusive, preserving system-owned slots 0 through 41." +details = "For each fixed slot in [42,1000), the handler stops/releases the movie-to-texture object at ctx+0x52bd4[slot], then invokes the ordinary retained-gfx surface-release worker. Protected/externally owned slots may be retained by the worker's per-slot guard. This is the resource half of the common 0x1f6/0x23d full-reset sequence." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x23d_release_transient_surfaces@0x4175c0 loops EDI=42 while EDI<1000, stops/releases ctx+0x52bd4[EDI] movie objects, and calls retained_gfx_release_surface@0x474e40 on owner ctx+0x46614. DATA1: 149 calls in 146 scripts; 146 directly follow 0x1f6." [[opcode]] op = 0x23f