From 7cb0e879e5f507455ab852be5a63ddf0a0121a50 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Wed, 29 Jul 2026 17:09:37 -0400 Subject: [PATCH] Fix skip completion for visual transitions --- docs/engine-re.md | 48 +++++++++++++++---- docs/opcode-reference.md | 20 ++++---- docs/phase-b-framework.md | 20 ++++++++ .../CoreScalarAndScreenTransitionOpsTests.cs | 22 +++++++++ .../ForegroundTransitionTests.cs | 43 +++++++++++++++++ engine/Age.Engine.Tests/TestSupport.cs | 19 ++++++-- engine/Age.Engine/Hosting/IHost.cs | 8 +++- engine/Age.Engine/Model/GfxState.cs | 44 ++++++++++++++++- engine/Age.Engine/Vm/VirtualMachine.cs | 10 ++-- godot/GodotAdvHost.cs | 46 +++++++++++++----- godot/Main.cs | 22 ++++++++- vm-map/opcodes.toml | 19 ++++---- 12 files changed, 271 insertions(+), 50 deletions(-) diff --git a/docs/engine-re.md b/docs/engine-re.md index ddddc79..4230116 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -213,6 +213,7 @@ The meaningful consumer catalog is: | `CANCELMESSKIPONCLICK` | Himegari overrides the zero fallback with 2, enabling `adv_interpreter_tick`'s press/release click-cancel state machine for persistent message Skip. | Active ADV input behavior; currently missing from port | | `CONTROLDISIBLECURSOR` | The spelling is native. Value 1 suppresses AGE's ordinary cursor-restore call when a bound ADV hotspot action or hover callback is selected. | Active ADV cursor behavior; currently missing from port | | `COEXISTMESSKIP` | Value 1 lets Auto and all-message Skip remain simultaneously enabled. With zero, `adv_toggle_auto_mode@0x406b70` clears Skip and `adv_toggle_skip_mode@0x406c20` clears Auto. | Active ADV toggle behavior; currently missing from port | +| `EffectSkipOnClick` (`SYS4REG.INI` `[system]`) | The generic fallback is 0; the active Himegari profile has value 1. While one of the native effect-service run-state bits `0x8`, `0x10`, `0x80`, or `0x100` is active, logical action 4 is consumed and the selected effect tick receives delta `0x10000000`, forcing its terminal frame. | Active visual-effect input policy; port routes action 4 ahead of retained ADV hotspots while a transition service is parked | | `REDRAWTEXTONKEY` | Value 0 disables the wheel/key path that traverses and republishes the current text-history view through `CALLBACK_TEXT.BIN`. | Active ADV history-input policy; port uses its script callback path instead | | `WHEELKEYUP`, `WHEELKEYDOWN` | Rebinds the registry action bits from generic defaults 3/1 to 8/9. `adv_input_service_poll@0x411230` uses them for ADV wheel-key/history handling; raw `WM_MOUSEWHEEL` accumulation for op `0x10d` remains a separate channel. | Active ADV input binding; port does not source these values | | `USEAPPDATAFOLDER`, `SAVEPATH` | Selects `%LOCALAPPDATA%\Eushully\姫狩りダンジョンマイスター\SAVE` as the native save root. | Active native path policy; the port redirects it through the shared profile-root policy | @@ -1016,7 +1017,12 @@ that average had folded service waits into execution time and was not an opcode The Godot host therefore leaves ordinary `FrameYield` non-blocking and publishes retained mutations only at `0x20c`, `0x21c`, sleep, and stable input waits. `0x21c` waits for visible finite color/matrix channels plus -`0x223`; ambient cyclic/spritesheet pulses do not block, and click forcing remains restricted to `0x223`. +`0x223`; ambient cyclic/spritesheet pulses do not block. Click forcing applies to that complete finite +presentation set, not only `0x223`: the outer tick's run-state-`0x400` branch reads +`system:EffectSkipOnClick`, consumes logical action 4, calls the same force-complete worker as `0x243`, and +renders the terminal retained frame. Ordinary object color/alpha and matrix fades therefore jump to their +endpoints together with type-0 surface commands. Movie-mask commands and `0x242`-detached or ambient channels +remain outside that completion set. This is a native-evidenced scheduler correction, not a guessed duration sleep. ##### The opening render path is RETAINED, not immediate-mode (2026-07-08, ground-truth correction) @@ -3440,6 +3446,14 @@ commits every ordinary finite group; detached objects ignore the request. The po commit directly when executing `0x243`, excludes detached groups from its blocking wait predicate, and continues including them in its visual recomposition predicate until natural completion. +The main loop also calls this worker, now named +`gfx_request_force_complete_and_reset_anim_clock@0x4076c0`, from the active run-state-`0x400` service. +At `0x412408..0x41245c` it reads `system:EffectSkipOnClick`, rejects service-flags bit 0, polls the triggered +logical-action mask, consumes action-4 bit `0x10`, clears run-state `0x400`, and requests terminal sampling +before `gfx_render_frame`. This is the generalized native CG-fade click path. It covers the ordinary finite +color/alpha and matrix channels serviced by `0x21c`, as well as queued type-0 surface commands; it is separate +from the legacy run-state-bit-8 fade service. + Corpus evidence is unusually sharp: all 303 calls pass an immediate flag, with zero used 302 times and one used once. SC0000's two CG-loader sites (`0x12723`, `0x13310`) write zero. `BTL.BIN@0x2b4d` writes one on an animated battle object after its texture/movie or sprite-cell setup, matching the nonblocking background @@ -3470,7 +3484,7 @@ skipped all greeting/farewell ids. Correct zero-range execution fixes the boot d or choosing a persistence backend for `0x1a2`/`0x1a3`. `op_0x25_handler@0x41ce00` starts the legacy transition manager at `EngineCtx+0x1c38`, sets run-state bit -8, and keeps the script parked until the transition completes. Its three operands are source surface, +8, and keeps ordinary script execution parked until the transition completes. Its three operands are source surface, target surface, and a timing argument. For arguments up to 64, the handler arms that many milliseconds per tick and advances the 8-bit alpha by 16. Larger arguments use `argument/16` milliseconds and alpha step 1. The branch is expressed in decompilation as `((arg < 65) - 1 & 0xfffffff1) + 0x10`; evaluating both @@ -3478,7 +3492,20 @@ outcomes is significant (`true -> 16`, `false -> 1`). `interval_timer_poll_elaps `screen_transition_tick@0x43a7a0` mode 4 composites source first and target over it at the accumulated alpha; at `0x100` it commits the target. `screen_transition_begin@0x439da0` and `screen_transition_finalize@0x4399c0` own the endpoints. Thus an argument 10 lasts about 160 ms, while 30 -lasts about 480 ms; input can force the endpoint through the same transition-abort service. Manual ROOM +lasts about 480 ms. The outer service's generalized skip paths are: + +- If ADV fast-forward bit `run_state_flags & 0x08000000` is already set when `0x25` dispatches, the + handler does not create a transition. It calls `screen_transition_finalize` for the target endpoint + and advances normally. +- Otherwise the handler starts service bit 8 and calls `input_flush_action_triggers@0x405380`, preventing + the input that entered the effect from also canceling it. With `system:EffectSkipOnClick` enabled, + `engine_main_tick_with_exception_policy@0x411840` polls + `input_poll_triggered_action_mask@0x460880`; logical action 4 (`0x10`) is consumed and + `screen_transition_tick(0x10000000)` forces the active service to its endpoint. + +The PC advances when the service begins. The handler's finalize branch is therefore the skip-at-dispatch +path, not a re-entry after natural completion; natural and click-forced progress lives wholly in the outer +service. Manual ROOM validation caught the initially inverted alpha-step branch. ROOM explicitly constructs those full frames. It selects/clears surface 1 and calls `0x20c`, mutates the @@ -3501,11 +3528,13 @@ the already implemented mode-4 `0x25` transition. Dispatch-table resolution give | `0x23` | `op_0x23_fade_surface_in_from_white@0x41cc80` | 2 | captured surface | | `0x24` | `op_0x24_fade_surface_out_to_white@0x41cd40` | 3 | white | -All four take `(surface_slot, timing_argument)`, set run-state bit 8, and use the exact `0x25` timing +All four take `(surface_slot, timing_argument)` and use the exact `0x25` timing conversion: arguments up to 64 use `argument` milliseconds per tick with alpha step 16; larger arguments use `argument/16` milliseconds with step 1. Modes 0/2 start with an opaque solid scratch surface over the captured frame and lower its alpha to zero. Modes 1/3 keep the captured frame opaque and raise the -black/white scratch alpha to 255. Re-entry finalizes the named endpoint before script execution resumes. +black/white scratch alpha to 255. With fast-forward inactive, they set service bit 8 and use the same +natural/action-4 forced-completion path described above. With fast-forward already active at dispatch, +they publish the named endpoint without starting the service. Himegari uses only the black pair: three `0x21` calls and eight `0x22` calls, all `(1,30)` and therefore about 480 ms. The scripts first select surface 1, clear it, publish a complete retained frame through @@ -3515,7 +3544,9 @@ menu/scene exit to black. The port dispatches both opcodes through the existing blocking `LegacyScreenTransition` clock and terminal publication path. Its compositor already clears the whole frame to opaque black, so an empty source list models black-to-captured and an empty target list models captured-to-black without overloading the mode-4 -missing-surface fallback (which intentionally snapshots the live retained frame). This closes 11 formerly +missing-surface fallback (which intentionally snapshots the live retained frame). Active ADV Skip/Ctrl now +marks the transition forced at dispatch, while action 4 is routed to the transition before retained ADV +hotspots can consume it. This closes 11 formerly skipped instructions across seven distinct scripts and reduces the effectful-gap inventory from 18 to 16 distinct opcodes / 28 instructions. @@ -3850,8 +3881,9 @@ The following native handlers were decoded while bounding that slice: completion-loop predicate: BTL scans its active combat surfaces, while FIELD and USEMAGIC poll slot 42. - `op_0x24e_set_gfx_animation_service_flags@0x425070` copies its operand directly to `EngineCtx.gfx_animation_service_flags` (`+0x51b80`). BTL brackets combat presentation with 1/0 and - GAMECLEAR uses 3/0. Bit 1 is independently consumed by opcode `0x243` to suppress a force-complete and - animation-clock-reset request. + GAMECLEAR uses 3/0. Bit 0 disables the run-state-`0x400` `EffectSkipOnClick` branch; bit 1 is independently + consumed by opcode `0x243` and the shared worker to suppress a force-complete and animation-clock-reset + request. - `op_0x207_copy_surface_rect@0x422b50` builds source/destination rectangles from `(source_surface, destination_surface, source_x, source_y, width, height, destination_x, destination_y)` and calls `gfx_copy_surface_rect@0x477da0`. The worker validates both slots, clips both rectangles while diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 9f015cd..dfc3381 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -732,9 +732,9 @@ Port status (2026-07-24): the VM refreshes this state from message:ReadTextSkip - **grounding:** source=investigation, confidence=high - **depends on:** 0x223, 0x1c7, 0x1cc - **depended on by:** 0x223 -- **evidence:** Ghidra handler 0x417520 records the 1-dword instruction length and ORs ctx+0xa0ce4 with 0x400. capture_presentation_trace.py: after 0x125a6 render, 0xcb8e/0xcb98 bind and 0xd5a/0xd63/0xd73/0xd8a mode+targets execute without render; repeated gfx_render_frame begins only at 0x21c. 2026-07-10. +- **evidence:** Ghidra handler 0x417520 records the 1-dword instruction length and ORs ctx+0xa0ce4 with 0x400. engine_main_tick_with_exception_policy@0x411840 branch 0x4123d1 polls gfx_animation_service_poll; its active branch reads EffectSkipOnClick@0x570f60, rejects gfx_animation_service_flags bit 0, polls/consumes action-4 bit 0x10, clears run-state 0x400, calls gfx_request_force_complete_and_reset_anim_clock@0x4076c0, then gfx_render_frame. capture_presentation_trace.py: after 0x125a6 render, 0xcb8e/0xcb98 bind and 0xd5a/0xd63/0xd73/0xd8a mode+targets execute without render; repeated gfx_render_frame begins only at 0x21c. 2026-07-10, click lifecycle refined 2026-07-29. -SC0000 label_1235a reaches this when 0x1c7/0x1cc are zero. Native run-state bit 0x400 parks the interpreter while gfx_render_frame repeatedly samples finite one-shot object channels and queued surface commands; op 0x224 follows after dirty state clears. Native trace proves AE001D bind, mode-1 0x203, and 0x202 targets complete in one 5 ms batch with no render, then first compose here. The port publishes and waits for visible finite one-shot channels or 0x223 commands; click forcing remains limited to the latter. +SC0000 label_1235a reaches this when 0x1c7/0x1cc are zero. Native run-state bit 0x400 parks the interpreter while gfx_render_frame repeatedly samples finite one-shot object channels and queued surface commands; op 0x224 follows after dirty state clears. When system:EffectSkipOnClick is enabled and animation-service flag bit 0 is clear, logical action 4 clears the service wait, requests the same forced completion as 0x243, renders the terminal retained frame, and consumes the trigger. That request completes ordinary finite color/scale/rotation/translation channels and type-0 surface commands together; op-0x242-detached channels, ambient cycles, and movie masks are excluded. Native trace proves AE001D bind, mode-1 0x203, and 0x202 targets complete in one 5 ms batch with no render, then first compose here. ## draw @@ -742,17 +742,17 @@ SC0000 label_1235a reaches this when 0x1c7/0x1cc are zero. Native run-state bit - **summary:** (surface_slot)(timing_argument) — block while fading from black to a captured full-frame surface. The captured surface remains the terminal frame. - **grounding:** source=investigation, confidence=high - **depends on:** 0x20c, 0x20d -- **evidence:** Ghidra /v2: dispatch slot 0x21 resolves to op_0x21_fade_surface_in_from_black@0x41cb00. First entry sets run-state bit 8, converts timing <=64 to interval=argument ms and alpha step=16 (otherwise interval=argument/16 and step=1), then starts screen_transition_begin@0x439da0 mode 0 with the operand surface. screen_transition_tick@0x43a7a0 draws that surface below an opaque-black scratch surface whose alpha falls 255->0; re-entry finalizes the operand surface. Corpus: 3 sites in EVOLVE, SELSTAGE, and STUDY, all (1,30), approximately 480 ms. +- **evidence:** Ghidra /v2: dispatch slot 0x21 resolves to op_0x21_fade_surface_in_from_black@0x41cb00. With run_state_flags bit 0x08000000 clear, the handler sets effect-service bit 8, converts timing <=64 to interval=argument ms and alpha step=16 (otherwise interval=argument/16 and step=1), starts screen_transition_begin@0x439da0 mode 0, flushes the initiating trigger, and advances the PC. engine_main_tick_with_exception_policy@0x411840 owns natural progress and, when system:EffectSkipOnClick is enabled, forces action-4 clicks through screen_transition_tick@0x43a7a0 with delta 0x10000000. If fast-forward bit 0x08000000 was already set at dispatch, the handler instead publishes the operand-surface endpoint directly; it is not a post-transition re-entry. Corpus: 3 sites in EVOLVE, SELSTAGE, and STUDY, all (1,30), approximately 480 ms. -Port status (2026-07-29): implemented through the blocking legacy full-frame transition host path. An explicit empty/black source endpoint is composited beneath the captured target; timing and terminal-frame publication share the op-0x25 lifecycle. +Port status (2026-07-29, skip lifecycle corrected 2026-07-29): implemented through the blocking legacy full-frame transition host path. An explicit empty/black source endpoint is composited beneath the captured target. An already-active ADV fast-forward channel bypasses the timed service and publishes the target endpoint; logical action 4 forces an in-progress transition endpoint when system:EffectSkipOnClick is enabled. ### 0x22 `fade-surface-out-to-black` (u00418920, argc 2) - **summary:** (surface_slot)(timing_argument) — block while fading a captured full-frame surface to black. Black remains the terminal frame. - **grounding:** source=investigation, confidence=high - **depends on:** 0x20c, 0x20d -- **evidence:** Ghidra /v2: dispatch slot 0x22 resolves to op_0x22_fade_surface_out_to_black@0x41cbc0. It uses the same timer/alpha-step conversion as 0x21 and 0x25, then starts screen_transition_begin@0x439da0 mode 1. screen_transition_tick@0x43a7a0 keeps the operand surface opaque and raises the black scratch-surface alpha 0->255; re-entry finalizes a black endpoint. Corpus: 8 sites across ALCHEMY, CHMENU, EVOLVE, FORT, SELSTAGE, STUDY, and SUMMON, all (1,30), approximately 480 ms. +- **evidence:** Ghidra /v2: dispatch slot 0x22 resolves to op_0x22_fade_surface_out_to_black@0x41cbc0. With run_state_flags bit 0x08000000 clear, it uses the same timer/alpha-step conversion as 0x21/0x25, starts screen_transition_begin@0x439da0 mode 1, flushes the initiating trigger, and advances the PC. screen_transition_tick@0x43a7a0 keeps the operand surface opaque and raises black alpha 0->255; engine_main_tick_with_exception_policy@0x411840 owns natural and system:EffectSkipOnClick-forced completion. If fast-forward was already active at dispatch, the handler publishes black directly; it is not a post-transition re-entry. Corpus: 8 sites across ALCHEMY, CHMENU, EVOLVE, FORT, SELSTAGE, STUDY, and SUMMON, all (1,30), approximately 480 ms. -Port status (2026-07-29): implemented through the blocking legacy full-frame transition host path. The captured source is composited beneath an explicit empty/black target endpoint; timing and terminal-frame publication share the op-0x25 lifecycle. +Port status (2026-07-29, skip lifecycle corrected 2026-07-29): implemented through the blocking legacy full-frame transition host path. The captured source is composited beneath an explicit empty/black target endpoint. An already-active ADV fast-forward channel bypasses the timed service and publishes black; logical action 4 forces an in-progress transition endpoint when system:EffectSkipOnClick is enabled. ### 0x23 `fade-surface-in-from-white` (u004189D0, argc 2) - **summary:** (surface_slot)(timing_argument) — broader-AGE mode-2 sibling of 0x21: block while fading from white to a captured full-frame surface. @@ -770,9 +770,9 @@ Port status (2026-07-29): implemented through the blocking legacy full-frame tra - **summary:** (source_surface)(target_surface)(interval_argument) — blocking legacy full-frame mode-4 alpha transition. The native engine composites the captured target surface over the captured source while an 8-bit alpha accumulator advances from 0 to 256. - **grounding:** source=investigation, confidence=high - **depends on:** 0x20c, 0x20d -- **evidence:** Ghidra /v2: dispatch handler op_0x25_handler@0x41ce00 sets run-state bit 8, converts operand 3 to timer interval/alpha step (<=64: interval=arg ms and step=16; >64: interval=arg/16 ms and step=1), calls screen_transition_begin@0x439da0 with mode 4, and later screen_transition_finalize@0x4399c0. engine_main_tick_with_exception_policy polls interval_timer_poll_elapsed_steps@0x44d080 and calls screen_transition_tick@0x43a7a0; mode 4 draws source then target with progress alpha and commits target at 0x100. Thus ROOM argument 10 is about 160 ms and argument 30 about 480 ms. ROOM sites: 0x12f,0x7c6,0x856 use (1,2,10); 0x8dc uses (1,2,30). The step branch was corrected after manual timing validation; /v2 annotated and saved 2026-07-21. +- **evidence:** Ghidra /v2: dispatch handler op_0x25_handler@0x41ce00 checks ADV fast-forward bit 0x08000000. Clear: it sets effect-service bit 8, converts operand 3 to timer interval/alpha step (<=64: interval=arg ms and step=16; >64: interval=arg/16 ms and step=1), starts screen_transition_begin@0x439da0 mode 4, flushes the initiating trigger, and advances the PC. engine_main_tick_with_exception_policy@0x411840 polls interval_timer_poll_elapsed_steps@0x44d080 and calls screen_transition_tick@0x43a7a0; mode 4 draws source then target with progress alpha and commits target at 0x100. With system:EffectSkipOnClick enabled, logical action 4 is consumed and delta 0x10000000 forces that endpoint. If fast-forward was already set at dispatch, the handler calls screen_transition_finalize@0x4399c0 for the target directly; this is not post-transition re-entry. Thus ROOM argument 10 is about 160 ms and argument 30 about 480 ms. ROOM sites: 0x12f,0x7c6,0x856 use (1,2,10); 0x8dc uses (1,2,30). /v2 annotations corrected 2026-07-29. -The handler uses an alpha step of 16 and timer interval=argument when argument <=64. Above 64 it uses step=1 and interval=argument/16. The main loop polls that timer, advances by skipped intervals plus the current interval, and does not resume the script until the target endpoint has been presented. ROOM uses (1,2,10) for button/character entry and exit fades and (1,2,30) for the final fade before returning to TITLE. The port captures retained-frame snapshots whenever op 0x20c presents to a selected offscreen render target, then reproduces this blocking alpha lifecycle in the interactive host. +The handler uses an alpha step of 16 and timer interval=argument when argument <=64. Above 64 it uses step=1 and interval=argument/16. The main loop polls that timer and does not resume ordinary script execution until the target endpoint is presented. An already-active ADV fast-forward channel publishes the target without starting the service; with system:EffectSkipOnClick enabled, logical action 4 forces an in-progress service to the same endpoint. ROOM uses (1,2,10) for button/character entry and exit fades and (1,2,30) for the final fade before returning to TITLE. The port captures retained-frame snapshots whenever op 0x20c presents to a selected offscreen render target, then reproduces the blocking and forced-endpoint lifecycle in the interactive host. ### 0x80 `set-default-gfx-object-slot` (u0041AF00, argc 1) - **summary:** (slot) - select the retained graphics-object slot used when op 0x1d9 receives explicit slot zero. @@ -1148,7 +1148,9 @@ The RGB24 sample callback copies the bottom-up green byte verbatim. In the nativ ### 0x24e `set-gfx-animation-service-flags` (set-gfx-animation-service-flags, argc 1) - **summary:** Replace the retained graphics animation-service flags with operand 1. BTL brackets combat presentation with values 1 and 0; GAMECLEAR uses 3 and 0. - **grounding:** source=investigation, confidence=high -- **evidence:** Ghidra /v2: op_0x24e_handler@0x425070 writes operand 1 directly to EngineCtx.gfx_animation_service_flags at +0x51b80. The mapped field is also read by op 0x243: bit 1 suppresses its force-complete/clock-reset request. +- **evidence:** Ghidra /v2: op_0x24e_handler@0x425070 writes operand 1 directly to EngineCtx.gfx_animation_service_flags at +0x51b80. engine_main_tick_with_exception_policy@0x411840 tests bit 0 before polling action 4 in the run-state-0x400 EffectSkipOnClick branch. gfx_request_force_complete_and_reset_anim_clock@0x4076c0 tests bit 1 before setting force-complete and zeroing the service clock; opcode 0x243 calls the same worker. + +Bit 0 disables the run-state-0x400 EffectSkipOnClick branch while finite retained presentation is active. Bit 1 independently suppresses opcode 0x243 and the click branch's shared force-complete/clock-reset request. BTL brackets combat presentation with 1/0 and GAMECLEAR uses 3/0, protecting those service-owned animations from ordinary ADV click completion. ### 0x258 `set-surface-persistence-flags` (set-surface-persistence-flags, argc 2) - **summary:** (surface_slot)(flags) - replace the native surface record's numbered-save persistence flags; bit 0 controls asset reload on restore and bit 1 controls the adjacent still-unnamed field. diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index 1823e81..f43cae0 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -854,6 +854,26 @@ conversion and exact terminal-frame publication, and closes all 11 sites. The re is 16 distinct opcodes / 28 instructions. All 477 engine tests, opcode build/lint, the zero-warning Godot build, and the Himegari-targeted threaded selftest pass. +**Legacy-transition Skip/click lifecycle corrected (2026-07-29):** Ghidra's outer engine tick exposes the +shared mechanism behind native skippable effects. With ADV fast-forward already active, `0x21`, `0x22`, and +`0x25` publish their terminal endpoint without starting the timed service. Otherwise run-state bit 8 owns +the transition; when `SYS4REG.INI` `system:EffectSkipOnClick` is enabled, logical action 4 is consumed and +the active effect tick receives delta `0x10000000`, forcing that same endpoint. The port now carries the +fast-forward state into legacy-transition dispatch and prioritizes action 4 over retained ADV hotspots +while a transition is parked. A focused regression covers forced black-fade and crossfade dispatch. +Validation passes 519/519 engine tests, opcode build/lint, the zero-warning Godot build, clean diff +checking, and the Himegari-targeted threaded selftest. + +**Retained CG-fade click completion corrected (2026-07-29):** the legacy service above was only one of two +native paths and did not cover the story CG fades observed in the port. The outer tick's separate +run-state-`0x400` service applies `EffectSkipOnClick` to the complete ordinary finite retained-animation set: +queued type-0 surface commands plus object color/alpha, scale, rotation, and translation channels. The port's +click handler had incorrectly forced only the surface-command subset, so CGs implemented as object alpha or +matrix fades still consumed their full duration. Interactive advance now completes the complete native set; +activating Ctrl/Skip during the wait uses the same endpoint path. Ambient loops, op-`0x242` detached channels, +and movie masks remain unaffected. Focused exclusions and endpoint regressions pass in the 523-test engine +suite, and the Godot build is warning-free. + **Cyclic reset implemented (2026-07-29):** `0x230(handle)` now gets or creates the retained object, disables the four looping channels represented by the compositor, and clears the complete native start/period block—including the preserved raw state for the currently unmodeled second cyclic matrix. diff --git a/engine/Age.Engine.Tests/CoreScalarAndScreenTransitionOpsTests.cs b/engine/Age.Engine.Tests/CoreScalarAndScreenTransitionOpsTests.cs index 170d9c3..7c05b76 100644 --- a/engine/Age.Engine.Tests/CoreScalarAndScreenTransitionOpsTests.cs +++ b/engine/Age.Engine.Tests/CoreScalarAndScreenTransitionOpsTests.cs @@ -92,4 +92,26 @@ public class CoreScalarAndScreenTransitionOpsTests Assert.Equal(480, LegacyScreenTransitionTiming.DurationMilliseconds(30)); Assert.Equal(1024, LegacyScreenTransitionTiming.DurationMilliseconds(65)); } + + [Fact] + public void ActiveAdvSkip_ForcesLegacyScreenTransitionEndpointsAtDispatch() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + var script = ScriptAssembler.Assemble(table, "SKIPPED_SCREEN_TRANSITIONS", + new List<(int, Operand[])> + { + (0x21, new[] { new Operand(Imm, 1), new Operand(Imm, 30) }), + (0x25, new[] + { + new Operand(Imm, 1), new Operand(Imm, 2), new Operand(Imm, 10), + }), + (0x2, Array.Empty()), + }, Array.Empty()); + var host = new RecordingHost { MessageSkip = true }; + + new VirtualMachine(script, table, host).Run(); + + Assert.Equal(new[] { true }, host.SurfaceBlackFadeForceEndpoints); + Assert.Equal(new[] { true }, host.SurfaceCrossfadeForceEndpoints); + } } diff --git a/engine/Age.Engine.Tests/ForegroundTransitionTests.cs b/engine/Age.Engine.Tests/ForegroundTransitionTests.cs index c014fd0..d754ffd 100644 --- a/engine/Age.Engine.Tests/ForegroundTransitionTests.cs +++ b/engine/Age.Engine.Tests/ForegroundTransitionTests.cs @@ -93,4 +93,47 @@ public class ForegroundTransitionTests Assert.False(gfx.HasActiveForegroundTransitions(100)); Assert.True(gfx.SnapshotMovieMaskTransitions().Single().Completed); } + + [Fact] + public void ClickCompletionFinishesSurfaceAndOrdinaryOneShotsButPreservesExcludedChannels() + { + var gfx = new GfxState(); + gfx.SetSurface(4, 0x25, -1); + gfx.BindDraw(100, 4, 0, 0, 800, 600, 0, 0); + gfx.BindDraw(101, 4, 0, 0, 800, 600, 0, 0); + gfx.SetAnimatedObjectColorResolved(100, 0, 1000, 0, 0xffffff); + gfx.SetScaleChannel(100, 0, 1000, (200, 200, 100)); + gfx.SetAnimatedObjectColorResolved(101, 0, 1000, 0, 0xffffff); + gfx.SetOneShotAnimationControl(101, 1); + gfx.SetRotationCycle(100, 1000, (0, 0, 1)); + gfx.QueueSurfaceAlphaTransition(102, 6, 101, 1, 100, 1, 0, 1000); + gfx.StartForegroundTransitions(100); + gfx.QueueMovieMaskTransition(new MovieMaskTransitionRequest( + 11, 45, 10, 1, -184, 0, 800, 600, 0, 0x325e, 0, 1000)); + + Assert.Equal(3, gfx.CompleteClickSkippableTimedPresentation(200)); + + Assert.Equal(1.0, gfx.SnapshotForegroundTransitions(200).Single().Progress); + Assert.False(gfx.TryGet(100)!.OneShotColorEnabled); + Assert.False(gfx.TryGet(100)!.ScaleEnabled); + Assert.True(gfx.TryGet(100)!.RotationEnabled); + Assert.True(gfx.TryGet(101)!.OneShotColorEnabled); + Assert.False(gfx.SnapshotMovieMaskTransitions().Single().Completed); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public void AnimationServiceFlagsSuppressClickCompletion(long flags) + { + var gfx = new GfxState(); + gfx.SetSurface(4, 0x25, -1); + gfx.BindDraw(100, 4, 0, 0, 800, 600, 0, 0); + gfx.SetAnimatedObjectColorResolved(100, 0, 1000, 0, 0xffffff); + gfx.SetAnimationServiceFlags(flags); + + Assert.Equal(0, gfx.CompleteClickSkippableTimedPresentation(100)); + Assert.True(gfx.TryGet(100)!.OneShotColorEnabled); + } } diff --git a/engine/Age.Engine.Tests/TestSupport.cs b/engine/Age.Engine.Tests/TestSupport.cs index 5567491..65501e9 100644 --- a/engine/Age.Engine.Tests/TestSupport.cs +++ b/engine/Age.Engine.Tests/TestSupport.cs @@ -60,6 +60,8 @@ internal class RecordingHost : IHost public readonly List<(int First, int Count)> ReleasedSurfaceRanges = new(); public readonly List<(int Surface, long Interval, SurfaceBlackFadeDirection Direction)> SurfaceBlackFades = new(); public readonly List<(int Source, int Target, long Interval)> SurfaceCrossfades = new(); + public readonly List SurfaceBlackFadeForceEndpoints = new(); + public readonly List SurfaceCrossfadeForceEndpoints = new(); public readonly List<(long Resource, int Slot)> Textures = new(); public readonly List MessageSkipChanges = new(); public readonly List PhysicalMessageSkipChanges = new(); @@ -175,10 +177,19 @@ internal class RecordingHost : IHost gfx.CompleteForegroundTransitions(100); } public void FadeSurfaceWithBlack( - GfxState gfx, int surface, long intervalArgument, SurfaceBlackFadeDirection direction) - => SurfaceBlackFades.Add((surface, intervalArgument, direction)); - public void CrossfadeSurfaces(GfxState gfx, int sourceSurface, int targetSurface, long intervalArgument) - => SurfaceCrossfades.Add((sourceSurface, targetSurface, intervalArgument)); + GfxState gfx, int surface, long intervalArgument, SurfaceBlackFadeDirection direction, + bool forceEndpoint = false) + { + SurfaceBlackFades.Add((surface, intervalArgument, direction)); + SurfaceBlackFadeForceEndpoints.Add(forceEndpoint); + } + public void CrossfadeSurfaces( + GfxState gfx, int sourceSurface, int targetSurface, long intervalArgument, + bool forceEndpoint = false) + { + SurfaceCrossfades.Add((sourceSurface, targetSurface, intervalArgument)); + SurfaceCrossfadeForceEndpoints.Add(forceEndpoint); + } public void CreateTexture(int slot, int w, int h) { } public void SetTexture(long resId, int slot) => Textures.Add((resId, slot)); public void ClearRenderTarget(int surfaceSlot) => ClearedRenderTargets.Add(surfaceSlot); diff --git a/engine/Age.Engine/Hosting/IHost.cs b/engine/Age.Engine/Hosting/IHost.cs index 0c7aca3..f4d69ea 100644 --- a/engine/Age.Engine/Hosting/IHost.cs +++ b/engine/Age.Engine/Hosting/IHost.cs @@ -131,9 +131,13 @@ public interface IHost void PresentFrame(GfxState gfx) { } // Legacy SYS4 screen-transition family (ops 0x21, 0x22, and 0x25): scripts render complete // frames into numbered surfaces, then block while the engine alpha-composites an endpoint. + // Native bypasses the timed service when ADV fast-forward is already active at opcode dispatch. void FadeSurfaceWithBlack( - GfxState gfx, int surface, long intervalArgument, SurfaceBlackFadeDirection direction) { } - void CrossfadeSurfaces(GfxState gfx, int sourceSurface, int targetSurface, long intervalArgument) { } + GfxState gfx, int surface, long intervalArgument, SurfaceBlackFadeDirection direction, + bool forceEndpoint = false) { } + void CrossfadeSurfaces( + GfxState gfx, int sourceSurface, int targetSurface, long intervalArgument, + bool forceEndpoint = false) { } void CreateTexture(int slot, int width, int height); /// Return a stable RGBA snapshot of one numbered surface, or null when unavailable. RgbaImage? CaptureSurfacePixels(int slot) => null; diff --git a/engine/Age.Engine/Model/GfxState.cs b/engine/Age.Engine/Model/GfxState.cs index ecb726e..750ba7d 100644 --- a/engine/Age.Engine/Model/GfxState.cs +++ b/engine/Age.Engine/Model/GfxState.cs @@ -934,7 +934,49 @@ public sealed class GfxState (o.RotationEnabled && o.RotationPeriodMs > 0))); } - /// Click completion affects only type-0 foreground transitions, never ambient object channels. + /// Force the finite retained presentation serviced by native run-state bit 0x400 to its + /// endpoint. This is the EffectSkipOnClick path: type-0 surface commands and ordinary finite object + /// channels complete together, while movie masks, ambient cycles, and op-0x242-detached channels remain + /// active. Op 0x24e bit 0 disables click skipping; bit 1 suppresses the shared force-complete request. + public int CompleteClickSkippableTimedPresentation(long nowMs) + { + lock (_lock) + { + if ((AnimationServiceFlags & 3) != 0) return 0; + + int completed = 0; + foreach (var t in _surfaceTransitions.Values) + { + if (t.Forced || TransitionProgress(t, nowMs) >= 1.0) continue; + t.Forced = true; + completed++; + } + + completed += CountOneShotChannels(_rangeTransform); + foreach (var o in _objects.Values) + { + if ((o.OneShotAnimationControlFlags & 1) != 0) continue; + completed += CountOneShotChannels(o); + } + ForceCompleteOneShotChannels(); + + if (AnimClockDurationTicks != 0) completed++; + AnimClockDurationTicks = 0; + AnimClockGeneration++; + + if (completed > 0) MarkRetainedMutation(); + return completed; + } + } + + private static int CountOneShotChannels(GfxObject o) + => (o.OneShotColorEnabled ? 1 : 0) + + (o.ScaleEnabled ? 1 : 0) + + (o.RotationChannelEnabled ? 1 : 0) + + (o.TranslationEnabled ? 1 : 0); + + /// Force only queued type-0 foreground transitions. PresentFrame uses this to publish a + /// command endpoint immediately; interactive run-state-0x400 skipping uses the broader method above. public int CompleteForegroundTransitions(long nowMs) { lock (_lock) diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 4b70414..00d39bd 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -2862,16 +2862,20 @@ public sealed class VirtualMachine case "fade-surface-in-from-black": // 0x21: blocking black -> captured full-frame surface case "u00418860": _host.FadeSurfaceWithBlack( - Gfx, (int)Read(a[0]), Read(a[1]), SurfaceBlackFadeDirection.FromBlack); + Gfx, (int)Read(a[0]), Read(a[1]), SurfaceBlackFadeDirection.FromBlack, + _messageSkipServiceActive || _host.IsMessageSkipActive); return pc + 1; case "fade-surface-out-to-black": // 0x22: blocking captured full-frame surface -> black case "u00418920": _host.FadeSurfaceWithBlack( - Gfx, (int)Read(a[0]), Read(a[1]), SurfaceBlackFadeDirection.ToBlack); + Gfx, (int)Read(a[0]), Read(a[1]), SurfaceBlackFadeDirection.ToBlack, + _messageSkipServiceActive || _host.IsMessageSkipActive); return pc + 1; case "crossfade-surfaces": // 0x25: legacy full-frame surface alpha transition case "u00418B40": - _host.CrossfadeSurfaces(Gfx, (int)Read(a[0]), (int)Read(a[1]), Read(a[2])); + _host.CrossfadeSurfaces( + Gfx, (int)Read(a[0]), (int)Read(a[1]), Read(a[2]), + _messageSkipServiceActive || _host.IsMessageSkipActive); return pc + 1; case "mark-frame-yield": // 0x21c: normal foreground-transition scheduler/resume boundary _host.WaitForForegroundTransition(Gfx); return pc + 1; diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index 688d488..ee43e4c 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -654,14 +654,24 @@ public sealed class GodotAdvHost : IHost _frameSignal.Set(); return; } + if (TryForceActiveTransition("advance")) return; + if (IsWaiting && _gate.CurrentCount == 0) _gate.Release(); + } + + private bool TryForceActiveTransition(string source) + { if (IsTransitionWaiting && _foregroundGfx != null) { - int completed = _foregroundGfx.CompleteForegroundTransitions(_clock.NowMs); + int completed = _foregroundGfx.CompleteClickSkippableTimedPresentation(_clock.NowMs); if (completed > 0) { - _timeline?.State("transition-forced-complete", new() { ["count"] = completed }); + _timeline?.State("transition-forced-complete", new() + { + ["count"] = completed, + ["source"] = source, + }); _frameSignal.Set(); - return; + return true; } } if (IsTransitionWaiting) @@ -671,13 +681,16 @@ public sealed class GodotAdvHost : IHost if (_screenTransition != null) { _screenTransition.Forced = true; - _timeline?.State("screen-transition-forced-complete", new()); + _timeline?.State("screen-transition-forced-complete", new() + { + ["source"] = source, + }); _frameSignal.Set(); - return; + return true; } } } - if (IsWaiting && _gate.CurrentCount == 0) _gate.Release(); + return false; } public bool IsMessageSkipActive => _messageSkipActive; @@ -715,6 +728,7 @@ public sealed class GodotAdvHost : IHost if (effective) { lock (_textLock) _advTextForceComplete = true; + TryForceActiveTransition("message-skip"); _frameSignal.Set(); _inputCallbackSignal.Set(); return; @@ -846,7 +860,8 @@ public sealed class GodotAdvHost : IHost } public void FadeSurfaceWithBlack( - GfxState gfx, int surface, long intervalArgument, SurfaceBlackFadeDirection direction) + GfxState gfx, int surface, long intervalArgument, SurfaceBlackFadeDirection direction, + bool forceEndpoint = false) { long start = _clock.NowMs; IReadOnlyList captured; @@ -859,7 +874,7 @@ public sealed class GodotAdvHost : IHost direction == SurfaceBlackFadeDirection.FromBlack ? black : captured; IReadOnlyList target = direction == SurfaceBlackFadeDirection.FromBlack ? captured : black; - RunLegacyScreenTransition(source, target, start, intervalArgument, new() + RunLegacyScreenTransition(source, target, start, intervalArgument, forceEndpoint, new() { ["kind"] = "surface-black-fade", ["surface"] = surface, @@ -868,7 +883,9 @@ public sealed class GodotAdvHost : IHost }); } - public void CrossfadeSurfaces(GfxState gfx, int sourceSurface, int targetSurface, long intervalArgument) + public void CrossfadeSurfaces( + GfxState gfx, int sourceSurface, int targetSurface, long intervalArgument, + bool forceEndpoint = false) { IReadOnlyList source; IReadOnlyList target; @@ -880,7 +897,7 @@ public sealed class GodotAdvHost : IHost target = _renderTargetSnapshots.TryGetValue(targetSurface, out var capturedTarget) ? capturedTarget : gfx.SnapshotVisibleObjects(start); } - RunLegacyScreenTransition(source, target, start, intervalArgument, new() + RunLegacyScreenTransition(source, target, start, intervalArgument, forceEndpoint, new() { ["kind"] = "surface-crossfade", ["source"] = sourceSurface, @@ -893,11 +910,15 @@ public sealed class GodotAdvHost : IHost // interval by sixteen and step alpha by one. This reproduces its blocking wall-clock duration. private void RunLegacyScreenTransition( IReadOnlyList source, IReadOnlyList target, - long start, long intervalArgument, Dictionary timelineDetail) + long start, long intervalArgument, bool forceEndpoint, + Dictionary timelineDetail) { long duration = LegacyScreenTransitionTiming.DurationMilliseconds(intervalArgument); lock (_screenTransitionLock) - _screenTransition = new LegacyScreenTransition(source, target, start, duration); + _screenTransition = new LegacyScreenTransition(source, target, start, duration) + { + Forced = forceEndpoint, + }; _foregroundGfx = null; System.Threading.Interlocked.Exchange(ref _transitionStartedAtMs, start); System.Threading.Interlocked.Exchange(ref _presentRequested, 1); @@ -906,6 +927,7 @@ public sealed class GodotAdvHost : IHost timelineDetail["duration_ms"] = duration; timelineDetail["source_objects"] = source.Count; timelineDetail["target_objects"] = target.Count; + timelineDetail["forced"] = forceEndpoint; _timeline?.State("screen-transition-start", timelineDetail); bool scriptSuspended = SuspendScriptForPresentation(); try diff --git a/godot/Main.cs b/godot/Main.cs index 5491799..5d1c4af 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -656,6 +656,14 @@ public partial class Main : Godot.Control GetViewport().SetInputAsHandled(); return; } + // Native blocking effect services poll logical action 4 before ADV hotspot dispatch. + // Consume the trigger here so a retained hotspot under the transition cannot steal it. + if (mb.Pressed && action == 4 && _host.IsTransitionWaiting) + { + _host.SignalInput(); + GetViewport().SetInputAsHandled(); + return; + } // AGE exposes mouse buttons twice: op 0x108 reads the raw bitmask while op 0xff translates // the held physical button through the script-configured logical action map. if (mb.Pressed && action >= 0 && _vm.TryActivateInputActions(1 << action)) @@ -678,7 +686,12 @@ public partial class Main : Godot.Control && Win32VirtualKeyTranslator.TryTranslate(gameplayKey, out int virtualKey)) { int action = _vm.UpdateKeyboardVirtualKeyState(virtualKey, gameplayKey.Pressed); - if (gameplayKey.Pressed && action >= 0 && _vm.TryActivateInputActions(1 << action)) + if (gameplayKey.Pressed && action == 4 && _host.IsTransitionWaiting) + { + _host.SignalInput(); + GetViewport().SetInputAsHandled(); + } + else if (gameplayKey.Pressed && action >= 0 && _vm.TryActivateInputActions(1 << action)) { GetViewport().SetInputAsHandled(); } @@ -697,7 +710,12 @@ public partial class Main : Godot.Control if (e is InputEventJoypadButton joyButton) { int actionMask = _vm.UpdateJoystickButtonState((int)joyButton.ButtonIndex, joyButton.Pressed); - if (joyButton.Pressed && _vm.TryActivateInputActions(actionMask)) + if (joyButton.Pressed && (actionMask & (1 << 4)) != 0 && _host.IsTransitionWaiting) + { + _host.SignalInput(); + GetViewport().SetInputAsHandled(); + } + else if (joyButton.Pressed && _vm.TryActivateInputActions(actionMask)) { GetViewport().SetInputAsHandled(); } diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index fe06114..616bcf8 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -202,13 +202,13 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "fade-surface-in-from-black" category = "draw" -details = "Port status (2026-07-29): implemented through the blocking legacy full-frame transition host path. An explicit empty/black source endpoint is composited beneath the captured target; timing and terminal-frame publication share the op-0x25 lifecycle." +details = "Port status (2026-07-29, skip lifecycle corrected 2026-07-29): implemented through the blocking legacy full-frame transition host path. An explicit empty/black source endpoint is composited beneath the captured target. An already-active ADV fast-forward channel bypasses the timed service and publishes the target endpoint; logical action 4 forces an in-progress transition endpoint when system:EffectSkipOnClick is enabled." summary = "(surface_slot)(timing_argument) — block while fading from black to a captured full-frame surface. The captured surface remains the terminal frame." noop_headless = false source = "investigation" confidence = "high" depends_on = [0x20c, 0x20d] -evidence = "Ghidra /v2: dispatch slot 0x21 resolves to op_0x21_fade_surface_in_from_black@0x41cb00. First entry sets run-state bit 8, converts timing <=64 to interval=argument ms and alpha step=16 (otherwise interval=argument/16 and step=1), then starts screen_transition_begin@0x439da0 mode 0 with the operand surface. screen_transition_tick@0x43a7a0 draws that surface below an opaque-black scratch surface whose alpha falls 255->0; re-entry finalizes the operand surface. Corpus: 3 sites in EVOLVE, SELSTAGE, and STUDY, all (1,30), approximately 480 ms." +evidence = "Ghidra /v2: dispatch slot 0x21 resolves to op_0x21_fade_surface_in_from_black@0x41cb00. With run_state_flags bit 0x08000000 clear, the handler sets effect-service bit 8, converts timing <=64 to interval=argument ms and alpha step=16 (otherwise interval=argument/16 and step=1), starts screen_transition_begin@0x439da0 mode 0, flushes the initiating trigger, and advances the PC. engine_main_tick_with_exception_policy@0x411840 owns natural progress and, when system:EffectSkipOnClick is enabled, forces action-4 clicks through screen_transition_tick@0x43a7a0 with delta 0x10000000. If fast-forward bit 0x08000000 was already set at dispatch, the handler instead publishes the operand-surface endpoint directly; it is not a post-transition re-entry. Corpus: 3 sites in EVOLVE, SELSTAGE, and STUDY, all (1,30), approximately 480 ms." [[opcode.semantics.args]] i = 1 @@ -229,13 +229,13 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "fade-surface-out-to-black" category = "draw" -details = "Port status (2026-07-29): implemented through the blocking legacy full-frame transition host path. The captured source is composited beneath an explicit empty/black target endpoint; timing and terminal-frame publication share the op-0x25 lifecycle." +details = "Port status (2026-07-29, skip lifecycle corrected 2026-07-29): implemented through the blocking legacy full-frame transition host path. The captured source is composited beneath an explicit empty/black target endpoint. An already-active ADV fast-forward channel bypasses the timed service and publishes black; logical action 4 forces an in-progress transition endpoint when system:EffectSkipOnClick is enabled." summary = "(surface_slot)(timing_argument) — block while fading a captured full-frame surface to black. Black remains the terminal frame." noop_headless = false source = "investigation" confidence = "high" depends_on = [0x20c, 0x20d] -evidence = "Ghidra /v2: dispatch slot 0x22 resolves to op_0x22_fade_surface_out_to_black@0x41cbc0. It uses the same timer/alpha-step conversion as 0x21 and 0x25, then starts screen_transition_begin@0x439da0 mode 1. screen_transition_tick@0x43a7a0 keeps the operand surface opaque and raises the black scratch-surface alpha 0->255; re-entry finalizes a black endpoint. Corpus: 8 sites across ALCHEMY, CHMENU, EVOLVE, FORT, SELSTAGE, STUDY, and SUMMON, all (1,30), approximately 480 ms." +evidence = "Ghidra /v2: dispatch slot 0x22 resolves to op_0x22_fade_surface_out_to_black@0x41cbc0. With run_state_flags bit 0x08000000 clear, it uses the same timer/alpha-step conversion as 0x21/0x25, starts screen_transition_begin@0x439da0 mode 1, flushes the initiating trigger, and advances the PC. screen_transition_tick@0x43a7a0 keeps the operand surface opaque and raises black alpha 0->255; engine_main_tick_with_exception_policy@0x411840 owns natural and system:EffectSkipOnClick-forced completion. If fast-forward was already active at dispatch, the handler publishes black directly; it is not a post-transition re-entry. Corpus: 8 sites across ALCHEMY, CHMENU, EVOLVE, FORT, SELSTAGE, STUDY, and SUMMON, all (1,30), approximately 480 ms." [[opcode.semantics.args]] i = 1 @@ -257,12 +257,12 @@ abi_source = "kelebek+decode-validated" name = "crossfade-surfaces" category = "draw" summary = "(source_surface)(target_surface)(interval_argument) — blocking legacy full-frame mode-4 alpha transition. The native engine composites the captured target surface over the captured source while an 8-bit alpha accumulator advances from 0 to 256." -details = "The handler uses an alpha step of 16 and timer interval=argument when argument <=64. Above 64 it uses step=1 and interval=argument/16. The main loop polls that timer, advances by skipped intervals plus the current interval, and does not resume the script until the target endpoint has been presented. ROOM uses (1,2,10) for button/character entry and exit fades and (1,2,30) for the final fade before returning to TITLE. The port captures retained-frame snapshots whenever op 0x20c presents to a selected offscreen render target, then reproduces this blocking alpha lifecycle in the interactive host." +details = "The handler uses an alpha step of 16 and timer interval=argument when argument <=64. Above 64 it uses step=1 and interval=argument/16. The main loop polls that timer and does not resume ordinary script execution until the target endpoint is presented. An already-active ADV fast-forward channel publishes the target without starting the service; with system:EffectSkipOnClick enabled, logical action 4 forces an in-progress service to the same endpoint. ROOM uses (1,2,10) for button/character entry and exit fades and (1,2,30) for the final fade before returning to TITLE. The port captures retained-frame snapshots whenever op 0x20c presents to a selected offscreen render target, then reproduces the blocking and forced-endpoint lifecycle in the interactive host." noop_headless = false source = "investigation" confidence = "high" depends_on = [0x20c, 0x20d] -evidence = "Ghidra /v2: dispatch handler op_0x25_handler@0x41ce00 sets run-state bit 8, converts operand 3 to timer interval/alpha step (<=64: interval=arg ms and step=16; >64: interval=arg/16 ms and step=1), calls screen_transition_begin@0x439da0 with mode 4, and later screen_transition_finalize@0x4399c0. engine_main_tick_with_exception_policy polls interval_timer_poll_elapsed_steps@0x44d080 and calls screen_transition_tick@0x43a7a0; mode 4 draws source then target with progress alpha and commits target at 0x100. Thus ROOM argument 10 is about 160 ms and argument 30 about 480 ms. ROOM sites: 0x12f,0x7c6,0x856 use (1,2,10); 0x8dc uses (1,2,30). The step branch was corrected after manual timing validation; /v2 annotated and saved 2026-07-21." +evidence = "Ghidra /v2: dispatch handler op_0x25_handler@0x41ce00 checks ADV fast-forward bit 0x08000000. Clear: it sets effect-service bit 8, converts operand 3 to timer interval/alpha step (<=64: interval=arg ms and step=16; >64: interval=arg/16 ms and step=1), starts screen_transition_begin@0x439da0 mode 4, flushes the initiating trigger, and advances the PC. engine_main_tick_with_exception_policy@0x411840 polls interval_timer_poll_elapsed_steps@0x44d080 and calls screen_transition_tick@0x43a7a0; mode 4 draws source then target with progress alpha and commits target at 0x100. With system:EffectSkipOnClick enabled, logical action 4 is consumed and delta 0x10000000 forces that endpoint. If fast-forward was already set at dispatch, the handler calls screen_transition_finalize@0x4399c0 for the target directly; this is not post-transition re-entry. Thus ROOM argument 10 is about 160 ms and argument 30 about 480 ms. ROOM sites: 0x12f,0x7c6,0x856 use (1,2,10); 0x8dc uses (1,2,30). /v2 annotations corrected 2026-07-29." [[opcode.semantics.args]] i = 1 @@ -5588,12 +5588,12 @@ abi_source = "kelebek+decode-validated" name = "mark-frame-yield" category = "control" summary = "Set native run-state bit 0x400; in normal ADV playback this is the retained-presentation render/wait/resume boundary." -details = "SC0000 label_1235a reaches this when 0x1c7/0x1cc are zero. Native run-state bit 0x400 parks the interpreter while gfx_render_frame repeatedly samples finite one-shot object channels and queued surface commands; op 0x224 follows after dirty state clears. Native trace proves AE001D bind, mode-1 0x203, and 0x202 targets complete in one 5 ms batch with no render, then first compose here. The port publishes and waits for visible finite one-shot channels or 0x223 commands; click forcing remains limited to the latter." +details = "SC0000 label_1235a reaches this when 0x1c7/0x1cc are zero. Native run-state bit 0x400 parks the interpreter while gfx_render_frame repeatedly samples finite one-shot object channels and queued surface commands; op 0x224 follows after dirty state clears. When system:EffectSkipOnClick is enabled and animation-service flag bit 0 is clear, logical action 4 clears the service wait, requests the same forced completion as 0x243, renders the terminal retained frame, and consumes the trigger. That request completes ordinary finite color/scale/rotation/translation channels and type-0 surface commands together; op-0x242-detached channels, ambient cycles, and movie masks are excluded. Native trace proves AE001D bind, mode-1 0x203, and 0x202 targets complete in one 5 ms batch with no render, then first compose here." noop_headless = false source = "investigation" confidence = "high" depends_on = [0x223, 0x1c7, 0x1cc] -evidence = "Ghidra handler 0x417520 records the 1-dword instruction length and ORs ctx+0xa0ce4 with 0x400. capture_presentation_trace.py: after 0x125a6 render, 0xcb8e/0xcb98 bind and 0xd5a/0xd63/0xd73/0xd8a mode+targets execute without render; repeated gfx_render_frame begins only at 0x21c. 2026-07-10." +evidence = "Ghidra handler 0x417520 records the 1-dword instruction length and ORs ctx+0xa0ce4 with 0x400. engine_main_tick_with_exception_policy@0x411840 branch 0x4123d1 polls gfx_animation_service_poll; its active branch reads EffectSkipOnClick@0x570f60, rejects gfx_animation_service_flags bit 0, polls/consumes action-4 bit 0x10, clears run-state 0x400, calls gfx_request_force_complete_and_reset_anim_clock@0x4076c0, then gfx_render_frame. capture_presentation_trace.py: after 0x125a6 render, 0xcb8e/0xcb98 bind and 0xd5a/0xd63/0xd73/0xd8a mode+targets execute without render; repeated gfx_render_frame begins only at 0x21c. 2026-07-10, click lifecycle refined 2026-07-29." [[opcode]] op = 0x21d @@ -6732,11 +6732,12 @@ abi_source = "kelebek+decode-validated" name = "set-gfx-animation-service-flags" category = "draw" summary = "Replace the retained graphics animation-service flags with operand 1. BTL brackets combat presentation with values 1 and 0; GAMECLEAR uses 3 and 0." +details = "Bit 0 disables the run-state-0x400 EffectSkipOnClick branch while finite retained presentation is active. Bit 1 independently suppresses opcode 0x243 and the click branch's shared force-complete/clock-reset request. BTL brackets combat presentation with 1/0 and GAMECLEAR uses 3/0, protecting those service-owned animations from ordinary ADV click completion." noop_headless = false source = "investigation" confidence = "high" depends_on = [] -evidence = "Ghidra /v2: op_0x24e_handler@0x425070 writes operand 1 directly to EngineCtx.gfx_animation_service_flags at +0x51b80. The mapped field is also read by op 0x243: bit 1 suppresses its force-complete/clock-reset request." +evidence = "Ghidra /v2: op_0x24e_handler@0x425070 writes operand 1 directly to EngineCtx.gfx_animation_service_flags at +0x51b80. engine_main_tick_with_exception_policy@0x411840 tests bit 0 before polling action 4 in the run-state-0x400 EffectSkipOnClick branch. gfx_request_force_complete_and_reset_anim_clock@0x4076c0 tests bit 1 before setting force-complete and zeroing the service clock; opcode 0x243 calls the same worker." [[opcode.semantics.args]] i = 1