From d6d1be984e1bbae4a1454e5f23328c41fc6da5e4 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 10 Jul 2026 18:49:53 -0400 Subject: [PATCH] Implement ADV foreground transition lifecycle --- docs/engine-ctx-reference.md | 1 + docs/engine-re.md | 34 +++- docs/opcode-reference.md | 43 +++-- docs/phase-a-slice-plan.md | 44 +++++ docs/tools-reference.md | 1 + .../ForegroundTransitionTests.cs | 77 +++++++++ .../RenderObjectBlendTests.cs | 21 +++ engine/Age.Engine.Tests/TestSupport.cs | 18 +++ engine/Age.Engine/Hosting/IHost.cs | 10 ++ engine/Age.Engine/Model/GfxState.cs | 153 +++++++++++++++++- engine/Age.Engine/Vm/VirtualMachine.cs | 19 ++- godot/GodotAdvHost.cs | 65 +++++++- godot/GodotTimelineLog.cs | 2 +- godot/Main.cs | 55 ++++++- vm-map/engine-ctx.toml | 5 + vm-map/opcodes.toml | 49 +++--- 16 files changed, 543 insertions(+), 54 deletions(-) create mode 100644 engine/Age.Engine.Tests/ForegroundTransitionTests.cs diff --git a/docs/engine-ctx-reference.md b/docs/engine-ctx-reference.md index a7412fc..7e2de26 100644 --- a/docs/engine-ctx-reference.md +++ b/docs/engine-ctx-reference.md @@ -36,6 +36,7 @@ Struct `EngineCtx`, size `0xa1000`. Applied to the Ghidra `/v2` image (dispatch- | `0x6db28` | `coroutine_yield_b` | `void*` | op 0x7b yield-state save (op2 -> +ctxidx*4) | | `0x6dbc8` | `coroutine_runstate` | `int` | op 0x7c resume gate (run-state bit 0x2000000) | | `0x6dbcc` | `coroutine_resume_off` | `int` | op 0x7c resume PC offset | +| `0x6dbd4` | `adv_read_skip_state` | `int` | current ADV read/click-skip service state; queried by op 0x1cc, maintained from ReadTextSkip + per-PC read history | | `0x9b24c` | `dispatch_table` | `void*` | opcode->handler table base [0x400]; handler(op) = *(0x9b24c + op*4) | | `0xa0cc0` | `screen_w` | `int` | screen width (640) | | `0xa0cc4` | `screen_h` | `int` | screen height (480) | diff --git a/docs/engine-re.md b/docs/engine-re.md index 2602ff8..ee893f8 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -515,9 +515,10 @@ Op `0x234` is the independent rotation cycle above. Op `0x238` still configures ##### `label_1235a` animation-section boundary (2026-07-10) The section helper computes the maximum configured duration and arms it with `0x238`, then reads -message-skip through `0x1c7` and ADV service state through `0x1cc`. Normal playback (both zero) -executes `0x243` (reset the separate global animation-service clock) followed by `0x20c` present; -the skip/service branch executes `0x21c` (set run-state bit `0x400`). Both branches finish with +message-skip through `0x1c7` and ADV read/click-skip state through `0x1cc`. The raw `jcc` order matters: +normal playback (both zero) branches to `0x21c` (set run-state bit `0x400`), while a nonzero skip/read +state executes `0x243` (reset the separate global animation-service clock) followed by `0x20c` present. +Both branches finish with `0x224`, which clears the native gfx command queue at `ctx+0x418`. These handlers are now named, commented, and saved in the Ghidra image. @@ -805,8 +806,33 @@ into the target and ramps alpha 0→1. Its SC0000 site `0x129e7` passes `(handle handle+1,1,handle,1,G[0x6249f],G[0x624a0])`. It remains a render-target/transition slice dependency rather than being approximated in the affine object compositor. +#### ADV foreground surface-transition lifecycle (2026-07-10) + +The completed native chain is `0x21d` snapshot -> `0x223` queue -> `label_1235a` skip queries -> +`0x21c` normal yield or `0x20c` skip endpoint-present: + +- `gfx_object_clone` (`0x47e4f0`, op `0x21d` handler `0x423310`) copies the complete retained-object + record: exactly `0xb5` dwords / `0x2d4` bytes. SC0000 `0x128fc` clones the current CG handle to + `handle+1`; the loader then rebinds the source handle to the new CG, and `0x223 @ 0x129e7` uses + `handle+1` as old range A, the updated handle as new range B, and `handle+2` as the target presenter. +- `0x1c7` is the `run_state_flags & 0x08000000` message-skip query. `0x1cc` reads + `ctx+0x6dbd4`, now named `adv_read_skip_state`; `adv_refresh_read_skip_state` (`0x406cd0`) and the + text/label/wait handlers maintain it from `message_ReadTextSkip` plus per-PC read history. It is not + surface-transition progress. +- Zero OR-state is normal playback and reaches `0x21c`; run-state bit `0x400` parks interpreter + progression while the type-0 surface command advances from the frame clock. Nonzero skip/read state + reaches `0x243 + 0x20c`, exposing the completed endpoint without the normal wait. +- Op `0x203` stores operand 2 at object `+0x30`. Transition sources use mode 2; their common + `0xffffffff` color is opaque identity modulation, not a solid-white tint. Negative alpha/RGB operands + separately preserve the corresponding byte(s) of current static color via the `FUN_0047f3e0` lookup. + +The port mirrors this with an explicit pending/start/progress/natural-or-forced-complete state. A click +while `0x21c` is parked completes only the active foreground surface transition and is consumed; it does +not pre-arm the following `wait-for-input`, and it does not complete independent retained rotation, +matrix, spritesheet, or color-animation channels. + **Still deferred:** `0x236` (`gfx_op_0x236` @`0x423ee0`) a -**timed/animated-surface (movie-like) op**; plus the unclassified `0x21c/0x21d/0x224/0x242/0x23d/0x20a/0x20e/0x243` +**timed/animated-surface (movie-like) op**; plus the unclassified `0x242/0x23d/0x20a/0x20e` tail (2-arg flags / inline). These stay GAP until a follow-up slice or are safe-noop'd if the opening tolerates it. ### Grey-background root cause — slot collision + tint-strength (2026-07-08, gfx-log) diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 35252dc..52dc1b9 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -79,15 +79,20 @@ Native handler sleep_op_0xc8 @0x420ec0 is NON-BLOCKING: it arms a timer (sleep_t - **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. -### 0x1cc `get-adv-service-state` (get-adv-service-state, argc 1) -- **summary:** (out) - copy native ADV service state ctx+0x6dbd4; label_1235a ORs it with message-skip to select its yield branch. +### 0x1cc `get-adv-read-skip-state` (get-adv-read-skip-state, argc 1) +- **summary:** (out) - copy the current ADV read/click-skip service state from ctx+0x6dbd4. label_1235a ORs it with 0x1c7's Ctrl/message-skip bit: zero takes 0x21c's normal transition/yield path; nonzero resets the animation service and presents the completed endpoint through 0x20c. - **grounding:** source=investigation, confidence=high -- **evidence:** Ghidra handler 0x427330 calls vm_operand_write(1, ctx+0x6dbd4). Exact service-state producer remains outside this opcode. +- **depended on by:** 0x20c, 0x20d, 0x21c, 0x223 +- **evidence:** Ghidra handler 0x427330 calls vm_operand_write(1, ctx+0x6dbd4). Producer recovered 2026-07-10: adv_update_read_text_skip_state@0x406cd0 and op 0x6e/0x71/0x72 maintain the field from message_ReadTextSkip plus current-PC read-history lookup; adv_interpreter_tick consumes it in click/read-skip control. It is not the 0x223 surface-transition progress flag. ### 0x21c `mark-frame-yield` (mark-frame-yield, argc 0) -- **summary:** Set native run-state bit 0x400. Host-implicit: the port already offers a scheduler yield after every completed opcode. -- **grounding:** source=investigation, confidence=high, noop_headless=True -- **evidence:** Ghidra handler 0x417520 sets cmd-type 1 and ORs ctx+0xa0ce4 with 0x400. SC0000 label_1235a reaches it only when op 0x1c7 or 0x1cc is nonzero. +- **summary:** Set native run-state bit 0x400; in normal ADV playback this is the queued foreground-transition yield/resume boundary. +- **grounding:** source=investigation, confidence=high +- **depends on:** 0x223, 0x1c7, 0x1cc +- **depended on by:** 0x223 +- **evidence:** Ghidra handler 0x417520 sets cmd-type 1 and ORs ctx+0xa0ce4 with 0x400. SC0000 label_1235a's jcc reaches it when the OR of op 0x1c7/0x1cc is zero; synchronized port trace confirmed this is the normal path after 0x223. + +SC0000 label_1235a reaches this when the OR of 0x1c7 message-skip and 0x1cc read-skip state is zero (normal playback). Native run-state bit 0x400 yields the interpreter while the queued foreground presentation advances. The interactive port starts pending 0x223 commands here, parks only the VM thread while per-frame compositing continues, and resumes after natural or click-forced completion. ## draw @@ -132,9 +137,9 @@ Native handler sleep_op_0xc8 @0x420ec0 is NON-BLOCKING: it arms a timer (sleep_t - **evidence:** Ghidra handler 0x4228d0 (dispatch ctx[0x26c93+0x202]); packs (alpha<<24|rgb) from operands 4/5, FUN_0047ea00(op1,op2,op3,packed). ### 0x203 `gfx-draw-color` (gfx-draw-color, argc 4) -- **summary:** 0x203 (handle)(v)(alpha)(color) — gfx cmd-type 9. Handler gfx_op_0x203_draw_color @0x4229a0: worker gfx_op_0x203_worker_set_color @0x47e9b0 sets a STATIC color/alpha (obj+0x60), no anim bit; packs ARGB from alpha(op3)/color(op4). Sibling of 0x202 (one fewer positional arg). C# VM (2026-07-08 blend slice): routes through GfxState.SetObjectColor → compositor applies static alpha/tint (BlendKind.Alpha). See docs/engine-re.md §Blend & transparency. +- **summary:** 0x203 (handle)(mode)(alpha)(color) — gfx cmd-type 9. Worker stores mode at obj+0x30 and STATIC packed color at obj+0x60. Negative alpha/RGB preserve current static bytes through FUN_0047f3e0. Mode 2 is the 0x223 transition-source alpha/identity path (0xffffffff = opaque identity, not solid white); mode 0 retains the established tint-strength behavior. - **grounding:** source=investigation, confidence=high -- **evidence:** Ghidra handler 0x4229a0 (dispatch ctx[0x26c93+0x203]); packs color from operands 3/4, FUN_0047e9b0(op1,op2,packed). +- **evidence:** Ghidra handler 0x4229a0 (dispatch ctx[0x26c93+0x203]); negative operands read current obj+0x60 via FUN_0047f3e0, then gfx_op_0x203_worker_set_color stores op2 at obj+0x30 and packed ARGB at obj+0x60. SC0000 transition setup uses mode 2 with -1/-1 before 0x21d/0x223; synchronized window pixels prove treating 0xffffffff as generic white tint is wrong. ### 0x208 `get-texture-size` (get-texture-size, argc 3) - **summary:** 0x208 (slot)(out_w)(out_h) — writes the loaded texture's width/height into two output globals; keystone for bytecode-computed sprite/bg geometry (SC0000 label_12649) @@ -142,11 +147,13 @@ Native handler sleep_op_0xc8 @0x420ec0 is NON-BLOCKING: it arms a timer (sleep_t - **evidence:** SC0000 label_12649: set-texture(resId,slot) then 0x208(slot)->w,h feeds w/2 horizontal-center + foot-anchor subtraction into draw-texture dst; stubbing yields 0x0 sizes / off-center draws ### 0x20c `present-frame` (present-frame, argc 0) -- **summary:** Present the composited frame (native gfx_render_frame). Host-implicit: our compositor presents every frame. -- **grounding:** source=investigation, confidence=high, noop_headless=True +- **summary:** Present the composited frame; label_1235a uses this on the read/message-skip branch to expose the completed foreground endpoint immediately. +- **grounding:** source=investigation, confidence=high +- **depends on:** 0x223, 0x1c7, 0x1cc +- **depended on by:** 0x20d, 0x223 - **evidence:** Ghidra: dispatch table FUN_00413860 param_1[0x26e9f]=gfx_op_0x20c_present_frame; 0x26e9f-0x26c93=0x20c. 2026-07-08. -Native handler gfx_op_0x20c_present_frame (dispatch ctx[0x26c93+0x20c]) -> gfx_render_frame @0x4820b0 flips the composited buffers. Our Godot host runs a continuous per-frame compositor (Main.Recomposite in _Process), so an explicit present is redundant and the VM can skip it. noop_headless=true -> scene coverage classifies it safe-noop. Kelebek label u00416200 was VA-drift (unrelated fn); real handler resolved via the dispatch table. +Native handler gfx_op_0x20c_present_frame (dispatch ctx[0x26c93+0x20c]) -> gfx_render_frame @0x4820b0. Godot composites continuously. In label_1235a the OR of 0x1c7/0x1cc is nonzero on the read/message-skip branch, which resets the animation service then presents; the port starts and snaps any pending 0x223 transition to its endpoint here. Normal zero-state playback branches to 0x21c, which owns wait/resume. Headless hosts remain non-blocking. Kelebek label u00416200 was VA-drift. ### 0x212 `set-gfx-field64` (set-gfx-field64, argc 2) - **summary:** 0x212 (obj_idx)(val) — gfx cmd-type 5. Handler gfx_op_0x212_set_field64 @0x4230c0: obj=[ctx+0x14d54 + obj_idx*4]; if obj: *(obj+0x64)=val. Sets one per-object field. See docs/engine-re.md gfx op-contract table. @@ -188,6 +195,12 @@ Native handler gfx_op_0x20c_present_frame (dispatch ctx[0x26c93+0x20c]) -> gfx_r - **grounding:** source=investigation, confidence=high - **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]. +### 0x21d `clone-gfx-object` (clone-gfx-object, argc 2) +- **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. + ### 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. - **grounding:** source=investigation, confidence=high @@ -206,6 +219,8 @@ Native handler gfx_op_0x20c_present_frame (dispatch ctx[0x26c93+0x20c]) -> gfx_r ### 0x223 `queue-surface-alpha-transition` (queue-surface-alpha-transition, argc 8) - **summary:** (command_key)(target_slot)(range_a_start)(range_a_count)(range_b_start)(range_b_count)(delay_ms)(duration_ms) — queue a type-0 timed alpha transition command in the separate ctx+0x414 command map. This is render-target/surface presentation state, not an object affine matrix. The render frame composites the two handle ranges into target_slot and ramps alpha 0->1 after delay over duration. - **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. ### 0x224 `clear-gfx-command-queue` (clear-gfx-command-queue, argc 0) @@ -286,6 +301,7 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it " ### 0x1c7 `get-message-skip` (get-message-skip, argc 1) - **summary:** (out) - write 1 iff ADV message-skip run-state bit 0x08000000 is set, otherwise 0. - **grounding:** source=investigation, confidence=high +- **depended on by:** 0x20c, 0x20d, 0x21c, 0x223 - **evidence:** Ghidra handler 0x4272b0 reads ctx+0xa0ce4 bit 0x08000000 and vm_operand_write(1, 1|0). SC0000 label_1235a ORs it with op 0x1cc. ## marker @@ -988,6 +1004,7 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it " ### 0x20d `u00420E10` (u00420E10, argc 1) - **summary:** — - **grounding:** source=kelebek, confidence=low +- **depends on:** 0x20c, 0x1c7, 0x1cc ### 0x20e `u00416250` (u00416250, argc 0) - **summary:** — @@ -997,10 +1014,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 -### 0x21d `u00421410` (u00421410, argc 2) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x222 `u004216C0` (u004216C0, argc 2) - **summary:** — - **grounding:** source=kelebek, confidence=low diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index a1c3eae..02b5b09 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -895,3 +895,47 @@ and composite call order; named useful parameters; saved `/v2`. apart from the pre-existing nullable warning and threaded `SELFTEST OK`; opcode tooling and focused Python tests clean; transform tool compiles; SC0000 coverage **81/129 handled (62.8%)**, 48 GAP ops / 602 GAP instructions; windowed affine capture clean. Final whitespace/diff validation is recorded with the handoff. + +### A2b — ADV foreground transition/click lifecycle ✅ DONE (2026-07-10) + +**Native recovery and control-flow correction.** `0x1c7` is the message-skip run-state query. +`0x1cc` reads `ctx+0x6dbd4`, now `adv_read_skip_state`; `adv_refresh_read_skip_state@0x406cd0` plus the +text/label/wait handlers maintain it from `message_ReadTextSkip` and the current-PC read-history lookup. +It is not surface progress. The earlier reading of `label_1235a`'s branch was reversed: zero OR-state +(normal playback) reaches `0x21c` and sets scheduler bit `0x400`; nonzero skip/read state reaches +`0x243 + 0x20c` to present the endpoint. Synchronized execution caught and corrected this before validation. + +The executed missing producer for `0x223` range A was `0x21d`: `gfx_object_clone@0x47e4f0` copies the full +`0x2d4`-byte retained-object record from the current CG handle to `handle+1`. SC0000 then rebinds the source +handle to the new CG and queues `0x223` with old clone range A, new source range B, and target presenter +`handle+2`. Op `0x203` mode 2 is the companion transition-source alpha/identity path; its `0xffffffff` +is opaque identity, not generic solid-white tint, and negative alpha/RGB preserve current static bytes. + +**Port lifecycle.** `GfxState` owns explicit queue/start/wall-clock progress/natural-or-forced completion. +Normal `0x21c` parks only the VM thread until the foreground command completes while Godot continues +per-frame compositing. A click during that wait forces only the foreground transition to progress 1, is +consumed, and resumes the VM; stable `wait-for-input` remains a separate gate. Tests verify that forcing a +transition leaves an independent cyclic retained rotation active. The `0x20c` skip/read path starts and +snaps a pending foreground transition to its endpoint without the normal wait. + +**Synchronized window evidence.** At normal speed, the second-to-third CG transition produced seven +successive distinct PNG hashes while progress advanced `0.133, 0.297, 0.442, 0.592, 0.742, 0.891, 1.000`; +the user independently observed that fade working in the live window. A timed click landed at progress +`0.157`, the next frame was the endpoint, and the stable page count did not advance. The accelerated +windowed run reached `BGM008 @ 0x1728` on frame 137 with non-white changing pixels (2,469 sampled colors at +the sentinel) and continued; its earlier white interval was finite and released before that boundary. +This is windowed pixel/object/timeline evidence, not the earlier headless reachability inference. + +The retained dialogue-text surface `0xe678` still has no pixels because native `draw-string 0x204` remains +a separate GAP; the Godot `Label` shortcut remains outside this slice. Its create/erase events are explicit +in the object timeline and are not being mistaken for foreground CG transition correctness. + +**Ghidra.** Renamed/commented `adv_refresh_read_skip_state` (`0x406cd0`), +`op_0x1cc_get_adv_read_skip_state` (`0x427330`), `op_0x21d_clone_gfx_object` (`0x423310`), and +`gfx_object_clone` (`0x47e4f0`); added `EngineCtx.adv_read_skip_state`; saved `/v2`. + +**Validation:** engine **102/102**; full sweep unchanged at **284 exit / 13 STEP-LIMIT**; Godot build clean +apart from the pre-existing nullable warning and threaded `SELFTEST OK`; all six Python test scripts, +opcode/ctx lint, 481-script decode validation, RECOVER, and Windows CR-aware `git diff --check` clean. +The plain check reports only the generated reference's CRLF on its newly added row. SC0000 coverage is +**85/129 handled (65.9%)**, 44 GAP ops / 598 GAP instructions. No commit was made. diff --git a/docs/tools-reference.md b/docs/tools-reference.md index 55d7358..ebdce29 100644 --- a/docs/tools-reference.md +++ b/docs/tools-reference.md @@ -139,6 +139,7 @@ texture ops (no GPU context) — run windowed for real scenes. User args (after - `--gfx-log ` — **compositor + op diagnostic** (the tool that root-caused the grey background). Logs, per rendered frame, only the objects whose draw outcome **CHANGED** (drawn↔skip↔gone, resId, resolved file, `slot`, `src`/`dst`, `op`acity, `tintStr`ength) — quiet until something actually changes, so the exact frame a layer drops out (and why) stands out. Also traces every `set-texture`/`create-texture` **slot assignment** (via `GodotAdvHost.TraceOps`). Works live or with `--shot-sequence`. Use it before theorising about layering/blend/geometry: it showed the grey BG = the slot-selecting globals resolving to 0 → every texture collapsing into slot 0 (see engine-re.md §"Grey-background root cause"). e.g. `godot --path godot -- --boot --gfx-log out/gfx.log` then click to the bad page. Matrix-channel outcomes also include `base`, `anchor`, projected `dst`, sampled `scale`/`trans`, and one-shot-plus-cyclic `rot`ation angles. Parent directories are created automatically. +- `--transition-click-ms ` — diagnostic-only input injector: after a foreground transition has been active for `n` virtual milliseconds, send one click through the real input lifecycle. The click completes/consumes the transition and does not advance a stable page. Use with `--timeline-log`, `--gfx-log`, and windowed `--shot-sequence`; omit for normal play. - `--timeline-log ` — diagnostic-only synchronized event stream for a real Godot run. Records every executed script byte offset/opcode, virtual time/frame, VM state changes (`running`, `sleep`, `input-wait`, `halted`), BGM events, and changed visible-object compositor outcomes in one ordered JSONL file. Combine with diff --git a/engine/Age.Engine.Tests/ForegroundTransitionTests.cs b/engine/Age.Engine.Tests/ForegroundTransitionTests.cs new file mode 100644 index 0000000..8082311 --- /dev/null +++ b/engine/Age.Engine.Tests/ForegroundTransitionTests.cs @@ -0,0 +1,77 @@ +using System.Collections.Generic; +using System.Linq; +using Age.Engine.Model; +using Age.Engine.Sys4; +using Age.Engine.Vm; +using Xunit; + +public class ForegroundTransitionTests +{ + 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); + + [Fact] + public void SurfaceTransition_HasStartDelayProgressNaturalAndForcedCompletion() + { + var gfx = new GfxState(); + gfx.SetSurface(4, 0x25, -1); + gfx.BindDraw(100, 4, 0, 0, 800, 600, 0, 0); + gfx.SetRotationCycle(100, 1000, (0, 0, 1)); // ambient, deliberately independent + gfx.QueueSurfaceAlphaTransition(102, 6, 101, 1, 100, 1, 100, 200); + + Assert.Equal(1, gfx.StartForegroundTransitions(1000)); + Assert.Equal(0.0, gfx.SnapshotForegroundTransitions(1050).Single().Progress); + Assert.Equal(0.25, gfx.SnapshotForegroundTransitions(1150).Single().Progress, 3); + Assert.False(gfx.HasActiveForegroundTransitions(1300)); + Assert.True(gfx.TryGet(100)!.RotationEnabled); + + gfx.QueueSurfaceAlphaTransition(202, 7, 201, 1, 200, 1, 0, 500); + gfx.StartForegroundTransitions(2000); + Assert.True(gfx.HasActiveForegroundTransitions(2100)); + Assert.Equal(1, gfx.CompleteForegroundTransitions(2100)); + var forced = gfx.SnapshotForegroundTransitions(2100).Single(t => t.TargetSlot == 7); + Assert.True(forced.Forced); + Assert.Equal(1.0, forced.Progress); + Assert.True(gfx.TryGet(100)!.RotationEnabled); // transition click did not finish ambient animation + } + + [Fact] + public void CloneObject_SnapshotsOldSurfaceBindingForTransitionRangeA() + { + var gfx = new GfxState(); + gfx.SetSurface(4, 0x25, -1); + gfx.BindDraw(100, 4, 0, 0, 800, 600, 0, 0); + gfx.SetStaticObjectColorResolved(100, 2, -1, -1); + Assert.True(gfx.CloneObject(100, 101)); + gfx.SetSurface(5, 0x27, -1); + gfx.BindDraw(100, 5, 0, 0, 800, 600, 0, 0); + + var snapshot = gfx.SnapshotVisibleObjects(); + Assert.Equal(0x27, snapshot.Single(x => x.Handle == 100).SurfaceResId); + Assert.Equal(0x25, snapshot.Single(x => x.Handle == 101).SurfaceResId); + } + + [Fact] + public void VmDispatchesSkipQueriesQueueAndPresentResumeBoundary() + { + var table = T(); + var scene = ScriptAssembler.Assemble(table, "FOREGROUND", new List<(int, Operand[])> + { + (0x1c7, new[] { G(1) }), + (0x1cc, new[] { G(2) }), + (0x223, new[] { I(102), I(6), I(101), I(1), I(100), I(1), I(50), I(200) }), + (0x21c, System.Array.Empty()), + (0x2, System.Array.Empty()), + }, System.Array.Empty()); + var host = new RecordingHost { MessageSkip = true, AdvReadSkip = true }; + var vm = new VirtualMachine(scene, table, host); + + vm.Run(); + + Assert.Equal(1, vm.Globals[1]); + Assert.Equal(1, vm.Globals[2]); + Assert.Equal(1, host.TransitionWaits); + Assert.Equal(1.0, vm.Gfx.SnapshotForegroundTransitions(100).Single().Progress); + } +} diff --git a/engine/Age.Engine.Tests/RenderObjectBlendTests.cs b/engine/Age.Engine.Tests/RenderObjectBlendTests.cs index 9b22b1c..43f56dc 100644 --- a/engine/Age.Engine.Tests/RenderObjectBlendTests.cs +++ b/engine/Age.Engine.Tests/RenderObjectBlendTests.cs @@ -4,6 +4,27 @@ using Xunit; public class RenderObjectBlendTests { + [Fact] + public void NegativeColorOperands_PreserveCurrentStaticColor() + { + var g = new GfxState(); + g.SetObjectColor(0x100, GfxState.PackColor(0, 0xffffff)); + g.SetObjectColorResolved(0x100, -1, -1); + Assert.Equal(GfxState.PackColor(0, 0xffffff), g.TryGet(0x100)!.Color); + } + + [Fact] + public void TransitionColorMode_TreatsWhiteAsIdentityAndAlphaAsOpacity() + { + var g = new GfxState(); + g.SetSurface(4, 1, -1); + g.BindDraw(0x100, 4, 0, 0, 1, 1, 0, 0); + g.SetStaticObjectColorResolved(0x100, 2, -1, -1); + var rendered = g.SnapshotVisibleObjects().Single(); + Assert.Equal(255, rendered.Alpha); + Assert.Equal(0, rendered.TintStrength); + } + // Make a visible textured object bound to a slot that has a surface, so it appears in the snapshot. private static GfxState WithVisibleObject(long handle, long resId, long colorKey) { diff --git a/engine/Age.Engine.Tests/TestSupport.cs b/engine/Age.Engine.Tests/TestSupport.cs index f0be7ec..ab9fc1c 100644 --- a/engine/Age.Engine.Tests/TestSupport.cs +++ b/engine/Age.Engine.Tests/TestSupport.cs @@ -9,12 +9,30 @@ using Age.Engine.Model; internal sealed class RecordingHost : IHost { public int Waits; + public int Presents; + public int TransitionWaits; + public bool MessageSkip; + public bool AdvReadSkip; public readonly List<(int Offset, string Text)> Lines = new(); public readonly List SleptDurations = new(); public void ShowText(int offset, string text) => Lines.Add((offset, text)); public void WaitForInput() => Waits++; public void Sleep(long duration) => SleptDurations.Add(duration); public void FrameYield() { } + public bool IsMessageSkipActive => MessageSkip; + public bool IsAdvReadSkipActive => AdvReadSkip; + public void PresentFrame(GfxState gfx) + { + Presents++; + gfx.StartForegroundTransitions(100); + gfx.CompleteForegroundTransitions(100); + } + public void WaitForForegroundTransition(GfxState gfx) + { + TransitionWaits++; + gfx.StartForegroundTransitions(100); + gfx.CompleteForegroundTransitions(100); + } public void CreateTexture(int slot, int w, int h) { } public void SetTexture(long resId, int slot) { } public void DrawTexture(int slot, int sx, int sy, int w, int h, int dx, int dy) { } diff --git a/engine/Age.Engine/Hosting/IHost.cs b/engine/Age.Engine/Hosting/IHost.cs index d50e128..cbe5cfc 100644 --- a/engine/Age.Engine/Hosting/IHost.cs +++ b/engine/Age.Engine/Hosting/IHost.cs @@ -1,3 +1,5 @@ +using Age.Engine.Model; + namespace Age.Engine.Hosting; public interface IHost { @@ -5,6 +7,14 @@ public interface IHost void WaitForInput(); void Sleep(long duration); void FrameYield(); + // Native 0x1c7/0x1cc query two distinct ADV skip channels. Headless and non-interactive + // hosts default to normal playback; the Godot host supplies the live interactive values. + bool IsMessageSkipActive => false; + bool IsAdvReadSkipActive => false; + // Normal playback reaches op 0x21c and parks until a queued 0x223 transition completes. The + // read/message-skip branch reaches op 0x20c and presents the completed endpoint immediately. + void WaitForForegroundTransition(GfxState gfx) { } + void PresentFrame(GfxState gfx) { } void CreateTexture(int slot, int width, int height); void SetTexture(long resourceId, int slot); void DrawTexture(int slot, int srcX, int srcY, int width, int height, int dstX, int dstY); diff --git a/engine/Age.Engine/Model/GfxState.cs b/engine/Age.Engine/Model/GfxState.cs index 856c566..5e5a7b3 100644 --- a/engine/Age.Engine/Model/GfxState.cs +++ b/engine/Age.Engine/Model/GfxState.cs @@ -14,6 +14,12 @@ public readonly record struct RotationCycleState(bool Enabled, long PeriodMs, double AxisX, double AxisY, double AxisZ, double AngleDegrees = 0); +/// Sampled op-0x223 type-0 surface transition. Range A is already present in normal z-order; +/// the compositor draws range B over it with to form the native crossfade. +public readonly record struct SurfaceTransitionState(long CommandKey, int TargetSlot, + long RangeAStart, int RangeACount, long RangeBStart, int RangeBCount, + long DelayMs, long DurationMs, long StartMs, double Progress, bool Forced); + /// A renderable view of one visible gfx object — the host composites these in ascending-handle order /// (= the engine's z-order) each frame. Built by ; the surface /// resId/colorkey are resolved from the object's live source slot at snapshot time (see docs/engine-re.md, @@ -25,7 +31,8 @@ public readonly record struct RotationCycleState(bool Enabled, long PeriodMs, public readonly record struct RenderObject(long Handle, long SurfaceResId, long ColorKey, int SrcX, int SrcY, int W, int H, int DstX, int DstY, TransformState Transform, RotationCycleState Rotation, - int Alpha, long Tint, int TintStrength, BlendKind Blend); + int Alpha, long Tint, int TintStrength, BlendKind Blend, + SurfaceTransitionState? SurfaceTransition = null); /// 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 @@ -35,12 +42,20 @@ public readonly record struct RenderObject(long Handle, long SurfaceResId, long /// the query ops read back, which is all the bytecode geometry math needs. public sealed class GfxState { + private sealed class SurfaceTransition + { + public long CommandKey, RangeAStart, RangeBStart, DelayMs, DurationMs; + public int TargetSlot, RangeACount, RangeBCount; + public long StartMs = -1; + public bool Forced; + } public sealed class GfxObject { public (long X, long Y, long Z) V18, V24, V16c; public long Field64, Field68, Field6c; public long Color; public bool HasColor; // true once op 0x202/0x203 set a color/alpha modulation on this object + public long StaticColorMode; // op 0x203 operand 2 -> obj+0x30; mode 2 is transition alpha/identity // ---- src-rect / spritesheet-cell channel (ops 0x239 static cell, 0x231 animate). Interpolator // SRC-RECT SCROLL channel: period obj+0x230, start obj+0x21c, grid obj+0x238/0x23c. ---- @@ -115,6 +130,37 @@ public sealed class GfxState } } + /// Op 0x21d: clone the native 0x2d4-byte retained-object record from source to destination. + public bool CloneObject(long sourceHandle, long destinationHandle) + { + lock (_lock) + { + if (!_objects.TryGetValue(sourceHandle, out var s)) return false; + _objects[destinationHandle] = new GfxObject + { + V18 = s.V18, V24 = s.V24, V16c = s.V16c, + Field64 = s.Field64, Field68 = s.Field68, Field6c = s.Field6c, + Color = s.Color, HasColor = s.HasColor, StaticColorMode = s.StaticColorMode, + SrcGridW = s.SrcGridW, SrcGridH = s.SrcGridH, SrcCell = s.SrcCell, + SrcPeriod = s.SrcPeriod, SrcStart = s.SrcStart, SrcAnim = s.SrcAnim, + ColorPeriod = s.ColorPeriod, ColorStart = s.ColorStart, ColorTarget = s.ColorTarget, + ColorAnim = s.ColorAnim, SourceSlot = s.SourceSlot, SrcRect = s.SrcRect, Visible = s.Visible, + ScaleCurrent = s.ScaleCurrent, ScaleTarget = s.ScaleTarget, + ScaleDelayMs = s.ScaleDelayMs, ScaleDurationMs = s.ScaleDurationMs, ScaleEnabled = s.ScaleEnabled, + TranslationCurrent = s.TranslationCurrent, TranslationTarget = s.TranslationTarget, + TranslationDelayMs = s.TranslationDelayMs, TranslationDurationMs = s.TranslationDurationMs, + TranslationEnabled = s.TranslationEnabled, + RotationCurrent = s.RotationCurrent, RotationTarget = s.RotationTarget, + RotationDelayMs = s.RotationDelayMs, RotationDurationMs = s.RotationDurationMs, + RotationChannelEnabled = s.RotationChannelEnabled, MatrixStartMs = s.MatrixStartMs, + RotationPeriodMs = s.RotationPeriodMs, RotationAxis = s.RotationAxis, + RotationEnabled = s.RotationEnabled, RotationStartMs = s.RotationStartMs, + }; + CurrentObject = destinationHandle; + return true; + } + } + /// 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); } } @@ -158,6 +204,7 @@ public sealed class GfxState // ---- surfaces (image buffers per slot): ctx+0x52bd4[slot], from create/set-texture ---- private readonly Dictionary _surfaces = new(); + private readonly Dictionary _surfaceTransitions = new(); public void SetSurface(int slot, long resId, long colorKey) { lock (_lock) { _surfaces[slot] = (resId, colorKey); } } /// Ops 0x202/0x203: record a packed 0xAARRGGBB color/alpha modulation on the object and mark it @@ -167,6 +214,27 @@ public sealed class GfxState lock (_lock) { var o = GetOrCreate(handle); o.Color = packed; o.HasColor = true; } } + /// Resolve 0x202/0x203's native negative sentinels against obj+0x60 (the current static + /// packed color): negative alpha preserves its alpha byte; negative RGB preserves its RGB bytes. + public void SetObjectColorResolved(long handle, long alpha, long rgb) + { + lock (_lock) + { + var o = GetOrCreate(handle); + long current = o.HasColor ? o.Color : 0xffffffff; + long resolvedAlpha = alpha < 0 ? (current >> 24) & 0xff : System.Math.Min(alpha, 0xff); + long resolvedRgb = rgb < 0 ? current & 0xffffff : rgb & 0xffffff; + o.Color = PackColor(resolvedAlpha, resolvedRgb); + o.HasColor = true; + } + } + + public void SetStaticObjectColorResolved(long handle, long mode, long alpha, long rgb) + { + SetObjectColorResolved(handle, alpha, rgb); + lock (_lock) GetOrCreate(handle).StaticColorMode = mode; + } + /// Ops 0x239 (static cell, period=0) / 0x231 (animate, period>0): set the spritesheet grid + /// the visible cell. When animated, the interpolator ping-pongs the cell across the grid over the period. public void SetSrcRect(long handle, long gridW, long gridH, long cell, long period) @@ -191,6 +259,73 @@ public sealed class GfxState } public void ClearSurface(int slot) { lock (_lock) { _surfaces[slot] = (0, 0); } } // create-texture (blank) + /// Op 0x223: queue a type-0 timed alpha transition into a target surface slot. + public void QueueSurfaceAlphaTransition(long commandKey, int targetSlot, + long rangeAStart, int rangeACount, long rangeBStart, int rangeBCount, + long delayMs, long durationMs) + { + lock (_lock) + { + _surfaceTransitions[targetSlot] = new SurfaceTransition + { + CommandKey = commandKey, TargetSlot = targetSlot, + RangeAStart = rangeAStart, RangeACount = System.Math.Max(0, rangeACount), + RangeBStart = rangeBStart, RangeBCount = System.Math.Max(0, rangeBCount), + DelayMs = System.Math.Max(0, delayMs), DurationMs = System.Math.Max(0, durationMs), + }; + } + } + + /// Start every pending foreground transition at the native present boundary. + public int StartForegroundTransitions(long nowMs) + { + lock (_lock) + { + int started = 0; + foreach (var t in _surfaceTransitions.Values) + if (t.StartMs < 0) { t.StartMs = nowMs; started++; } + return started; + } + } + + public bool HasActiveForegroundTransitions(long nowMs) + { + lock (_lock) + return _surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0); + } + + /// Click completion affects only type-0 foreground transitions, never ambient object channels. + public int CompleteForegroundTransitions(long nowMs) + { + lock (_lock) + { + int completed = 0; + foreach (var t in _surfaceTransitions.Values) + if (!t.Forced && TransitionProgress(t, nowMs) < 1.0) { t.Forced = true; completed++; } + return completed; + } + } + + public IReadOnlyList SnapshotForegroundTransitions(long nowMs) + { + lock (_lock) + return _surfaceTransitions.Values.Select(t => SampleTransition(t, nowMs)).ToList(); + } + + private static double TransitionProgress(SurfaceTransition t, long nowMs) + { + if (t.Forced) return 1.0; + if (t.StartMs < 0) return 0.0; + long elapsed = nowMs - t.StartMs - t.DelayMs; + if (elapsed <= 0) return 0.0; + if (t.DurationMs <= 0) return 1.0; + return System.Math.Clamp(elapsed / (double)t.DurationMs, 0.0, 1.0); + } + + private static SurfaceTransitionState SampleTransition(SurfaceTransition t, long nowMs) + => new(t.CommandKey, t.TargetSlot, t.RangeAStart, t.RangeACount, t.RangeBStart, t.RangeBCount, + t.DelayMs, t.DurationMs, t.StartMs, TransitionProgress(t, nowMs), t.Forced); + /// draw-texture bind (gfx_object_bind_draw): object draws surface /// 's rect at (dstX,dstY) and becomes visible. public void BindDraw(long handle, int slot, int sx, int sy, int w, int h, int dstX, int dstY) @@ -289,7 +424,17 @@ public sealed class GfxState if (o.HasColor) { var (a, r, g, b) = BlendMath.UnpackArgb(o.Color); - strength = a; tint = ((long)r << 16) | ((long)g << 8) | (long)b; blend = BlendKind.Alpha; + tint = ((long)r << 16) | ((long)g << 8) | (long)b; + if (o.StaticColorMode == 2) + { + // Native transition-source mode: 0xffffffff is opaque identity modulation, + // not a request to replace every texel with white. + alpha = a; strength = 0; blend = BlendKind.Alpha; + } + else + { + strength = a; blend = BlendKind.Alpha; + } } if (o.ColorAnim) { @@ -342,6 +487,8 @@ public sealed class GfxState cycleAngle = ((elapsed % o.RotationPeriodMs) * 360) / o.RotationPeriodMs; } + SurfaceTransitionState? transition = _surfaceTransitions.TryGetValue(o.SourceSlot, out var st) + ? SampleTransition(st, nowMs) : null; list.Add(new RenderObject(kv.Key, resId, ck, srcX, srcY, w, h, (int)o.V24.X, (int)o.V24.Y, new TransformState(scale.X, scale.Y, scale.Z, @@ -351,7 +498,7 @@ public sealed class GfxState new RotationCycleState(o.RotationEnabled, o.RotationPeriodMs, o.RotationAxis.X, o.RotationAxis.Y, o.RotationAxis.Z, cycleAngle), - alpha, tint, strength, blend)); + alpha, tint, strength, blend, transition)); } return list; } diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index d81eb0c..44ec649 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -277,6 +277,11 @@ public sealed class VirtualMachine _host.WaitForInput(); return pc + 1; case "sleep": // 0xc8 (duration) — pause the host duration ms; headless hosts no-op (parity). Frame pacing. _host.Sleep(Read(a[0])); return pc + 1; + case "get-message-skip": // 0x1c7: Ctrl/message fast-forward run-state bit + Write(a[0], _host.IsMessageSkipActive ? 1 : 0); return pc + 1; + case "get-adv-read-skip-state": // 0x1cc: per-message read/click skip service state + case "get-adv-service-state": // compatibility with pre-recovery generated tables + Write(a[0], _host.IsAdvReadSkipActive ? 1 : 0); return pc + 1; case "end-text-line": case "set-font": case "comment": case "display-furigana": case "dev_ukn": return pc + 1; @@ -362,10 +367,12 @@ public sealed class VirtualMachine Gfx.EraseRange(Read(a[0]), Read(a[1])); return pc + 1; case "gfx-elem-release": // 0x1fa (surface slot) Gfx.ClearSurface((int)Read(a[0])); return pc + 1; + case "clone-gfx-object": // 0x21d (source handle)(destination handle) + Gfx.CloneObject(Read(a[0]), Read(a[1])); 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; + Gfx.SetObjectColorResolved(Read(a[0]), Read(a[3]), Read(a[4])); return pc + 1; case "gfx-draw-color": // 0x203 (handle)(v)(alpha)(color) — static alpha/tint - Gfx.SetObjectColor(Read(a[0]), GfxState.PackColor(Read(a[2]), Read(a[3]))); return pc + 1; + Gfx.SetStaticObjectColorResolved(Read(a[0]), Read(a[1]), Read(a[2]), Read(a[3])); return pc + 1; // ---- sprite transform / animation cluster (docs/engine-re.md "0x21c-0x243 ... ANIMATION") ---- case "set-anim-transform-abs": // 0x220 (handle)(delay)(duration)(tx)(ty)(tz) Gfx.SetTranslationChannel(Read(a[0]), Read(a[1]), Read(a[2]), @@ -382,7 +389,13 @@ public sealed class VirtualMachine Gfx.SetAnimClock(Read(a[0])); return pc + 1; case "reset-anim-clock": // 0x243: reset the separate global animation-service clock Gfx.ResetAnimClock(); return pc + 1; - case "mark-frame-yield": // 0x21c: host already yields after every completed opcode + case "queue-surface-alpha-transition": // 0x223: target surface crossfade over two object ranges + Gfx.QueueSurfaceAlphaTransition(Read(a[0]), (int)Read(a[1]), Read(a[2]), (int)Read(a[3]), + Read(a[4]), (int)Read(a[5]), Read(a[6]), Read(a[7])); return pc + 1; + case "present-frame": // 0x20c: read/message-skip path snaps a queued transition to its endpoint + _host.PresentFrame(Gfx); return pc + 1; + case "mark-frame-yield": // 0x21c: normal foreground-transition scheduler/resume boundary + _host.WaitForForegroundTransition(Gfx); return pc + 1; case "clear-gfx-command-queue": // 0x224: retained compositor does not use this native queue return pc + 1; default: diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index 5034892..a611615 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Threading; using Age.Engine.Hosting; +using Age.Engine.Model; using Age.Engine.Sys4; public sealed class GodotAdvHost : IHost @@ -18,7 +19,11 @@ public sealed class GodotAdvHost : IHost private readonly GodotTimelineLog? _timeline; private readonly System.Threading.AutoResetEvent _frameSignal = new(false); private volatile bool _stopping; + private GfxState? _foregroundGfx; public volatile bool IsWaiting; + public volatile bool IsTransitionWaiting; + private long _transitionStartedAtMs = -1; + public long TransitionStartedAtMs => System.Threading.Interlocked.Read(ref _transitionStartedAtMs); public readonly List<(int Offset, string Text)> Captured = new(); public GodotAdvHost(Main main, ResourceMap res, string scene, Age.Engine.Hosting.FrameClock clock, @@ -50,8 +55,64 @@ public sealed class GodotAdvHost : IHost _main.CallDeferred("ClearPage"); } - // called from the main thread (click) or the selftest auto-clicker - public void SignalInput() { if (IsWaiting && _gate.CurrentCount == 0) _gate.Release(); } + // Called from the main thread (click) or an auto-clicker. A transition click is consumed by + // the foreground lifecycle; it never pre-arms or advances the following stable input wait. + public void SignalInput() + { + if (IsTransitionWaiting && _foregroundGfx != null) + { + int completed = _foregroundGfx.CompleteForegroundTransitions(_clock.NowMs); + if (completed > 0) + { + _timeline?.State("transition-forced-complete", new() { ["count"] = completed }); + _frameSignal.Set(); + return; + } + } + if (IsWaiting && _gate.CurrentCount == 0) _gate.Release(); + } + + public void WaitForForegroundTransition(GfxState gfx) + { + int started = gfx.StartForegroundTransitions(_clock.NowMs); + if (started == 0 && !gfx.HasActiveForegroundTransitions(_clock.NowMs)) return; + _foregroundGfx = gfx; + System.Threading.Interlocked.Exchange(ref _transitionStartedAtMs, _clock.NowMs); + IsTransitionWaiting = true; + _timeline?.State("transition-start", new() { ["count"] = started }); + int lastBucket = -1; + while (gfx.HasActiveForegroundTransitions(_clock.NowMs) && !_stopping) + { + var active = gfx.SnapshotForegroundTransitions(_clock.NowMs); + int bucket = active.Count == 0 ? 100 : (int)System.Math.Floor(active[0].Progress * 10); + if (bucket != lastBucket) + { + lastBucket = bucket; + _timeline?.State("transition-progress", new() + { + ["progress"] = active.Count == 0 ? 1.0 : active[0].Progress, + ["forced"] = active.Count != 0 && active[0].Forced, + }); + } + _frameSignal.WaitOne(50); + } + IsTransitionWaiting = false; + System.Threading.Interlocked.Exchange(ref _transitionStartedAtMs, -1); + _foregroundGfx = null; + _opPacer.Reset(); + _timeline?.State("running", new() { ["transition_complete"] = true }); + } + + public void PresentFrame(GfxState gfx) + { + int started = gfx.StartForegroundTransitions(_clock.NowMs); + int completed = gfx.CompleteForegroundTransitions(_clock.NowMs); + if (started > 0 || completed > 0) + _timeline?.State("transition-skip-complete", new() + { + ["started"] = started, ["completed"] = completed, + }); + } public void Stop() { diff --git a/godot/GodotTimelineLog.cs b/godot/GodotTimelineLog.cs index 5a8cf00..35e6437 100644 --- a/godot/GodotTimelineLog.cs +++ b/godot/GodotTimelineLog.cs @@ -23,7 +23,7 @@ public sealed class GodotTimelineLog : System.IDisposable var dir = Path.GetDirectoryName(path); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); _writer = new StreamWriter(path) { AutoFlush = true }; - Record("start", new() { ["transition"] = "unmodeled" }); + Record("start", new() { ["transition"] = "surface-alpha-lifecycle" }); } public void SetFrame(int frame, long nowMs) diff --git a/godot/Main.cs b/godot/Main.cs index c6824d9..2597aed 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -98,6 +98,7 @@ public partial class Main : Godot.Control var seeds = new List<(int Addr, long Val)>(); // --seed 0xADDR=VAL (repeatable) — initial global state double sleepScale = 1.0; // --sleep-scale : slow/speed the paced opening for inspection double speed = 1.0; // --speed : whole-runtime diagnostic speed + long transitionClickMs = -1; // --transition-click-ms : force active transitions after n virtual ms string? histFile = null; // --trace-histogram : op/call-site execution counts of the REAL run for (int i = 0; i < userArgs.Length; i++) { @@ -111,6 +112,7 @@ public partial class Main : Godot.Control if (userArgs[i] == "--frames" && i + 1 < userArgs.Length) int.TryParse(userArgs[i + 1], out _seqFrames); if (userArgs[i] == "--sleep-scale" && i + 1 < userArgs.Length) double.TryParse(userArgs[i + 1], out sleepScale); if (userArgs[i] == "--speed" && i + 1 < userArgs.Length) double.TryParse(userArgs[i + 1], out speed); + if (userArgs[i] == "--transition-click-ms" && i + 1 < userArgs.Length) long.TryParse(userArgs[i + 1], out transitionClickMs); if (userArgs[i] == "--trace-histogram" && i + 1 < userArgs.Length) histFile = userArgs[i + 1]; if (userArgs[i] == "--seed" && i + 1 < userArgs.Length) { @@ -167,6 +169,17 @@ public partial class Main : Godot.Control // --shot-sequence: auto-advance past every input wait so the paced burst isn't blocked on a click. if (_seqDir != null) _ = Task.Run(async () => { while (!_done) { if (_host.IsWaiting) _host.SignalInput(); await Task.Delay(1); } }); + if (transitionClickMs >= 0) + _ = Task.Run(async () => + { + while (!_done) + { + if (_host.IsTransitionWaiting && _host.TransitionStartedAtMs >= 0 && + _clock.NowMs - _host.TransitionStartedAtMs >= transitionClickMs) + _host.SignalInput(); + await Task.Delay(1); + } + }); } public override void _Process(double delta) @@ -253,7 +266,8 @@ public partial class Main : Godot.Control _screen.Fill(new Color(0, 0, 0, 0)); System.Collections.Generic.Dictionary? decisions = _gfxLogPath != null || _timeline != null ? new() : null; int z = 0; - foreach (var v in _vm.Gfx.SnapshotVisibleObjects(_clock.NowMs)) // interpolate at the throttled clock + var visible = _vm.Gfx.SnapshotVisibleObjects(_clock.NowMs); // one synchronized sample for objects + ranges + foreach (var v in visible) // interpolate at the throttled clock { var t = v.Transform; var affine = Age.Engine.Model.Transform2DMath.Build(t, v.Rotation); @@ -264,7 +278,13 @@ public partial class Main : Godot.Control float opacity = v.Alpha / 255f; // transform Z is never opacity float strength = v.TintStrength / 255f; // tint-blend / fill strength string outcome; - if (v.SurfaceResId == 0) + if (v.SurfaceTransition is { } transition) + { + int layers = DrawTransitionRange(visible, transition); + outcome = $"TRANSITION slot={transition.TargetSlot} key=0x{transition.CommandKey:x} " + + $"progress={transition.Progress:0.000} forced={transition.Forced} layers={layers}"; + } + else if (v.SurfaceResId == 0) { // A colored object with no bound surface = a fade/flash fill (e.g. fade-to-black). Its presence // is the tint STRENGTH (0=absent, 255=solid), scaled by any object opacity. Uncolored surfaceless @@ -305,6 +325,37 @@ public partial class Main : Godot.Control if (decisions != null) LogGfxDecisionChanges(decisions); } + // Native type-0 surface commands first leave range A in normal z-order, then alpha-composite range B + // into the target surface. SC0000 binds that target to handle+2, above both source handles, so drawing + // range B here with progress produces old*(1-progress)+new*progress without disturbing ambient channels. + private int DrawTransitionRange(IReadOnlyList visible, SurfaceTransitionState transition) + { + int drawn = 0; + long end = transition.RangeBStart + transition.RangeBCount; + foreach (var source in visible) + { + if (source.Handle < transition.RangeBStart || source.Handle >= end || source.SurfaceTransition != null) + continue; + var affine = Transform2DMath.Build(source.Transform, source.Rotation).FromLocalOrigin(source.DstX, source.DstY); + float opacity = source.Alpha / 255f * (float)transition.Progress; + if (source.SurfaceResId == 0) + { + if (source.Blend == BlendKind.Opaque) continue; + int w = source.W > 0 ? source.W : 800, h = source.H > 0 ? source.H : 600; + FillAffineQuad(w, h, affine, source.Tint, opacity * source.TintStrength / 255f); + } + else + { + var bmp = _host.ResolveResIdTexture(source.SurfaceResId); + if (bmp == null) continue; + BlitLayer(bmp, source.ColorKey, source.Tint, source.TintStrength / 255f, + source.SrcX, source.SrcY, source.W, source.H, affine, opacity); + } + drawn++; + } + return drawn; + } + // Diagnostic (--gfx-log): print, per rendered frame, only the objects whose compositor outcome CHANGED // since last frame (added / gone / drawn↔skip / resId change). Quiet until something actually changes, so // the frame where the background drops out — and WHY — stands out. See systematic-debugging of the grey-BG. diff --git a/vm-map/engine-ctx.toml b/vm-map/engine-ctx.toml index 4b868a8..5fb127f 100644 --- a/vm-map/engine-ctx.toml +++ b/vm-map/engine-ctx.toml @@ -158,6 +158,11 @@ name = "coroutine_resume_off" type = "int" note = "op 0x7c resume PC offset" [[field]] +offset = 0x6dbd4 +name = "adv_read_skip_state" +type = "int" +note = "current ADV read/click-skip service state; queried by op 0x1cc, maintained from ReadTextSkip + per-PC read history" +[[field]] offset = 0x9b24c name = "dispatch_table" type = "void*" diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 8feb326..465dd2a 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -4199,19 +4199,19 @@ observed_types = ["g-int"] [[opcode]] op = 0x1cc -label = "get-adv-service-state" +label = "get-adv-read-skip-state" argc = 1 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "get-adv-service-state" +name = "get-adv-read-skip-state" category = "control" -summary = "(out) - copy native ADV service state ctx+0x6dbd4; label_1235a ORs it with message-skip to select its yield branch." +summary = "(out) - copy the current ADV read/click-skip service state from ctx+0x6dbd4. label_1235a ORs it with 0x1c7's Ctrl/message-skip bit: zero takes 0x21c's normal transition/yield path; nonzero resets the animation service and presents the completed endpoint through 0x20c." noop_headless = false source = "investigation" confidence = "high" depends_on = [] -evidence = "Ghidra handler 0x427330 calls vm_operand_write(1, ctx+0x6dbd4). Exact service-state producer remains outside this opcode." +evidence = "Ghidra handler 0x427330 calls vm_operand_write(1, ctx+0x6dbd4). Producer recovered 2026-07-10: adv_update_read_text_skip_state@0x406cd0 and op 0x6e/0x71/0x72 maintain the field from message_ReadTextSkip plus current-PC read-history lookup; adv_interpreter_tick consumes it in click/read-skip control. It is not the 0x223 surface-transition progress flag." [[opcode.semantics.args]] i = 1 @@ -4832,12 +4832,12 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "gfx-draw-color" category = "draw" -summary = "0x203 (handle)(v)(alpha)(color) — gfx cmd-type 9. Handler gfx_op_0x203_draw_color @0x4229a0: worker gfx_op_0x203_worker_set_color @0x47e9b0 sets a STATIC color/alpha (obj+0x60), no anim bit; packs ARGB from alpha(op3)/color(op4). Sibling of 0x202 (one fewer positional arg). C# VM (2026-07-08 blend slice): routes through GfxState.SetObjectColor → compositor applies static alpha/tint (BlendKind.Alpha). See docs/engine-re.md §Blend & transparency." +summary = "0x203 (handle)(mode)(alpha)(color) — gfx cmd-type 9. Worker stores mode at obj+0x30 and STATIC packed color at obj+0x60. Negative alpha/RGB preserve current static bytes through FUN_0047f3e0. Mode 2 is the 0x223 transition-source alpha/identity path (0xffffffff = opaque identity, not solid white); mode 0 retains the established tint-strength behavior." noop_headless = false source = "investigation" confidence = "high" depends_on = [] -evidence = "Ghidra handler 0x4229a0 (dispatch ctx[0x26c93+0x203]); packs color from operands 3/4, FUN_0047e9b0(op1,op2,packed)." +evidence = "Ghidra handler 0x4229a0 (dispatch ctx[0x26c93+0x203]); negative operands read current obj+0x60 via FUN_0047f3e0, then gfx_op_0x203_worker_set_color stores op2 at obj+0x30 and packed ARGB at obj+0x60. SC0000 transition setup uses mode 2 with -1/-1 before 0x21d/0x223; synchronized window pixels prove treating 0xffffffff as generic white tint is wrong." [[opcode.semantics.args]] i = 1 @@ -5109,12 +5109,12 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "present-frame" category = "draw" -summary = "Present the composited frame (native gfx_render_frame). Host-implicit: our compositor presents every frame." -details = "Native handler gfx_op_0x20c_present_frame (dispatch ctx[0x26c93+0x20c]) -> gfx_render_frame @0x4820b0 flips the composited buffers. Our Godot host runs a continuous per-frame compositor (Main.Recomposite in _Process), so an explicit present is redundant and the VM can skip it. noop_headless=true -> scene coverage classifies it safe-noop. Kelebek label u00416200 was VA-drift (unrelated fn); real handler resolved via the dispatch table." -noop_headless = true +summary = "Present the composited frame; label_1235a uses this on the read/message-skip branch to expose the completed foreground endpoint immediately." +details = "Native handler gfx_op_0x20c_present_frame (dispatch ctx[0x26c93+0x20c]) -> gfx_render_frame @0x4820b0. Godot composites continuously. In label_1235a the OR of 0x1c7/0x1cc is nonzero on the read/message-skip branch, which resets the animation service then presents; the port starts and snaps any pending 0x223 transition to its endpoint here. Normal zero-state playback branches to 0x21c, which owns wait/resume. Headless hosts remain non-blocking. Kelebek label u00416200 was VA-drift." +noop_headless = false source = "investigation" confidence = "high" -depends_on = [] +depends_on = [0x223, 0x1c7, 0x1cc] evidence = "Ghidra: dispatch table FUN_00413860 param_1[0x26e9f]=gfx_op_0x20c_present_frame; 0x26e9f-0x26c93=0x20c. 2026-07-08." [[opcode]] @@ -5130,7 +5130,7 @@ summary = "" noop_headless = false source = "kelebek" confidence = "low" -depends_on = [] +depends_on = [0x20c, 0x1c7, 0x1cc] evidence = "" [[opcode.semantics.args]] @@ -5468,28 +5468,29 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "mark-frame-yield" category = "control" -summary = "Set native run-state bit 0x400. Host-implicit: the port already offers a scheduler yield after every completed opcode." -noop_headless = true +summary = "Set native run-state bit 0x400; in normal ADV playback this is the queued foreground-transition yield/resume boundary." +details = "SC0000 label_1235a reaches this when the OR of 0x1c7 message-skip and 0x1cc read-skip state is zero (normal playback). Native run-state bit 0x400 yields the interpreter while the queued foreground presentation advances. The interactive port starts pending 0x223 commands here, parks only the VM thread while per-frame compositing continues, and resumes after natural or click-forced completion." +noop_headless = false source = "investigation" confidence = "high" -depends_on = [] -evidence = "Ghidra handler 0x417520 sets cmd-type 1 and ORs ctx+0xa0ce4 with 0x400. SC0000 label_1235a reaches it only when op 0x1c7 or 0x1cc is nonzero." +depends_on = [0x223, 0x1c7, 0x1cc] +evidence = "Ghidra handler 0x417520 sets cmd-type 1 and ORs ctx+0xa0ce4 with 0x400. SC0000 label_1235a's jcc reaches it when the OR of op 0x1c7/0x1cc is zero; synchronized port trace confirmed this is the normal path after 0x223." [[opcode]] op = 0x21d -label = "u00421410" +label = "clone-gfx-object" argc = 2 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00421410" -category = "unknown" -summary = "" +name = "clone-gfx-object" +category = "draw" +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." noop_headless = false -source = "kelebek" -confidence = "low" -depends_on = [] -evidence = "" +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." [[opcode.semantics.args]] i = 1 @@ -5683,7 +5684,7 @@ summary = "(command_key)(target_slot)(range_a_start)(range_a_count)(range_b_star noop_headless = false source = "investigation" confidence = "high" -depends_on = [] +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." [[opcode.semantics.args]]