diff --git a/.gitattributes b/.gitattributes index 759ba15..9f533f0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,4 @@ docs/opcode-reference.md whitespace=trailing-space,space-before-tab,cr-at-eol +docs/global-reference.md whitespace=trailing-space,space-before-tab,cr-at-eol +docs/engine-ctx-reference.md whitespace=trailing-space,space-before-tab,cr-at-eol tools/age_opcodes_himegari.py whitespace=trailing-space,space-before-tab,cr-at-eol diff --git a/docs/engine-ctx-reference.md b/docs/engine-ctx-reference.md index 7e2de26..4f226e6 100644 --- a/docs/engine-ctx-reference.md +++ b/docs/engine-ctx-reference.md @@ -26,6 +26,7 @@ Struct `EngineCtx`, size `0xa1000`. Applied to the Ghidra `/v2` image (dispatch- | `0x53d60` | `ctx_record_base` | `void*` | 0x78-byte context-record array base (coroutine/script contexts) | | `0x53d64` | `gfx_obj_record_array` | `void*` | gfx object-record array (field[0]=0xffffffff free; cmd-type at rec+0x24) | | `0x53d88` | `cmd_type_table` | `int` | per-object cmd-type column base (write *(0x53d88 + curCtx*0x78)) | +| `0x55104` | `auto_message_enabled` | `int` | ADV Auto mode flag; op 0x1b6 reads, op 0x1b7 writes, adv_input_service_poll consumes | | `0x55120` | `anti_tamper_a` | `int` | anti-tamper checksum operand | | `0x55124` | `anti_tamper_b` | `int` | anti-tamper checksum operand | | `0x5512c` | `anti_tamper_fp` | `int` | anti-tamper (import fn ptr / result) | @@ -37,6 +38,7 @@ Struct `EngineCtx`, size `0xa1000`. Applied to the Ghidra `/v2` image (dispatch- | `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 | +| `0x6dbe4` | `adv_auto_voice_pending` | `int` | set by play-voice ops when voice service exists; Auto waits for voice completion then arms AutoMessageTime0; op 0x1bc resets | | `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 8f82c1b..05da95c 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -1054,6 +1054,108 @@ NOT the predicted coroutine yield (`0x140`/`~0x50f`); the oracle reports whateve surfaced an earlier state hole. **This is now the repeatable way to localize a mis-modeled op/state.** Phase-2 extension (deferred): effects-diff (global-bank / gfx-registry writes) for branchy scenes. +### ADV control-strip buttons and native hotspot callbacks (2026-07-18) + +The five controls at the lower right of ordinary ADV scenes are script-driven retained UI, backed by +`SO001.AGF` in system surface slot 17. They are not Godot-style widgets and opcode `0x90` is not an +immediate hit-test branch. The shared ADV routine copied into all 301 ADV scripts registers five rectangles +at `(684|706|728|750|772,572)`, nominally `20x20`, plus three keyboard/pad records. Native +`op_0x90_handler@0x41fc80` stores `(x,y,x+w,y+h)` and three callback PCs through +`input_hotspot_register_rect_callbacks@0x403d70`; `op 0x94` then arms the input service. The bounds are +compared inclusively. + +`input_hotspot_update_cursor_hit@0x403e90` publishes the first matching record index. +`input_hotspot_poll_hover_callback@0x4040b0` maps the stored PCs exactly: operand 5 on pointer entry and +operand 6 on pointer exit. A direct move from one hotspot to another dispatches the old record's exit first +and the new record's entry on the next poll. `input_hotspot_take_click_callback@0x404330` resets the +registry and returns operand 7 on activation. The interpreter temporarily redirects the current script PC +to these callbacks; registration itself returns normally. Companion op `0x97` finds an identical registered +rectangle and binds its fifth operand as an input-bit index; SC0000 uses bits 0, 8, and 7 for its three +`1x1` keyed records. + +The five entry callbacks set `G[0x6c9..0x6cd]` one at a time and call the common redraw routine at SC0000 +`label_11ffa`; exit clears the corresponding flag and redraws. The redraw takes three pieces from SO001: + +- the always-visible base strip from source `(0,254,114,25)` to screen `(681,570)`; +- one 132x27 textual tooltip from source y=227; and +- the same generic 20x20 hover overlay from source `(114,254)` over the selected icon, rendered through + mode-1 `0x203` at alpha `0x80`. + +This gives the exact left-to-right behavior: + +| x | Hover flag | SO001 tooltip source x | Label/action on activation | +|---:|---|---:|---| +| 684 | `G[0x6c9]` | 396 | **History** — cancel hotspot wait, call `HISTORY.BIN`, then rebuild chrome | +| 706 | `G[0x6ca]` | 264 | **Auto message** — `0x1b6` read, toggle, `0x1b7` write | +| 728 | `G[0x6cb]` | 0 | **Message skip** — `0x88(1)` enables all-message skip | +| 750 | `G[0x6cc]` | 132 | **Read-message skip** — `0x1cb` read, toggle, `0x1ca` write `message:ReadTextSkip` | +| 772 | `G[0x6cd]` | 528 | **Hide window** — `0x199` yields into the ADV/HIDEWIN coroutine flow | + +The redraw also overlays persistent active-state cells for Auto, Message skip, and Read-message skip from +SO001 source x `154/174/194` at y=254. After the four state/coroutine actions, the script waits 100 ms, +reads the virtual cursor with `0x109`, alternates its y by one pixel, writes it with `0x10a`, and resets +transient skip/input state through `0x101`. That deliberate cursor jiggle re-arms entry/exit processing. + +**No hover sound.** Manual correction on 2026-07-18 confirms these five ADV controls are silent on hover. +This matches the static evidence: none of the entry/exit callbacks executes `0xb4/0xb5` or another script +audio opcode, and the decoded native registration/hover-dispatch chain contains no audio call. The port +should not invent a sound asset or host audio event for this interaction. + +The `/v2` Ghidra image names/comments the opcode handlers, registry/hit-test/dispatch helpers, message-skip, +auto-message, read-skip, cursor, and coroutine operations described above; saved 2026-07-18. + +**Port implementation (2026-07-18).** The C# VM now models the per-frame registry and dispatches its three +local callbacks on the VM thread through a callback-only host wake channel, leaving the surrounding ADV input +wait parked. Godot feeds scaled native-screen pointer coordinates and consumes activation before ordinary +page advance. The blocking-host model retains the registered definitions across a normal action callback to +represent the native scheduler's subsequent shared-registration pass; explicit op `0x93` still clears them, +and callbacks such as History rebuild through their script path. The existing bytecode therefore owns the +SO001 hover/active redraw rather than a parallel widget layer; callback completion publishes one retained +frame even though the enclosing wait remains static. `0x1b6`/`0x1b7` are implemented as VM service +state for the first Auto action bridge; timed automatic page advance remains separate follow-up work. + +**Port correction from manual validation (2026-07-18).** Merely implementing op `0x90` was insufficient in +the cold single-scene harness. The visible SO001 strip is drawn independently, while `jcc@0x8d` skips its five +rectangles when inherited `G[0x6c1]` is zero; the existing trace did exactly that. Even with the native value +one, `cancel-hotspot-wait@0x622` clears the early pass before page-one `wait-for-input@0x83c`, and the native +ADV coroutine later republishes it. Godot now seeds `adv_chrome_enabled=1` as part of the same bounded +SYSTEM4 chrome bootstrap as SO000/SO001. The blocking VM retains canceled definitions only as inactive +coroutine templates, replaces them if script registration runs first, and otherwise re-arms them at a stable +message wait. A full SC0000 regression proves the real History enter callback and retained-frame publication, +not just a synthetic registry path. + +### ADV Auto-message timing and voice completion (2026-07-18) + +Auto advance is a native input-service policy, not a script sleep or a fixed synthetic click. The native +state is split between `ctx+0x55104` (`auto_message_enabled`) and `ctx+0x6dbe4` +(`adv_auto_voice_pending`). `op_0xc4_handler@0x420610` sets the latter when it queues voice playback, while +`op_0x1bc_handler@0x416c20` clears it at the next message boundary. + +`op_0x72_handler@0x41e690` arms `message:AutoMessageTime1` when Auto is enabled and the current message has +no queued voice. For a voiced message, `adv_input_service_poll@0x411230` instead waits until the native voice +service reports playback complete, clears `adv_auto_voice_pending`, and then arms +`message:AutoMessageTime0`. Expiry follows the same wait-release path as ordinary ADV input. Both branches +substitute 100 ms when their configuration getter returns zero. + +The two settings are script-visible rather than constants embedded in the wait handler: + +- op `0x1b8` reads selector 0 = post-voice `AutoMessageTime0`, selector 1 = unvoiced + `AutoMessageTime1`; +- op `0x1b9` writes the same selectors; +- `CONFIG.BIN` initializes them to 500 ms and 2000 ms respectively, and its UI adjusts either setting in + 500-ms steps over 500..9500 ms. + +**Port implementation.** The VM retains the enable bit, both configured delays, and per-message voice flag, +then supplies their live state to the blocking host wait. `GodotAdvHost` polls a small deterministic timer +from the existing monotonic frame clock. Unvoiced pages wait Time1; voiced pages remain parked through actual +`AudioStreamPlayer` playback and then wait Time0. A queued/started/completed generation counter closes the +deferred-call race between the VM thread queuing a voice and Godot beginning playback. Turning Auto off +cancels an armed deadline, and turning it back on starts a fresh one. This keeps timing in the host service +boundary and configuration in the VM, without scene offsets, wall-clock sleeps, or Auto-specific input +injection. Timer and VM regressions cover both paths, exact deadlines, disable/re-enable, the native zero +fallback, `0x1b8`/`0x1b9`, and the `0xc4`/`0x1bc` voice-state lifecycle. The `/v2` handlers and input poller +are named/commented and saved. + ### ADV retained text — ops `0x7a` / `0x204` and show-text publication (2026-07-10) The SC0000 textbox uses two related native paths under the text manager at `ctx+0x14940`: diff --git a/docs/global-reference.md b/docs/global-reference.md index c41e21b..550b3d2 100644 --- a/docs/global-reference.md +++ b/docs/global-reference.md @@ -1,7 +1,7 @@ # Global Variable Reference (generated) -16415 globals (70 curated, 16345 auto shape-inferred). Source of truth: `vm-map/globals.toml`. +16416 globals (71 curated, 16345 auto shape-inferred). Source of truth: `vm-map/globals.toml`. ## choice-output @@ -3522,12 +3522,13 @@ | address | name | conf | source | usage | |---|---|---|---|---| | `0x6be` | — | low | inference | ADV message-window / text-render state in the 0x6bx-0x6cx chrome cluster (passed to render helpers u0041F9C0/u00415F70 alongside 0x6c3). NOT a story flag — high scene-reach is from the shared render chrome. Branch-read in 146 scenes. | +| `0x6c1` | adv_chrome_enabled | high | investigation | Inherited SYSTEM4 UI-boot flag. Standard ADV scripts register the five visible SO001 control-strip pointer rectangles only while nonzero; zero skips directly to the three off-screen keyboard/pad records. The Phase-A single-scene Godot bootstrap seeds the native observed value 1. | | `0x6c3` | — | low | inference | ADV message-window / text-render state in the 0x6bx-0x6cx chrome cluster (`mov 0x6c3,` then `u00415F70(0x6c3)` right after draw-texture; paired with 0x6be). NOT a story flag. Branch-read in 136 scenes. | -| `0x6c9` | ui_toggle_0 | med | investigation | ADV-chrome hotspot button toggle (op 0x90 site, near-universal across scenes). | -| `0x6ca` | ui_toggle_1 | med | investigation | ADV-chrome hotspot button toggle. See ui_toggle_0. | -| `0x6cb` | ui_toggle_2 | med | investigation | ADV-chrome hotspot button toggle. See ui_toggle_0. | -| `0x6cc` | ui_toggle_3 | med | investigation | ADV-chrome hotspot button toggle. See ui_toggle_0. | -| `0x6cd` | ui_toggle_4 | med | investigation | ADV-chrome hotspot button toggle. See ui_toggle_0. | +| `0x6c9` | adv_hover_history | high | investigation | Pointer-hover flag for the standard ADV History button at (684,572). Its op 0x90 enter/leave callbacks set 1/0; the shared SO001 redraw shows the History tooltip plus generic hover overlay while set. | +| `0x6ca` | adv_hover_auto_message | high | investigation | Pointer-hover flag for the standard ADV Auto-message button at (706,572). See adv_hover_history. | +| `0x6cb` | adv_hover_message_skip | high | investigation | Pointer-hover flag for the standard ADV all-message Skip button at (728,572). See adv_hover_history. | +| `0x6cc` | adv_hover_read_message_skip | high | investigation | Pointer-hover flag for the standard ADV read-message-only Skip button at (750,572). See adv_hover_history. | +| `0x6cd` | adv_hover_hide_window | high | investigation | Pointer-hover flag for the standard ADV Hide-window button at (772,572). See adv_hover_history. | ## unknown diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 2783710..4f2f213 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -6,9 +6,9 @@ ## adv ### 0x72 `wait-for-input` (wait-for-input, argc 1) -- **summary:** (layout_slot) - arm the ADV input wait after text reveal completes; activates the wait indicator configured for the selected text layout by op 0x73. +- **summary:** (layout_slot) - arm the ADV input wait after text reveal completes; activate the configured wait indicator and, while Auto is enabled, arm the appropriate Auto-message timer. - **grounding:** source=investigation, confidence=high -- **evidence:** Ghidra /v2: op_0x72_handler@0x41e690 fetches operand 1 and calls FUN_00453120(text_manager, layout_slot, -1, &state), then sets the input-wait run-state flags. FUN_00453120 resolves layout slot 0 as current and consumes the indicator descriptor at layout+0x3c configured by op 0x73. SYSTEM4 layout 1 uses SO000's bat strip; the click that completes show-text is consumed before this opcode is reached. +- **evidence:** Ghidra /v2: op_0x72_handler@0x41e690 fetches operand 1 and calls FUN_00453120(text_manager, layout_slot, -1, &state), then sets the input-wait run-state flags. FUN_00453120 resolves layout slot 0 as current and consumes the indicator descriptor at layout+0x3c configured by op 0x73. SYSTEM4 layout 1 uses SO000's bat strip; the click that completes show-text is consumed before this opcode is reached. The handler also checks ctx+0x55104 (Auto enabled): when ctx+0x6dbe4 has no pending voice it arms the timer with message:AutoMessageTime1, substituting 100 ms for configuration value zero. adv_input_service_poll@0x411230 waits for an active voice to finish and then arms AutoMessageTime0, likewise with a 100-ms zero fallback. ### 0x73 `configure-adv-wait-indicator` (configure-adv-wait-indicator, argc 10) - **summary:** (layout_slot)(dst_x)(dst_y)(surface_slot)(src_x)(src_y)(cell_w)(cell_h)(terminal_frame)(frame_period_ms) - configure the animated marker shown while the selected ADV layout waits for input. @@ -114,6 +114,11 @@ 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. +### 0x199 `yield-adv-coroutine` (u00414D50, argc 0) +- **summary:** Yield/re-enter the registered ADV coroutine handler. The fifth standard chrome button uses this transition to enter the HIDEWIN/window-hidden flow. +- **grounding:** source=investigation, confidence=med +- **evidence:** Ghidra /v2: op_0x199_yield_adv_coroutine@0x416440 selects the registered coroutine yield-A or yield-B PC according to ctx+0x6dbc8, saves the current resume offset/state, and redirects the current frame PC. SC0000's x=772 ADV button invokes it; the SO001 tooltip at source x=528 reads Window hide, and the surrounding coroutine calls HIDEWIN.BIN. + ### 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 @@ -328,31 +333,97 @@ The handler requires an existing destination texture, allocates/reuses a 0x478-b ## input -### 0x90 `hotspot-branch` (u0041BEB0, argc 7) -- **summary:** cursor/input hotspot hit-test: rect (x,y,w,h) -> 3-way branch on interaction, else fall through to pc+1 +### 0x88 `set-message-skip` (u0041B290, argc 1) +- **summary:** (enabled) - set the all-message skip state. Nonzero arms ADV fast-forward; zero clears the associated native run-state bit. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x88_set_message_skip@0x41f130 writes operand 1 to ctx+0x13dc and ctx+0x550fc; nonzero also sets ctx+0x6da78=1, while zero clears run-state bit 0x08000000. adv_interpreter_tick injects input bit 0x40 while ctx+0x13dc is nonzero and cancels the state according to set:CancelMesSkipOnClick. SC0000's Message-skip button passes 1. + +### 0x90 `register-hotspot-callbacks` (u0041BEB0, argc 7) +- **summary:** (x)(y)(w)(h)(on_enter_pc)(on_leave_pc)(on_activate_pc) - append an inclusive cursor rectangle and three callback PCs to the current script frame's native input registry. This opcode only registers; the ADV input service dispatches callbacks later. - **grounding:** source=investigation, confidence=high, noop_headless=True -- **depends on:** 0x1f4, 0x1f5 - **depended on by:** 0x97 -- **evidence:** all 301 ADV scripts contain the identical 8 sites; enc.len 15 lands pc+1 on the fall-through stmt (design-confirmed); fall-through = correct headless no-input path, proven by 279 CLEAN dialogue scenes +- **evidence:** Ghidra /v2: op_0x90_handler@0x41fc80 fetches x/y/w/h, forms x+w/y+h, fetches operands 5/6/7, and calls input_hotspot_register_rect_callbacks@0x403d70 on the current frame registry. The worker appends rect + callbacks to arrays and returns without changing PC. input_hotspot_poll_hover_callback@0x4040b0 returns operand 5 on entry and operand 6 on exit; input_hotspot_take_click_callback@0x404330 resets the registry and returns operand 7 on activation. All 301 ADV scripts contain the identical five control-strip records plus three keyed records. op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it "ukn" noting args -5-7 are code locations. Corpus analysis (all 301 ADV scripts) resolves it: +5-7 are code locations. Native RE plus corpus analysis resolves it: +- The opcode APPENDS a record; it is not an immediate branch. Rect bounds are stored as + `(x,y,x+w,y+h)` and the cursor hit-test compares them inclusively. +- The later input service maps targets exactly: target A = pointer enter, target B = pointer leave, + target C = activation/click. Moving directly between records emits leave first, then enter on the + next service poll. Activation consumes/resets the registry before dispatching target C. - Two forms, both ONLY in one shared ADV-chrome subroutine copied into every ADV script: * Mode A (1505 = 5x301): immediate x,y,w,h with w=h=20; the five on-screen buttons at - (684|706|728|750|772, 572), each setting one of G[0x6c9..0x6cd] to 1 / 0 / 0+run-action - (reads as hover-enter / hover-leave / click). All 3 targets real. - * Mode B (903 = 3x301): local-int operands, w=h=1, only tgt_c real -- a keyed 2-way input test. + (684|706|728|750|772, 572): History, Auto message, Message skip, Read-message skip, and + Hide window. Their enter/leave callbacks set G[0x6c9..0x6cd] to 1/0 and redraw SO001's + tooltip + generic 20x20 translucent hover overlay; target C runs the per-button action. + * Mode B (903 = 3x301): local-int operands, w=h=1, only tgt_c real. Companion op 0x97 binds + input-bit ids 0, 8, and 7 to these records for keyboard/pad activation. - Every one of the 301 scripts has EXACTLY 8 sites (5 A + 3 B); zero scene-specific use. -- Falls through (pc+1) when nothing matches -- design-confirmed (0xd0 + 15 dwords = 0xdf = label_df). -- Headless (no cursor/input) => fall through => vm0 stub already correct; the 12 EMPTY sweep scenes +- Registration returns normally to pc+1; later callbacks temporarily redirect execution. +- Headless (no cursor/input) can ignore registration; the 12 EMPTY sweep scenes are gated by state + this input-wait chrome, NOT by unmodelled 0x90. Model live in A2. -### 0x97 `hotspot-reg?` (u0041C150, argc 5) -- **summary:** companion register-hotspot / set-widget-action (argc5: v1 v2 1 1 ; NO code targets) -- **grounding:** source=inference, confidence=med, noop_headless=True +### 0x93 `cancel-hotspot-wait` (u00415040, argc 0) +- **summary:** Reset the current frame's hotspot registry/input wait and clear native run-state bit 0x00800000. Used before opening History, Menu, or HIDEWIN flows. +- **grounding:** source=investigation, confidence=high, noop_headless=True +- **evidence:** Ghidra /v2: op_0x93_cancel_hotspot_wait@0x416670 clears run-state bit 0x00800000, calls input_hotspot_reset@0x404130, and clears the active flag at ctx+0xc6e4 (or sets the pending flag at +0xc6e0 when already inactive). + +### 0x94 `arm-hotspot-wait` (u00415090, argc 0) +- **summary:** Arm native hotspot input processing after the script has registered its rectangles. +- **grounding:** source=investigation, confidence=high, noop_headless=True +- **evidence:** Ghidra /v2: op_0x94_arm_hotspot_wait@0x4166d0 sets ctx+0xc6e4=1 and calls input_hotspot_arm_cursor_tracking@0x404230 with 10000. SC0000 executes it immediately after the five control-strip and three keyed registrations. + +### 0x97 `bind-hotspot-key` (u0041C150, argc 5) +- **summary:** (x)(y)(w)(h)(input_bit) - find the already-registered rectangle with identical bounds and bind an input/key bit number to it. +- **grounding:** source=investigation, confidence=high, noop_headless=True - **depends on:** 0x90 -- **evidence:** interleaves with 0x90 in the shared ADV-chrome subroutine; trailing imm = action id 0x0/0x7/0x8; same widget cluster as 0x90/0x91/0x92/0x95; confirm via frida +- **evidence:** Ghidra /v2: op_0x97_bind_hotspot_key@0x41ff30 builds the same inclusive rect as op 0x90 and calls input_hotspot_bind_key_bit@0x403f50. That worker searches registered rects for exact equality and stores operand 5 in the record's key-bit array. SC0000 binds bits 0, 8, and 7 to its three 1x1 keyed records. + +### 0x101 `reset-message-skip-input` (u00415BF0, argc 0) +- **summary:** Reset transient message-skip/input service state after an ADV chrome action. +- **grounding:** source=investigation, confidence=med +- **evidence:** Ghidra /v2: op_0x101_reset_message_skip_input@0x4170a0 resets the input state rooted at ctx+0xa0ce8, clears run-state bit 0x08000000, zeroes ctx+0xa0ce8, and writes ctx+0x6da74=1 / ctx+0x6da80=0. The Auto, Message-skip, Read-skip, and Hide-window click callbacks invoke it after their 100 ms cursor re-arm sequence. + +### 0x109 `get-cursor-virtual` (u00415EC0, argc 2) +- **summary:** (out_x)(out_y) - read the OS cursor and convert it into AGE's virtual-screen coordinates. +- **grounding:** source=investigation, confidence=high, noop_headless=True +- **evidence:** Ghidra /v2: op_0x109_get_cursor_virtual@0x428bb0 calls the cursor-position helper, converts client/display coordinates through the active VirtualFullScreen transform, and writes x/y to operands 1/2. The ADV chrome callbacks preserve x and then move y by alternating -1/+1 before op 0x10a. + +### 0x10a `set-cursor-virtual` (u0041E540, argc 2) +- **summary:** (x)(y) - convert AGE virtual-screen coordinates to client/screen coordinates and move the OS cursor. +- **grounding:** source=investigation, confidence=high, noop_headless=True +- **evidence:** Ghidra /v2: op_0x10a_set_cursor_virtual@0x421590 maps virtual coordinates through the active VirtualFullScreen geometry and calls SetCursorPos. SC0000 alternates the cursor by one vertical pixel after state-changing ADV button clicks so the hover state re-enters cleanly. + +### 0x19a `get-message-skip` (u00414E50, argc 1) +- **summary:** (out) - return the current all-message skip state set by op 0x88. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x19a_get_message_skip@0x4271b0 writes ctx+0x550fc to operand 1. The shared ADV redraw routine uses it to select SO001's active Message-skip icon at x=728. + +### 0x1b6 `get-auto-message` (u00414F60, argc 1) +- **summary:** (out) - return whether automatic message advance is enabled. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x1b6_get_auto_message@0x4271e0 writes `(ctx+0x55104 != 0)` to operand 1. FUN_00411230 consumes the same field to arm AutoMessageTime0/1 timers; the shared ADV redraw routine uses it for the active Auto icon. + +### 0x1b7 `set-auto-message` (u0041B640, argc 1) +- **summary:** (enabled) - enable or disable automatic message advance. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x1b7_set_auto_message@0x41f560 stores `(operand1 != 0)` at ctx+0x55104. SC0000's x=706 Auto button reads the current value through 0x1b6, toggles modulo 2, then writes it through this opcode. + +### 0x1b8 `get-auto-message-time` (u0041B670, argc 2) +- **summary:** (selector)(out) - read an Auto-message delay from engine configuration: selector 0 = post-voice AutoMessageTime0, selector 1 = unvoiced AutoMessageTime1. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x1b8_handler@0x427210 dispatches selector 0 to config key message:AutoMessageTime0 and selector 1 to message:AutoMessageTime1, then writes the result to operand 2. CONFIG.BIN uses it to bound the two UI settings. + +### 0x1b9 `set-auto-message-time` (u0041B710, argc 2) +- **summary:** (selector)(milliseconds) - write an Auto-message delay to engine configuration: selector 0 = post-voice AutoMessageTime0, selector 1 = unvoiced AutoMessageTime1. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x1b9_handler@0x41f5a0 dispatches selector 0/1 to config setters for message:AutoMessageTime0/1. CONFIG.BIN initializes them to 500/2000 ms and adjusts each in 500-ms steps within 500..9500. + +### 0x1bc `reset-message-voice-state` (u00415670, argc 0) +- **summary:** Reset the per-message queued-voice flag used by ADV Auto timing. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x1bc_handler@0x416c20 writes zero to ctx+0x6dbe4 (adv_auto_voice_pending). Opcode 0xc4 sets that field when voice playback is queued; adv_input_service_poll consumes it to select the post-voice AutoMessageTime0 path. ### 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. @@ -360,11 +431,17 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it " - **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 +### 0x1ca `set-read-message-skip` (u0041B9B0, argc 1) +- **summary:** (enabled) - set the engine setting `message:ReadTextSkip`, which skips only previously read text. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x1ca_set_read_message_skip@0x41f880 calls the engine setting interface's setter for `message:ReadTextSkip` with operand 1. SC0000's x=750 Read-message-skip button toggles the value read by op 0x1cb. -### 0x1bc `block-mark` (u00415670, argc 0) -- **summary:** zero-arg; follows jcc/mov, precedes mov/ret — block boundary -- **grounding:** source=inference, confidence=high, noop_headless=True +### 0x1cb `get-read-message-skip` (u00414FD0, argc 1) +- **summary:** (out) - read the engine setting `message:ReadTextSkip`. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x1cb_get_read_message_skip@0x4272f0 calls the engine setting interface's getter for `message:ReadTextSkip` and writes the result to operand 1. The shared ADV redraw routine uses it for the active Read-message-skip icon. + +## marker ### 0x1bf `call-end` (u004156C0, argc 0) - **summary:** zero-arg; call->0x1bf->stmt-end — end-of-call-statement marker @@ -381,12 +458,10 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it " ### 0x1f4 `stmt-begin` (u004160D0, argc 0) - **summary:** zero-arg; opens scripts, pairs with stmt-end 0x1f5 - **grounding:** source=investigation, confidence=high, noop_headless=True -- **depended on by:** 0x90 ### 0x1f5 `stmt-end` (u00416120, argc 0) - **summary:** zero-arg; precedes exit/next-stmt, pairs with 0x1f4 - **grounding:** source=investigation, confidence=high, noop_headless=True -- **depended on by:** 0x90 ### 0x21b `line-id?` (u004213E0, argc 1) - **summary:** 1 imm; mov->0x21b->stmt-end; near save/load-messkip — likely line/stmt id, verify not msg-control @@ -581,10 +656,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 -### 0x88 `u0041B290` (u0041B290, argc 1) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x8b `u0041B3D0` (u0041B3D0, argc 1) - **summary:** — - **grounding:** source=kelebek, confidence=low @@ -593,14 +664,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it " - **summary:** — - **grounding:** source=kelebek, confidence=med -### 0x93 `u00415040` (u00415040, argc 0) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - -### 0x94 `u00415090` (u00415090, argc 0) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0xa0 `jcc` (jcc, argc 3) - **summary:** — - **grounding:** source=kelebek, confidence=med @@ -693,10 +756,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 -### 0x101 `u00415BF0` (u00415BF0, argc 0) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x107 `u0041E500` (u0041E500, argc 2) - **summary:** — - **grounding:** source=kelebek, confidence=low @@ -705,14 +764,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 -### 0x109 `u00415EC0` (u00415EC0, argc 2) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - -### 0x10a `u0041E540` (u0041E540, argc 2) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x10b `u0041E5A0` (u0041E5A0, argc 2) - **summary:** — - **grounding:** source=kelebek, confidence=low @@ -829,14 +880,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 -### 0x199 `u00414D50` (u00414D50, argc 0) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - -### 0x19a `u00414E50` (u00414E50, argc 1) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x19b `u00414E80` (u00414E80, argc 0) - **summary:** — - **grounding:** source=kelebek, confidence=low @@ -933,22 +976,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 -### 0x1b6 `u00414F60` (u00414F60, argc 1) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - -### 0x1b7 `u0041B640` (u0041B640, argc 1) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - -### 0x1b8 `u0041B670` (u0041B670, argc 2) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - -### 0x1b9 `u0041B710` (u0041B710, argc 2) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x1ba `u0041D850` (u0041D850, argc 2) - **summary:** — - **grounding:** source=kelebek, confidence=low @@ -969,14 +996,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it " - **summary:** — - **grounding:** source=kelebek, confidence=med -### 0x1ca `u0041B9B0` (u0041B9B0, argc 1) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - -### 0x1cb `u00414FD0` (u00414FD0, argc 1) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x1ce `u0041B9F0` (u0041B9F0, argc 1) - **summary:** — - **grounding:** source=kelebek, confidence=low diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index 95e6ebe..2413123 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -1469,3 +1469,66 @@ that lifecycle. The loader consequently builds base `(0,-500)`, so the retained at `(0,50)`. Op `0x1ff` was also completed as the native immediate current-translation setter at obj+0x16c. Validation: engine **155/155**, zero-warning Godot and CLI builds, and a full synchronized run reaching the exact page-89 wait with `BG004D.AGF` drawn at `(0,50)` alongside the character sprite. + +### ADV control-strip button investigation complete (2026-07-18) + +Native RE and the shared SC0000 routine now establish the implementation contract for the five visible +lower-right controls: op `0x90` registers enter/leave/activation callbacks, SO001 supplies the base icons, +tooltips, generic alpha-0x80 hover overlay, and persistent state overlays, and the activation callbacks map +left-to-right to History, Auto message, all-message Skip, read-message Skip, and Hide window. Manual +correction confirms the controls are silent on hover, matching the absence of any audio call in the path. +Relevant input/state opcodes and `G[0x6c9..0x6cd]` are named in their canonical registries; the `/v2` +Ghidra image is annotated and saved. Full native evidence is in +[`engine-re.md`](engine-re.md#adv-control-strip-buttons-and-native-hotspot-callbacks-2026-07-18). + +### ADV control-strip pointer callbacks + Auto bridge implemented (2026-07-18) + +The first bounded runtime slice is now live. `ExecFrame` owns the native-style hotspot registry populated by +ops `0x90`, `0x94`, and `0x97`; Godot routes 800x600 virtual pointer motion into it and consumes an armed +hotspot click before the ordinary ADV page-advance path. Enter, leave, and activation targets execute as +temporary local callbacks on the VM thread while the enclosing `wait-for-input` remains parked. A dedicated +host wake channel services those callbacks without releasing the page semaphore. Inclusive bounds, direct +enter/leave dispatch, activation/re-arm, nested local calls, click consumption, and registry lifetime have +focused regressions. Callback completion requests one retained-frame publication, so static waits show the +new tooltip/glow immediately without continuously recompositing the 800x600 canvas. + +This does not introduce duplicate Godot widgets or hover assets: the callbacks already present in every ADV +script redraw the retained SO001 tooltip/glow/state layers. Ops `0x1b6`/`0x1b7` now retain Auto-message state, +so the x=706 action toggles and redraws its active overlay. Auto's timed page advance is not part of this +slice; nor are keyboard/pad activation and the remaining four action services. Validation: engine 157/157, +zero-warning Godot build, threaded `SELFTEST OK`, and SC0000 static coverage 100/129 handled (77.5%; +88 implemented + 12 safe no-ops, 29 GAP ops / 70 instructions). + +**Next:** manually validate hover and repeated Auto clicks in a normal SC0000 run, then add Auto timing and +the History/all-skip/read-skip/Hide service behavior one bounded action at a time. No hover-audio work is +required. + +**Manual-validation correction (2026-07-18).** The first build displayed the SO001 strip but did not hover. +Its cold profile state took `jcc@0x8d` with `G[0x6c1]==0`, skipping the five visible registrations at +`0x94..0xd0`; later op `0x93@0x622` also canceled the one early registration pass before the first dialogue +wait. Native SYSTEM4 supplies `G[0x6c1]=1`, and its ADV coroutine republishes the shared definitions at the +stable wait. The single-scene bootstrap now supplies that exact inherited flag, retains canceled definitions +as inactive coroutine templates, and re-arms them at `wait-for-input`. Called-script frame exit also restores +the parent's armed registry. A real-SC0000 regression reaches the first wait, moves to History `(684,572)`, +executes `label_1a0`, observes `G[0x6c9]=1`, and verifies callback-frame publication. Validation is now engine +159/159, zero-warning Godot build, and threaded `SELFTEST OK`; manual recheck remains the visual gate. + +### ADV Auto timing implemented (2026-07-18) + +The follow-up manual check confirms hover and the script-owned toggles work; Auto previously changed only its +SO001 active overlay because timed release had not yet been implemented. Native RE closes that gap without a +scene-specific shortcut. Auto now uses the engine's two configuration channels: unvoiced pages wait +`AutoMessageTime1` (CONFIG default 2000 ms), while voiced pages wait for real voice completion and then apply +`AutoMessageTime0` (default 500 ms). Ops `0x1b8`/`0x1b9` read/write those delays, op `0xc4` marks a queued +voice, and op `0x1bc` resets the per-message voice state. A configured zero retains the native 100-ms +fallback. + +The implementation keeps VM-owned settings separate from host-owned clock/audio state. The Godot wait loop +polls the existing monotonic frame clock and an interrupt-safe voice generation counter; it does not enqueue +a fake click or sleep the VM thread for a hard-coded duration. Focused regressions cover unvoiced and voiced +deadlines, live Auto disable/re-enable, zero fallback, the time-setting ABI, and voice-state reset. Validation: +engine 165/165, opcode and EngineCtx generators/tests/lints clean, Godot build has no compiler/analyzer +warnings, and threaded `SELFTEST OK`. + +**Next:** manually validate Auto advancing both an unvoiced page and a voiced page, then take the remaining +History/all-message-skip/read-message-skip/Hide actions as separate bounded services. diff --git a/engine/Age.Engine.Tests/AdvAutoAdvanceTimerTests.cs b/engine/Age.Engine.Tests/AdvAutoAdvanceTimerTests.cs new file mode 100644 index 0000000..627d2a1 --- /dev/null +++ b/engine/Age.Engine.Tests/AdvAutoAdvanceTimerTests.cs @@ -0,0 +1,53 @@ +using Age.Engine.Hosting; +using Xunit; + +public class AdvAutoAdvanceTimerTests +{ + [Fact] + public void UnvoicedWait_UsesAutoMessageTime1() + { + var timer = new AdvAutoAdvanceTimer(); + var state = new AdvAutoWaitState(true, false, 500, 2000); + + Assert.False(timer.Poll(state, false, 1000)); + Assert.False(timer.Poll(state, false, 2999)); + Assert.True(timer.Poll(state, false, 3000)); + } + + [Fact] + public void VoicedWait_WaitsForVoiceThenUsesAutoMessageTime0() + { + var timer = new AdvAutoAdvanceTimer(); + var state = new AdvAutoWaitState(true, true, 500, 2000); + + Assert.False(timer.Poll(state, true, 1000)); + Assert.False(timer.Poll(state, true, 9000)); + Assert.False(timer.Poll(state, false, 9000)); + Assert.False(timer.Poll(state, false, 9499)); + Assert.True(timer.Poll(state, false, 9500)); + } + + [Fact] + public void DisablingAuto_CancelsDeadlineAndReenableStartsFresh() + { + var timer = new AdvAutoAdvanceTimer(); + var on = new AdvAutoWaitState(true, false, 500, 2000); + + Assert.False(timer.Poll(on, false, 0)); + Assert.False(timer.Poll(on with { Enabled = false }, false, 1500)); + Assert.False(timer.Poll(on, false, 5000)); + Assert.False(timer.Poll(on, false, 6999)); + Assert.True(timer.Poll(on, false, 7000)); + } + + [Fact] + public void ZeroConfiguration_UsesNativeHundredMillisecondFallback() + { + var timer = new AdvAutoAdvanceTimer(); + var state = new AdvAutoWaitState(true, false, 0, 0); + + Assert.False(timer.Poll(state, false, 0)); + Assert.False(timer.Poll(state, false, 99)); + Assert.True(timer.Poll(state, false, 100)); + } +} diff --git a/engine/Age.Engine.Tests/HotspotInputTests.cs b/engine/Age.Engine.Tests/HotspotInputTests.cs new file mode 100644 index 0000000..6ce20c5 --- /dev/null +++ b/engine/Age.Engine.Tests/HotspotInputTests.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Age.Engine.Hosting; +using Age.Engine.Model; +using Age.Engine.Sys4; +using Age.Engine.Vm; +using Xunit; + +public class HotspotInputTests +{ + private sealed class StopAtFirstWaitException : Exception { } + + private sealed class InteractiveHost : RecordingHost + { + public VirtualMachine Vm = null!; + public bool ClickConsumed; + public bool SecondClickConsumed; + + public override void WaitForInput(int layoutSlot, Func serviceInputCallback) + { + Waits++; + + // Width/height are added to x/y, so the native rectangle includes (30,40). + Vm.UpdatePointer(30, 40); + while (serviceInputCallback()) { } + Assert.Equal(1, Vm.Globals.GetValueOrDefault(0x100)); + + Vm.UpdatePointer(31, 40); + while (serviceInputCallback()) { } + Assert.Equal(1, Vm.Globals.GetValueOrDefault(0x101)); + + Vm.UpdatePointer(30, 40); + while (serviceInputCallback()) { } + ClickConsumed = Vm.TryActivatePointer(30, 40); + while (serviceInputCallback()) { } + SecondClickConsumed = Vm.TryActivatePointer(30, 40); + while (serviceInputCallback()) { } + } + } + + private sealed class Sc0000HoverHost : RecordingHost + { + public VirtualMachine Vm = null!; + public bool SawHistoryHover; + + public override void WaitForInput(int layoutSlot, Func serviceInputCallback) + { + Vm.UpdatePointer(684, 572); + while (serviceInputCallback()) { } + SawHistoryHover = Vm.Globals.GetValueOrDefault(0x6c9) == 1; + throw new StopAtFirstWaitException(); + } + } + + private sealed class AutoStateHost : RecordingHost + { + public AdvAutoWaitState State; + + public override void WaitForInput(int layoutSlot, Func serviceInputCallback, + Func autoWaitState) + { + while (serviceInputCallback()) { } + State = autoWaitState(); + Waits++; + } + } + + [Fact] + public void ArmedHotspot_DispatchesHoverAndConsumesActivationWithoutAdvancingPage() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + // Main code ends at dword 20. Activation also exercises an ordinary local call beneath the + // VM's temporary callback return sentinel. + const int enterTarget = 20, leaveTarget = 26, activateTarget = 32; + var script = ScriptAssembler.Assemble(table, "HOTSPOT", new List<(int, Operand[])> + { + (0x90, new[] { I(10), I(20), I(20), I(20), I(enterTarget), I(leaveTarget), I(activateTarget) }), + (0x94, Array.Empty()), + (0x72, new[] { I(1) }), + (0x2, Array.Empty()), + (0x55, new[] { G(0x100), I(1) }), + (0x5, Array.Empty()), + (0x55, new[] { G(0x101), I(1) }), + (0x5, Array.Empty()), + (0x8f, new[] { I(36) }), + (0x5, Array.Empty()), + (0x1b7, new[] { I(1) }), + (0x5, Array.Empty()), + }, Array.Empty()); + var host = new InteractiveHost(); + var vm = new VirtualMachine(script, table, host); + host.Vm = vm; + + vm.Run(); + + Assert.True(host.ClickConsumed); + Assert.True(host.SecondClickConsumed); + Assert.True(vm.AutoMessageEnabled); + Assert.Equal(1, host.Waits); + Assert.Equal(7, host.InputCallbackFrames); + Assert.Equal("exit", vm.HaltReason); + Assert.False(vm.TryActivatePointer(30, 40)); // the script frame has exited, so no hotspot remains active + } + + [Fact] + public void Sc0000AdvChromeBootstrap_VisitsAllFiveVisibleButtonRegistrations() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + var script = Sys4Loader.Load(Paths.Scripts()["SC0000.BIN"], table); + var trace = new RecordingTraceSink { TracingSteps = true }; + var vm = new VirtualMachine(script, table, new RecordingHost(), + new VmOptions(HaltAtWaitForInput: true), sink: trace); + vm.Globals[0x6c1] = 1; // inherited SYSTEM4 adv_chrome_enabled state seeded by Godot Main + + vm.Run(); + + Assert.Equal(new[] { 0x94, 0xa3, 0xb2, 0xc1, 0xd0 }, trace.Events + .Where(e => e.Kind == Age.Engine.Diagnostics.TraceEventKind.Step + && e.Opcode == 0x90 && e.Ins!.Offset < 0xdf) + .Select(e => e.Ins!.Offset).ToArray()); + Assert.Equal("wait-for-input", vm.HaltReason); + } + + [Fact] + public void Sc0000FirstWait_DispatchesRealHistoryHoverCallback() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + var script = Sys4Loader.Load(Paths.Scripts()["SC0000.BIN"], table); + var host = new Sc0000HoverHost(); + var vm = new VirtualMachine(script, table, host, new VmOptions(MaxSteps: 1_000_000)); + host.Vm = vm; + vm.Globals[0x6c1] = 1; + + Assert.Throws(() => vm.Run()); + + Assert.True(host.InputCallbackFrames > 0); + Assert.True(host.SawHistoryHover); + } + + [Fact] + public void AutoMessageOpcodes_RoundTripVmServiceState() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + var script = ScriptAssembler.Assemble(table, "AUTO", new List<(int, Operand[])> + { + (0x1b7, new[] { I(1) }), + (0x1b6, new[] { G(0x120) }), + (0x1b7, new[] { I(0) }), + (0x2, Array.Empty()), + }, Array.Empty()); + var vm = new VirtualMachine(script, table, new RecordingHost()); + + vm.Run(); + + Assert.Equal(1, vm.Globals.GetValueOrDefault(0x120)); + Assert.False(vm.AutoMessageEnabled); + } + + [Fact] + public void AutoMessageTimeOpcodes_ConfigureWaitSchedulerState() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + var script = ScriptAssembler.Assemble(table, "AUTO_TIMES", new List<(int, Operand[])> + { + (0x1b9, new[] { I(0), I(750) }), + (0x1b9, new[] { I(1), I(2250) }), + (0x1b8, new[] { I(0), G(0x130) }), + (0x1b8, new[] { I(1), G(0x131) }), + (0x1b7, new[] { I(1) }), + (0x72, new[] { I(1) }), + (0x2, Array.Empty()), + }, Array.Empty()); + var host = new AutoStateHost(); + var vm = new VirtualMachine(script, table, host); + + vm.Run(); + + Assert.Equal(750, vm.Globals.GetValueOrDefault(0x130)); + Assert.Equal(2250, vm.Globals.GetValueOrDefault(0x131)); + Assert.Equal(new AdvAutoWaitState(true, false, 750, 2250), host.State); + Assert.Equal(1, host.Waits); + } + + [Fact] + public void VoicePlayback_MarksAutoWaitUntilBlockMarkResetsIt() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + var voiced = ScriptAssembler.Assemble(table, "AUTO_VOICE", new List<(int, Operand[])> + { + (0xc4, new[] { I(12) }), + (0x72, new[] { I(1) }), + (0x2, Array.Empty()), + }, Array.Empty()); + var reset = ScriptAssembler.Assemble(table, "AUTO_VOICE_RESET", new List<(int, Operand[])> + { + (0xc4, new[] { I(12) }), + (0x1bc, Array.Empty()), + (0x72, new[] { I(1) }), + (0x2, Array.Empty()), + }, Array.Empty()); + var voicedHost = new AutoStateHost(); + var resetHost = new AutoStateHost(); + + new VirtualMachine(voiced, table, voicedHost).Run(); + new VirtualMachine(reset, table, resetHost).Run(); + + Assert.True(voicedHost.State.VoicePending); + Assert.False(resetHost.State.VoicePending); + } + + private static Operand I(long value) => new(0, value); + private static Operand G(long address) => new(3, address); +} diff --git a/engine/Age.Engine.Tests/TestSupport.cs b/engine/Age.Engine.Tests/TestSupport.cs index 280725e..9ed41b3 100644 --- a/engine/Age.Engine.Tests/TestSupport.cs +++ b/engine/Age.Engine.Tests/TestSupport.cs @@ -6,11 +6,12 @@ using Age.Engine.Model; /// Shared test doubles: a host that records observable effects, and an in-memory script /// provider for synthetic call-script targets. -internal sealed class RecordingHost : IHost +internal class RecordingHost : IHost { public int Waits; public int Presents; public int TransitionWaits; + public int InputCallbackFrames; public bool MessageSkip; public bool AdvReadSkip; public readonly List<(int Offset, string Text)> Lines = new(); @@ -29,6 +30,15 @@ internal sealed class RecordingHost : IHost => SurfaceStrings.Add((surfaceSlot, x, y, text)); public void ConfigureAdvWaitIndicator(AdvWaitIndicatorConfig config) => WaitIndicators.Add(config); public void WaitForInput() => Waits++; + public virtual void WaitForInput(int layoutSlot, Func serviceInputCallback) + { + while (serviceInputCallback()) { } + WaitForInput(); + } + public virtual void WaitForInput(int layoutSlot, Func serviceInputCallback, + Func autoWaitState) + => WaitForInput(layoutSlot, serviceInputCallback); + public void InputCallbackCompleted(GfxState gfx) => InputCallbackFrames++; public void Sleep(long duration) => SleptDurations.Add(duration); public void FrameYield() { } public bool IsMessageSkipActive => MessageSkip; diff --git a/engine/Age.Engine/Hosting/AdvAutoAdvanceTimer.cs b/engine/Age.Engine/Hosting/AdvAutoAdvanceTimer.cs new file mode 100644 index 0000000..4047ae2 --- /dev/null +++ b/engine/Age.Engine/Hosting/AdvAutoAdvanceTimer.cs @@ -0,0 +1,50 @@ +namespace Age.Engine.Hosting; + +/// +/// Native ADV Auto timing policy. Unvoiced waits use AutoMessageTime1; voiced waits park until voice +/// completion and then use AutoMessageTime0. The host supplies a monotonic clock and live voice state. +/// +public sealed class AdvAutoAdvanceTimer +{ + private bool _armed; + private bool _waitingForVoice; + private long _deadlineMs; + + public void Reset() + { + _armed = false; + _waitingForVoice = false; + _deadlineMs = 0; + } + + public bool Poll(AdvAutoWaitState state, bool voiceActive, long nowMs) + { + if (!state.Enabled) + { + Reset(); + return false; + } + + if (!_armed) + { + _armed = true; + _waitingForVoice = state.VoicePending && voiceActive; + if (!_waitingForVoice) + _deadlineMs = nowMs + EffectiveDelay( + state.VoicePending ? state.PostVoiceDelayMs : state.UnvoicedDelayMs); + } + + if (_waitingForVoice) + { + if (voiceActive) return false; + _waitingForVoice = false; + _deadlineMs = nowMs + EffectiveDelay(state.PostVoiceDelayMs); + } + + return nowMs >= _deadlineMs; + } + + // Native callers substitute 100 ms when either configuration getter returns zero. + private static long EffectiveDelay(long configuredMs) + => configuredMs == 0 ? 100 : System.Math.Max(1, configuredMs); +} diff --git a/engine/Age.Engine/Hosting/IHost.cs b/engine/Age.Engine/Hosting/IHost.cs index ac4ae95..50a92de 100644 --- a/engine/Age.Engine/Hosting/IHost.cs +++ b/engine/Age.Engine/Hosting/IHost.cs @@ -7,6 +7,9 @@ public readonly record struct AdvWaitIndicatorConfig( int SourceX, int SourceY, int CellWidth, int CellHeight, int TerminalFrame, long FramePeriodMs); +public readonly record struct AdvAutoWaitState( + bool Enabled, bool VoicePending, long PostVoiceDelayMs, long UnvoicedDelayMs); + public interface IHost { void ShowText(int offset, string text); @@ -17,6 +20,18 @@ public interface IHost void ConfigureAdvWaitIndicator(AdvWaitIndicatorConfig config) { } void WaitForInput(); void WaitForInput(int layoutSlot) => WaitForInput(); + // Interactive hosts service script callbacks on the VM thread while the enclosing ADV page remains + // parked. The callback returns true while another queued input callback is ready to run. + void WaitForInput(int layoutSlot, Func serviceInputCallback) + { + while (serviceInputCallback()) { } + WaitForInput(layoutSlot); + } + void WaitForInput(int layoutSlot, Func serviceInputCallback, + Func autoWaitState) + => WaitForInput(layoutSlot, serviceInputCallback); + void WakeInputCallbackService() { } + void InputCallbackCompleted(GfxState gfx) { } void Sleep(long duration); void FrameYield(); // Native 0x1c7/0x1cc query two distinct ADV skip channels. Headless and non-interactive diff --git a/engine/Age.Engine/Vm/ExecFrame.cs b/engine/Age.Engine/Vm/ExecFrame.cs index 5533dad..8d02dad 100644 --- a/engine/Age.Engine/Vm/ExecFrame.cs +++ b/engine/Age.Engine/Vm/ExecFrame.cs @@ -14,5 +14,6 @@ internal sealed class ExecFrame public int? CoroutineYieldHandlerA; // op 0x7b: native per-frame handler PCs public int? CoroutineYieldHandlerB; public readonly Dictionary CoroutineYieldVisits = new(); // instruction index -> visits + public readonly HotspotRegistry Hotspots = new(); public ExecFrame(Script script, int pc) { Script = script; Pc = pc; } } diff --git a/engine/Age.Engine/Vm/HotspotRegistry.cs b/engine/Age.Engine/Vm/HotspotRegistry.cs new file mode 100644 index 0000000..102c0e7 --- /dev/null +++ b/engine/Age.Engine/Vm/HotspotRegistry.cs @@ -0,0 +1,109 @@ +namespace Age.Engine.Vm; + +/// Per-script-frame registry populated by SYS4 ops 0x90/0x94/0x97. +internal sealed class HotspotRegistry +{ + private sealed class Entry + { + public int Left, Top, Right, Bottom; + public int EnterTarget, LeaveTarget, ActivateTarget; + public int? InputBit; + + public bool Contains(int x, int y) + => x >= Left && x <= Right && y >= Top && y <= Bottom; + } + + private readonly List _entries = new(); + private readonly Queue _pendingTargets = new(); + private int _hovered = -1; + private bool _replaceOnNextRegister; + public bool Armed { get; private set; } + public bool HasDefinitions => _entries.Count != 0; + + public void Register(int x, int y, int width, int height, + int enterTarget, int leaveTarget, int activateTarget) + { + if (_replaceOnNextRegister) + { + _entries.Clear(); + _replaceOnNextRegister = false; + } + _entries.Add(new Entry + { + Left = x, Top = y, Right = x + width, Bottom = y + height, + EnterTarget = enterTarget, LeaveTarget = leaveTarget, + ActivateTarget = activateTarget, + }); + } + + public void BindKey(int x, int y, int width, int height, int inputBit) + { + int right = x + width, bottom = y + height; + var entry = _entries.FirstOrDefault(e => e.Left == x && e.Top == y + && e.Right == right && e.Bottom == bottom); + if (entry != null) entry.InputBit = inputBit; + } + + public bool Arm(int pointerX, int pointerY) + { + _replaceOnNextRegister = false; + Armed = true; + return UpdatePointer(pointerX, pointerY); + } + + public bool UpdatePointer(int x, int y) + { + if (!Armed) return false; + int hit = FindHit(x, y); + if (hit == _hovered) return false; + + if (_hovered >= 0) QueueTarget(_entries[_hovered].LeaveTarget); + _hovered = hit; + if (_hovered >= 0) QueueTarget(_entries[_hovered].EnterTarget); + return _pendingTargets.Count != 0; + } + + public bool Activate(int x, int y) + { + if (!Armed) return false; + int hit = FindHit(x, y); + if (hit < 0) return false; + + int target = _entries[hit].ActivateTarget; + // Native consumes the active input registration before dispatch. Its ADV scheduler revisits the + // shared registration routine afterward; retain the definitions so the blocking host can model + // that revisit without advancing the enclosing dialogue page. + _pendingTargets.Clear(); + _hovered = -1; + Armed = false; + QueueTarget(target); + return true; + } + + public bool RearmAfterCallback(int pointerX, int pointerY) + => !Armed && _entries.Count != 0 && Arm(pointerX, pointerY); + + public bool TryDequeue(out int target) => _pendingTargets.TryDequeue(out target); + + public void Reset() + { + _pendingTargets.Clear(); + _hovered = -1; + Armed = false; + // The native ADV coroutine can republish the shared definitions after a cancellation. If script + // code explicitly registers again first, replace this retained template instead of duplicating it. + _replaceOnNextRegister = _entries.Count != 0; + } + + private int FindHit(int x, int y) + { + for (int i = 0; i < _entries.Count; i++) + if (_entries[i].Contains(x, y)) return i; + return -1; + } + + private void QueueTarget(int target) + { + if (target >= 0 && (uint)target != uint.MaxValue) _pendingTargets.Enqueue(target); + } +} diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 01dcd89..b6d3191 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -8,6 +8,7 @@ public sealed class VirtualMachine private const long NoJump = 0xFFFFFFFF; private const int HALT = int.MinValue; private const int FRAME_RETURN = int.MinValue + 1; + private const int HOTSPOT_RETURN = int.MinValue + 2; private const int SceneEntryCoroutineGate = 0xaba5c; private const int T_IMM = 0, T_STR = 2, T_GINT = 3, T_GFLOAT = 4, T_GSTR = 5, T_GPTR = 6, T_GSTRPTR = 8, T_LINT = 9, T_LFLOAT = 10, T_LSTR = 11, T_LPTR = 12, @@ -22,6 +23,13 @@ public sealed class VirtualMachine private ExecFrame _cur = null!; private int _depth; private readonly ITraceSink _sink; + private readonly object _interactiveLock = new(); + private ExecFrame? _interactiveFrame; + private int _pointerX = int.MinValue, _pointerY = int.MinValue; + private bool _autoMessageEnabled; + private long _autoMessageTime0Ms = 500; + private long _autoMessageTime1Ms = 2000; + private bool _autoVoicePending; public long CallScriptDispatches { get; private set; } public Dictionary Globals { get; } = new(); @@ -32,12 +40,43 @@ public sealed class VirtualMachine public List<(int Offset, string Text, string Script)> Emitted { get; } = new(); public string? HaltReason { get; private set; } public long Steps { get; private set; } + public bool AutoMessageEnabled => _autoMessageEnabled; public VirtualMachine(Script s, OpcodeTable t, IHost host, VmOptions? o = null, IScriptProvider? provider = null, ITraceSink? sink = null) { _s = s; _t = t; _host = host; _o = o ?? new VmOptions(); _provider = provider; _sink = sink ?? NullTraceSink.Instance; } + /// Update the native 800x600 cursor coordinate without advancing the current ADV page. + public void UpdatePointer(int x, int y) + { + bool wake = false; + lock (_interactiveLock) + { + _pointerX = x; _pointerY = y; + if (_interactiveFrame != null) + wake = _interactiveFrame.Hotspots.UpdatePointer(x, y); + } + if (wake) _host.WakeInputCallbackService(); + } + + /// Queue an armed hotspot's activation callback. True means the click was consumed. + public bool TryActivatePointer(int x, int y) + { + bool consumed = false; + lock (_interactiveLock) + { + _pointerX = x; _pointerY = y; + if (_interactiveFrame != null) + { + _interactiveFrame.Hotspots.UpdatePointer(x, y); + consumed = _interactiveFrame.Hotspots.Activate(x, y); + } + } + if (consumed) _host.WakeInputCallbackService(); + return consumed; + } + private static long Gi(Dictionary d, int k) => d.TryGetValue(k, out var v) ? v : 0; private long ReadGlobal(int k) => ExternalGlobals.TryGetValue(k, out var v) ? v : Gi(Globals, k); private static string Gs(Dictionary d, int k) => d.TryGetValue(k, out var v) ? v : ""; @@ -156,6 +195,8 @@ public sealed class VirtualMachine private FrameOutcome RunFrame(ExecFrame frame, FrameCause cause, long callId = 0) { + ExecFrame? previousInteractiveFrame; + lock (_interactiveLock) previousInteractiveFrame = _interactiveFrame; var prev = _cur; _cur = frame; _depth++; _sink.Emit(TraceEvent.FrameEnter(frame.Script.Name, _depth, cause, callId)); var outcome = FrameOutcome.RanOff; @@ -172,10 +213,49 @@ public sealed class VirtualMachine pc = next; } _sink.Emit(TraceEvent.FrameExit(frame.Script.Name, _depth, outcome.ToString())); + lock (_interactiveLock) + { + if (cause == FrameCause.CallScript) + _interactiveFrame = previousInteractiveFrame?.Hotspots.Armed == true + ? previousInteractiveFrame : null; + else if (ReferenceEquals(_interactiveFrame, frame)) + _interactiveFrame = null; + } _cur = prev; _depth--; return outcome; } + private bool ServiceHotspotCallback() + { + int target; + lock (_interactiveLock) + { + if (_interactiveFrame == null || !_interactiveFrame.Hotspots.TryDequeue(out target)) return false; + } + if (_cur.Script.IndexByOffset.TryGetValue(target, out int pc)) + { + _cur.CallStack.Add(HOTSPOT_RETURN); + while (pc >= 0 && pc < _cur.Script.Instructions.Count) + { + if (Steps >= _o.MaxSteps) { HaltReason ??= "STEP-LIMIT"; break; } + Steps++; + if (_sink.TracingSteps) _sink.Emit(TraceEvent.Step(pc, _cur.Script.Instructions[pc], _depth)); + int next = Step(_cur.Script.Instructions[pc], pc); + _host.FrameYield(); + if (next == HOTSPOT_RETURN || next == FRAME_RETURN) break; + if (next == HALT) break; + pc = next; + } + // A malformed callback must not leave its sentinel in the page's ordinary local-call stack. + int sentinel = _cur.CallStack.LastIndexOf(HOTSPOT_RETURN); + if (sentinel >= 0) _cur.CallStack.RemoveAt(sentinel); + } + lock (_interactiveLock) + _interactiveFrame?.Hotspots.RearmAfterCallback(_pointerX, _pointerY); + _host.InputCallbackCompleted(Gfx); + return true; + } + private int Step(Instruction ins, int pc) { int op = ins.Opcode; @@ -295,7 +375,55 @@ public sealed class VirtualMachine case "wait-for-input": // Faithful headless: no player => halt here rather than plow past every prompt (see VmOptions). if (_o.HaltAtWaitForInput) { HaltReason ??= "wait-for-input"; return HALT; } - _host.WaitForInput((int)Read(a[0])); return pc + 1; + // The native ADV chrome is a coroutine: after an earlier 0x93 cancellation its shared + // registration pass runs again before a stable message wait. Our blocking host models that + // scheduler boundary by re-arming the frame's retained definitions here. + bool wakeAtWait = false; + lock (_interactiveLock) + { + if (_cur.Hotspots.HasDefinitions && !_cur.Hotspots.Armed) + { + _interactiveFrame = _cur; + wakeAtWait = _cur.Hotspots.Arm(_pointerX, _pointerY); + } + } + if (wakeAtWait) _host.WakeInputCallbackService(); + _host.WaitForInput((int)Read(a[0]), ServiceHotspotCallback, + () => new AdvAutoWaitState(_autoMessageEnabled, _autoVoicePending, + _autoMessageTime0Ms, _autoMessageTime1Ms)); + return pc + 1; + case "u0041BEB0": + case "register-hotspot-callbacks": // 0x90: inclusive rect + enter/leave/activate local callbacks + lock (_interactiveLock) + _cur.Hotspots.Register((int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2]), (int)Read(a[3]), + (int)Read(a[4]), (int)Read(a[5]), (int)Read(a[6])); + return pc + 1; + case "u00415040": + case "cancel-hotspot-wait": // 0x93 + lock (_interactiveLock) + { + _cur.Hotspots.Reset(); + if (ReferenceEquals(_interactiveFrame, _cur)) _interactiveFrame = null; + } + return pc + 1; + case "u00415090": + case "arm-hotspot-wait": // 0x94 + { + bool wake; + lock (_interactiveLock) + { + _interactiveFrame = _cur; + wake = _cur.Hotspots.Arm(_pointerX, _pointerY); + } + if (wake) _host.WakeInputCallbackService(); + return pc + 1; + } + case "u0041C150": + case "bind-hotspot-key": // 0x97: keyboard/pad routing is a later host-input slice + lock (_interactiveLock) + _cur.Hotspots.BindKey((int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2]), + (int)Read(a[3]), (int)Read(a[4])); + 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 @@ -303,6 +431,24 @@ public sealed class VirtualMachine 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 "u00414F60": + case "get-auto-message": // 0x1b6: VM service state used by the ADV redraw callback + Write(a[0], _autoMessageEnabled ? 1 : 0); return pc + 1; + case "u0041B640": + case "set-auto-message": // 0x1b7 + _autoMessageEnabled = Read(a[0]) != 0; return pc + 1; + case "u0041B670": + case "get-auto-message-time": // 0x1b8 (selector 0=post-voice Time0, 1=unvoiced Time1, out) + Write(a[1], Read(a[0]) == 0 ? _autoMessageTime0Ms : _autoMessageTime1Ms); return pc + 1; + case "u0041B710": + case "set-auto-message-time": // 0x1b9 (selector, milliseconds) + if (Read(a[0]) == 0) _autoMessageTime0Ms = Read(a[1]); + else if (Read(a[0]) == 1) _autoMessageTime1Ms = Read(a[1]); + return pc + 1; + case "u00415670": + case "block-mark": + case "reset-message-voice-state": // 0x1bc resets native per-message voice/queued-voice state + _autoVoicePending = false; return pc + 1; case "end-text-line": case "set-font": case "comment": case "display-furigana": case "dev_ukn": return pc + 1; @@ -329,7 +475,9 @@ public sealed class VirtualMachine return pc + 1; } case "play-bgm": _host.PlayBgm(Read(a[0])); return pc + 1; - case "play-voice": _host.PlayVoice(Read(a[0])); return pc + 1; + case "play-voice": + _autoVoicePending = true; + _host.PlayVoice(Read(a[0])); return pc + 1; case "play-sound-effect": // 0xb4 / semantics: sfx-load _host.LoadSoundEffect(Read(a[0]), (int)Read(a[1])); return pc + 1; case "u0041D050": // 0xb5 / semantics: sfx-start diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index 5dfff79..783284f 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -1,9 +1,12 @@ +using System; using System.Collections.Generic; +using System.Runtime.Versioning; using System.Threading; using Age.Engine.Hosting; using Age.Engine.Model; using Age.Engine.Sys4; +[SupportedOSPlatform("windows")] public sealed class GodotAdvHost : IHost { private readonly Main _main; @@ -20,6 +23,7 @@ public sealed class GodotAdvHost : IHost // primary surface, but op 0x1fa releases it like any other slot; subsequent size queries must return 0x0. private readonly Dictionary _slotDims = new() { { 0, (800, 600) } }; private readonly SemaphoreSlim _gate = new(0, 1); + private readonly AutoResetEvent _inputCallbackSignal = new(false); private readonly Age.Engine.Hosting.FrameClock _clock; private readonly GodotTimelineLog? _timeline; private readonly PageLocatorState _locator; @@ -147,6 +151,13 @@ public sealed class GodotAdvHost : IHost public void WaitForInput() => WaitForInput(0); public void WaitForInput(int layoutSlot) + => WaitForInput(layoutSlot, static () => false, static () => default); + + public void WaitForInput(int layoutSlot, Func serviceInputCallback) + => WaitForInput(layoutSlot, serviceInputCallback, static () => default); + + public void WaitForInput(int layoutSlot, Func serviceInputCallback, + Func autoWaitState) { Pages++; _locator.Wait(Pages); @@ -158,9 +169,22 @@ public sealed class GodotAdvHost : IHost _waitIndicatorStartedMs = _clock.NowMs; IsWaiting = true; _timeline?.State("input-wait", new() { ["page"] = Pages }); - _gate.Wait(); + var autoTimer = new AdvAutoAdvanceTimer(); + bool autoAdvanced = false; + while (!_stopping) + { + while (serviceInputCallback()) { } + if (autoTimer.Poll(autoWaitState(), _main.IsVoicePlaybackActive, _clock.NowMs)) + { + autoAdvanced = true; + break; + } + if (_gate.Wait(0)) break; + WaitHandle.WaitAny(new[] { _gate.AvailableWaitHandle, _inputCallbackSignal, _frameSignal }); + if (_gate.Wait(0)) break; + } IsWaiting = false; - _timeline?.State("running", new() { ["input"] = "auto-or-user" }); + _timeline?.State("running", new() { ["input"] = autoAdvanced ? "auto" : "user" }); lock (_textLock) { _advText = ""; @@ -194,6 +218,11 @@ public sealed class GodotAdvHost : IHost if (IsWaiting && _gate.CurrentCount == 0) _gate.Release(); } + public void WakeInputCallbackService() => _inputCallbackSignal.Set(); + + public void InputCallbackCompleted(GfxState gfx) + => Interlocked.Exchange(ref _presentRequested, 1); + public void WaitForForegroundTransition(GfxState gfx) { int started = gfx.StartForegroundTransitions(_clock.NowMs); @@ -251,6 +280,7 @@ public sealed class GodotAdvHost : IHost _stopping = true; lock (_textLock) _advTextForceComplete = true; if (_gate.CurrentCount == 0) _gate.Release(); + _inputCallbackSignal.Set(); _frameSignal.Set(); } @@ -440,7 +470,11 @@ public sealed class GodotAdvHost : IHost { var asset = _res.Resolve(_scene, id); var audio = asset != null ? LoadAudio(asset) : null; - if (audio != null) _main.CallDeferred("PlayVoice", audio.Bytes, audio.Name); + if (audio != null) + { + int generation = _main.QueueVoicePlayback(); + _main.CallDeferred("PlayVoice", audio.Bytes, audio.Name, generation); + } } public void LoadSoundEffect(long resourceId, int channel) diff --git a/godot/Main.cs b/godot/Main.cs index ad84f71..5c4815d 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -30,6 +30,9 @@ public partial class Main : Godot.Control private Label _locatorHud = null!; private AudioStreamPlayer _bgm = null!; // looping background music private AudioStreamPlayer _voice = null!; // interrupt-on-new voice + private int _voiceQueuedGeneration; + private int _voiceStartedGeneration; + private int _voiceCompletedGeneration; private readonly AudioStreamPlayer[] _sfx = new AudioStreamPlayer[10]; // SC0000 channels 0..9 private VirtualMachine _vm = null!; private GodotAdvHost _host = null!; @@ -231,6 +234,10 @@ public partial class Main : Godot.Control foreach (var kv in session.GlobalStrings) _vm.GlobalStrings[kv.Key] = kv.Value; GD.Print($"[boot] system boot done: {session.Globals.Count} globals seeded"); } + // The native SYSTEM4 UI boot enables standard ADV chrome after the data-only *INIT prefix above. + // Without this inherited value the visible SO001 strip is still drawn, but every ADV script skips + // its five pointer rectangles and registers only the off-screen keyboard/pad records. + if (!_selftest) _vm.Globals[0x6c1] = 1; // Native AGE owns this transient secondary-SFX channel outside script-visible writes. // The matching SC0000 trace has value 4 at 0xc31; seed only this proven profile/slice. if (!_selftest && scene.Equals("SC0000", System.StringComparison.OrdinalIgnoreCase)) @@ -265,6 +272,7 @@ public partial class Main : Godot.Control _timelineFrame++; _timeline?.SetFrame(_timelineFrame, _clock.NowMs); _host?.PulseFrame(); + UpdateVoicePlaybackState(); UpdateMovieFrames(); if (!_selftest && _vm != null && _host != null && _host.ShouldRecomposite(_vm.Gfx)) Recomposite(); // native publishes retained mutations only at present/service boundaries @@ -325,9 +333,32 @@ public partial class Main : Godot.Control _locatorHud.Text = _locator.CurrentDisplay + " · copied"; return; } - if (e.IsActionPressed("ui_accept") || - (e is InputEventMouseButton mb && mb.Pressed && mb.ButtonIndex == MouseButton.Left)) + if (e is InputEventMouseMotion motion) + { + var p = ToNativeScreen(motion.Position); + _vm.UpdatePointer(p.X, p.Y); + return; + } + if (e is InputEventMouseButton mb && mb.Pressed && mb.ButtonIndex == MouseButton.Left) + { + var p = ToNativeScreen(mb.Position); + if (_vm.TryActivatePointer(p.X, p.Y)) + { + GetViewport().SetInputAsHandled(); + return; + } _host.SignalInput(); + return; + } + if (e.IsActionPressed("ui_accept")) _host.SignalInput(); + } + + private (int X, int Y) ToNativeScreen(Vector2 position) + { + Vector2 size = GetViewportRect().Size; + if (size.X <= 0 || size.Y <= 0) return (0, 0); + return ((int)System.Math.Floor(position.X * ScreenWidth / size.X), + (int)System.Math.Floor(position.Y * ScreenHeight / size.Y)); } public override void _ExitTree() @@ -644,15 +675,47 @@ public partial class Main : Godot.Control _bgm.Play(); } - public void PlayVoice(byte[] oggBytes, string assetName) + public int QueueVoicePlayback() + => System.Threading.Interlocked.Increment(ref _voiceQueuedGeneration); + + public bool IsVoicePlaybackActive + => System.Threading.Volatile.Read(ref _voiceCompletedGeneration) + < System.Threading.Volatile.Read(ref _voiceQueuedGeneration); + + public void PlayVoice(byte[] oggBytes, string assetName, int generation) { var stream = AudioStreamOggVorbis.LoadFromBuffer(oggBytes); - if (stream == null) { GD.Print($"OGG load failed {assetName}"); return; } + if (stream == null) + { + GD.Print($"OGG load failed {assetName}"); + CompleteVoiceGeneration(generation); + return; + } stream.Loop = false; _voice.Stream = stream; + System.Threading.Volatile.Write(ref _voiceStartedGeneration, generation); _voice.Play(); } + private void UpdateVoicePlaybackState() + { + int started = System.Threading.Volatile.Read(ref _voiceStartedGeneration); + if (started > System.Threading.Volatile.Read(ref _voiceCompletedGeneration) && !_voice.Playing) + CompleteVoiceGeneration(started); + } + + private void CompleteVoiceGeneration(int generation) + { + int current; + do + { + current = System.Threading.Volatile.Read(ref _voiceCompletedGeneration); + if (current >= generation) return; + } + while (System.Threading.Interlocked.CompareExchange( + ref _voiceCompletedGeneration, generation, current) != current); + } + public void LoadSoundEffect(byte[] wavBytes, string assetName, int channel) { if ((uint)channel >= (uint)_sfx.Length) return; diff --git a/tools/age_opcodes_himegari.py b/tools/age_opcodes_himegari.py index bcf8fb6..a055e04 100644 --- a/tools/age_opcodes_himegari.py +++ b/tools/age_opcodes_himegari.py @@ -7,16 +7,30 @@ INFERRED: dict[int, dict] = { 0x71: dict(name='label-def', category='structural', noop=True, confidence='high', source='investigation', summary='1 imm; count == T1 table size -> the label/anchor T1 indexes. v1 no-op; revisit if menu/callback dispatch looks up by id'), 0x7b: dict(name='coroutine-save-yield-handlers', category='control', noop=False, confidence='high', source='investigation', summary='(handler1_pc)(handler2_pc) — scene-coroutine: save the two per-frame yield/resume handler PCs. Native writes op1→ctx[0x6da88+idx*4], op2→ctx[0x6db28+idx*4] (idx=ctx[0x53d14] script-context index) + gfx cmd-type 5. SC0000 0x79: `0x7b label_3c9 label_41e` registers the ADV per-frame render→poll→yield handlers. Part of the scene-coroutine framework (see engine-re.md §Scene-coroutine framework); pairs with 0x7c (resume) + 0x140 (loop iterator).'), 0x7c: dict(name='coroutine-resume', category='control', noop=False, confidence='high', source='investigation', summary='() — scene-coroutine RESUME point. Native requires run-state bit 0x2000000 (ctx[0x6dbc8]) set — THROWS (__CxxThrowException) if unset, so it is only ever reached on a scheduler-driven re-entry, NEVER on a cold first pass (cold flow jmps over it). Restores PC=ctx[0x53d28]+ctx[0x6dbcc]*4, clears the run-bit (ctx+0xa0ce4 &= ~0x2000000), resets input/line state. SC0000 0x443 (falls into the main loop label_444). See engine-re.md §Scene-coroutine framework.'), - 0x90: dict(name='hotspot-branch', category='input', noop=True, confidence='high', source='investigation', summary='cursor/input hotspot hit-test: rect (x,y,w,h) -> 3-way branch on interaction, else fall through to pc+1'), - 0x97: dict(name='hotspot-reg?', category='input', noop=True, confidence='med', source='inference', summary='companion register-hotspot / set-widget-action (argc5: v1 v2 1 1 ; NO code targets)'), + 0x88: dict(name='set-message-skip', category='input', noop=False, confidence='high', source='investigation', summary='(enabled) - set the all-message skip state. Nonzero arms ADV fast-forward; zero clears the associated native run-state bit.'), + 0x90: dict(name='register-hotspot-callbacks', category='input', noop=True, confidence='high', source='investigation', summary="(x)(y)(w)(h)(on_enter_pc)(on_leave_pc)(on_activate_pc) - append an inclusive cursor rectangle and three callback PCs to the current script frame's native input registry. This opcode only registers; the ADV input service dispatches callbacks later."), + 0x93: dict(name='cancel-hotspot-wait', category='input', noop=True, confidence='high', source='investigation', summary="Reset the current frame's hotspot registry/input wait and clear native run-state bit 0x00800000. Used before opening History, Menu, or HIDEWIN flows."), + 0x94: dict(name='arm-hotspot-wait', category='input', noop=True, confidence='high', source='investigation', summary='Arm native hotspot input processing after the script has registered its rectangles.'), + 0x97: dict(name='bind-hotspot-key', category='input', noop=True, confidence='high', source='investigation', summary='(x)(y)(w)(h)(input_bit) - find the already-registered rectangle with identical bounds and bind an input/key bit number to it.'), 0xb4: dict(name='sfx-load', category='audio', noop=False, confidence='high', source='investigation', summary="(resource_id)(channel) — synchronously resolve/open the scene-manifest asset and replace the channel's decoded sound buffer without starting playback. Native manager supports channels 0..12; SC0000 uses 0..9."), 0xb5: dict(name='sfx-start', category='audio', noop=False, confidence='high', source='investigation', summary='(channel) — start the already-loaded channel once (logical loop=false). DirectSound publishes synchronously through Play(0,0,DSBPLAY_LOOPING); the low-level flag loops only the streaming ring, while decoder EOF stops logical playback.'), 0xb6: dict(name='sfx-release', category='audio', noop=False, confidence='high', source='investigation', summary='(channel) - stop/destroy the channel decoder and DirectSound buffer, clear its retained resource id, and leave the slot empty. Idempotent for an unused channel.'), 0xc2: dict(name='fade-bgm', category='audio', noop=False, confidence='high', source='investigation', summary='(target_percent)(duration_ms) — block script service while linearly fading current BGM volume to 0..100%. Durations >=1000 ms use 100 steps; shorter fades use 10. Target 0 releases the current BGM source at completion.'), 0xd9: dict(name='clear-run-state-0x1000', category='control', noop=True, confidence='high', source='investigation', summary='Clear native run/service bit 0x1000; if the secondary context is active, clear the same bit there. SC0000 executes it once after the initial SFX-channel reset, with no VM-visible result.'), + 0x101: dict(name='reset-message-skip-input', category='input', noop=False, confidence='med', source='investigation', summary='Reset transient message-skip/input service state after an ADV chrome action.'), + 0x109: dict(name='get-cursor-virtual', category='input', noop=True, confidence='high', source='investigation', summary="(out_x)(out_y) - read the OS cursor and convert it into AGE's virtual-screen coordinates."), + 0x10a: dict(name='set-cursor-virtual', category='input', noop=True, confidence='high', source='investigation', summary='(x)(y) - convert AGE virtual-screen coordinates to client/screen coordinates and move the OS cursor.'), 0x140: dict(name='coroutine-label-yield', category='control', noop=False, confidence='med', source='investigation', summary="(out)(name_str)(sub_str)(in) — scene-coroutine LOOP ITERATOR / labeled yield. Handler copies name/sub strings + the int operand and calls the NATIVE video/transition service (*DAT_005c6018)(8, ctx[0x54fe8], &{name,sub,in}); writes the returned PC-like value to operand 1. In SC0000 label_462 'ループ開始' (@0x46d): `out=G[0x6be]=LABEL('J',G[0x6be])`; loop runs the intro-setup body (incl. call label_125bd = slot-table fill G[0x3239..0x324e]=4..11) and jmps back until out==G[0x6c3] (a per-scene exit-PC immediate) → mov aba5c 0 → content. The gate G[0xaba5c]==1 that opens this loop is NATIVE scene-entry state (no script sets it to 1). DAT_005c6018 is runtime-resolved (all xrefs READ) = SAME class as the DirectDraw workers we don't model. PORT = HOST-MODEL IMPLEMENTED: synthesize the ADV scene-entry gate, run the LABEL/J setup body once, then return the structurally discovered per-scene terminal; do not emulate the video service. See engine-re.md §Scene-coroutine framework."), - 0x1bc: dict(name='block-mark', category='marker', noop=True, confidence='high', source='inference', summary='zero-arg; follows jcc/mov, precedes mov/ret — block boundary'), + 0x199: dict(name='yield-adv-coroutine', category='control', noop=False, confidence='med', source='investigation', summary='Yield/re-enter the registered ADV coroutine handler. The fifth standard chrome button uses this transition to enter the HIDEWIN/window-hidden flow.'), + 0x19a: dict(name='get-message-skip', category='input', noop=False, confidence='high', source='investigation', summary='(out) - return the current all-message skip state set by op 0x88.'), + 0x1b6: dict(name='get-auto-message', category='input', noop=False, confidence='high', source='investigation', summary='(out) - return whether automatic message advance is enabled.'), + 0x1b7: dict(name='set-auto-message', category='input', noop=False, confidence='high', source='investigation', summary='(enabled) - enable or disable automatic message advance.'), + 0x1b8: dict(name='get-auto-message-time', category='input', noop=False, confidence='high', source='investigation', summary='(selector)(out) - read an Auto-message delay from engine configuration: selector 0 = post-voice AutoMessageTime0, selector 1 = unvoiced AutoMessageTime1.'), + 0x1b9: dict(name='set-auto-message-time', category='input', noop=False, confidence='high', source='investigation', summary='(selector)(milliseconds) - write an Auto-message delay to engine configuration: selector 0 = post-voice AutoMessageTime0, selector 1 = unvoiced AutoMessageTime1.'), + 0x1bc: dict(name='reset-message-voice-state', category='input', noop=False, confidence='high', source='investigation', summary='Reset the per-message queued-voice flag used by ADV Auto timing.'), 0x1bf: dict(name='call-end', category='marker', noop=True, confidence='med', source='inference', summary='zero-arg; call->0x1bf->stmt-end — end-of-call-statement marker'), + 0x1ca: dict(name='set-read-message-skip', category='input', noop=False, confidence='high', source='investigation', summary='(enabled) - set the engine setting `message:ReadTextSkip`, which skips only previously read text.'), + 0x1cb: dict(name='get-read-message-skip', category='input', noop=False, confidence='high', source='investigation', summary='(out) - read the engine setting `message:ReadTextSkip`.'), 0x1d2: dict(name='stmt-desc?', category='marker', noop=True, confidence='med', source='harness', summary='2 imm; immediately after stmt-begin 0x1f4 — statement descriptor?'), 0x1d5: dict(name='cond-block', category='marker', noop=True, confidence='high', source='inference', summary='zero-arg; ALWAYS follows jcc — marks conditional body entry'), 0x1f4: dict(name='stmt-begin', category='marker', noop=True, confidence='high', source='investigation', summary='zero-arg; opens scripts, pairs with stmt-end 0x1f5'), diff --git a/vm-map/engine-ctx.toml b/vm-map/engine-ctx.toml index 5fb127f..0011ed1 100644 --- a/vm-map/engine-ctx.toml +++ b/vm-map/engine-ctx.toml @@ -108,6 +108,11 @@ name = "cmd_type_table" type = "int" note = "per-object cmd-type column base (write *(0x53d88 + curCtx*0x78))" [[field]] +offset = 0x55104 +name = "auto_message_enabled" +type = "int" +note = "ADV Auto mode flag; op 0x1b6 reads, op 0x1b7 writes, adv_input_service_poll consumes" +[[field]] offset = 0x55120 name = "anti_tamper_a" type = "int" @@ -163,6 +168,11 @@ 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 = 0x6dbe4 +name = "adv_auto_voice_pending" +type = "int" +note = "set by play-voice ops when voice service exists; Auto waits for voice completion then arms AutoMessageTime0; op 0x1bc resets" +[[field]] offset = 0x9b24c name = "dispatch_table" type = "void*" diff --git a/vm-map/globals.toml b/vm-map/globals.toml index d9b520f..83e9087 100644 --- a/vm-map/globals.toml +++ b/vm-map/globals.toml @@ -51,59 +51,70 @@ confidence = "high" depends_on = ["0xa57", "0xa58"] [[global]] -address = "0x6c9" -name = "ui_toggle_0" +address = "0x6c1" +name = "adv_chrome_enabled" category = "ui-toggle" type = "int" value_domain = "{0,1}" -usage = "ADV-chrome hotspot button toggle (op 0x90 site, near-universal across scenes)." +usage = "Inherited SYSTEM4 UI-boot flag. Standard ADV scripts register the five visible SO001 control-strip pointer rectangles only while nonzero; zero skips directly to the three off-screen keyboard/pad records. The Phase-A single-scene Godot bootstrap seeds the native observed value 1." source = "investigation" -confidence = "med" -depends_on = [] +confidence = "high" +depends_on = ["0x6c9", "0x6ca", "0x6cb", "0x6cc", "0x6cd"] + +[[global]] +address = "0x6c9" +name = "adv_hover_history" +category = "ui-toggle" +type = "int" +value_domain = "{0,1}" +usage = "Pointer-hover flag for the standard ADV History button at (684,572). Its op 0x90 enter/leave callbacks set 1/0; the shared SO001 redraw shows the History tooltip plus generic hover overlay while set." +source = "investigation" +confidence = "high" +depends_on = ["0x6ca", "0x6cb", "0x6cc", "0x6cd"] [[global]] address = "0x6ca" -name = "ui_toggle_1" +name = "adv_hover_auto_message" category = "ui-toggle" type = "int" value_domain = "{0,1}" -usage = "ADV-chrome hotspot button toggle. See ui_toggle_0." +usage = "Pointer-hover flag for the standard ADV Auto-message button at (706,572). See adv_hover_history." source = "investigation" -confidence = "med" -depends_on = [] +confidence = "high" +depends_on = ["0x6c9"] [[global]] address = "0x6cb" -name = "ui_toggle_2" +name = "adv_hover_message_skip" category = "ui-toggle" type = "int" value_domain = "{0,1}" -usage = "ADV-chrome hotspot button toggle. See ui_toggle_0." +usage = "Pointer-hover flag for the standard ADV all-message Skip button at (728,572). See adv_hover_history." source = "investigation" -confidence = "med" -depends_on = [] +confidence = "high" +depends_on = ["0x6c9"] [[global]] address = "0x6cc" -name = "ui_toggle_3" +name = "adv_hover_read_message_skip" category = "ui-toggle" type = "int" value_domain = "{0,1}" -usage = "ADV-chrome hotspot button toggle. See ui_toggle_0." +usage = "Pointer-hover flag for the standard ADV read-message-only Skip button at (750,572). See adv_hover_history." source = "investigation" -confidence = "med" -depends_on = [] +confidence = "high" +depends_on = ["0x6c9"] [[global]] address = "0x6cd" -name = "ui_toggle_4" +name = "adv_hover_hide_window" category = "ui-toggle" type = "int" value_domain = "{0,1}" -usage = "ADV-chrome hotspot button toggle. See ui_toggle_0." +usage = "Pointer-hover flag for the standard ADV Hide-window button at (772,572). See adv_hover_history." source = "investigation" -confidence = "med" -depends_on = [] +confidence = "high" +depends_on = ["0x6c9"] [[global]] address = "0x62ccf" diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 59b6a13..48ebe3d 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -1015,12 +1015,12 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "wait-for-input" category = "adv" -summary = "(layout_slot) - arm the ADV input wait after text reveal completes; activates the wait indicator configured for the selected text layout by op 0x73." +summary = "(layout_slot) - arm the ADV input wait after text reveal completes; activate the configured wait indicator and, while Auto is enabled, arm the appropriate Auto-message timer." noop_headless = false source = "investigation" confidence = "high" depends_on = [] -evidence = "Ghidra /v2: op_0x72_handler@0x41e690 fetches operand 1 and calls FUN_00453120(text_manager, layout_slot, -1, &state), then sets the input-wait run-state flags. FUN_00453120 resolves layout slot 0 as current and consumes the indicator descriptor at layout+0x3c configured by op 0x73. SYSTEM4 layout 1 uses SO000's bat strip; the click that completes show-text is consumed before this opcode is reached." +evidence = "Ghidra /v2: op_0x72_handler@0x41e690 fetches operand 1 and calls FUN_00453120(text_manager, layout_slot, -1, &state), then sets the input-wait run-state flags. FUN_00453120 resolves layout slot 0 as current and consumes the indicator descriptor at layout+0x3c configured by op 0x73. SYSTEM4 layout 1 uses SO000's bat strip; the click that completes show-text is consumed before this opcode is reached. The handler also checks ctx+0x55104 (Auto enabled): when ctx+0x6dbe4 has no pending voice it arms the timer with message:AutoMessageTime1, substituting 100 ms for configuration value zero. adv_input_service_poll@0x411230 waits for an active voice to finish and then arms AutoMessageTime0, likewise with a 100-ms zero fallback." [[opcode.semantics.args]] i = 1 @@ -1383,18 +1383,18 @@ argc = 1 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u0041B290" -category = "unknown" -summary = "" +name = "set-message-skip" +category = "input" +summary = "(enabled) - set the all-message skip state. Nonzero arms ADV fast-forward; zero clears the associated native run-state bit." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x88_set_message_skip@0x41f130 writes operand 1 to ctx+0x13dc and ctx+0x550fc; nonzero also sets ctx+0x6da78=1, while zero clears run-state bit 0x08000000. adv_interpreter_tick injects input bit 0x40 while ctx+0x13dc is nonzero and cancels the state according to set:CancelMesSkipOnClick. SC0000's Message-skip button passes 1." [[opcode.semantics.args]] i = 1 -role = "" +role = "enabled" observed_types = ["imm"] [[opcode]] @@ -1468,62 +1468,69 @@ argc = 7 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "hotspot-branch" +name = "register-hotspot-callbacks" category = "input" -summary = "cursor/input hotspot hit-test: rect (x,y,w,h) -> 3-way branch on interaction, else fall through to pc+1" +summary = "(x)(y)(w)(h)(on_enter_pc)(on_leave_pc)(on_activate_pc) - append an inclusive cursor rectangle and three callback PCs to the current script frame's native input registry. This opcode only registers; the ADV input service dispatches callbacks later." noop_headless = true source = "investigation" confidence = "high" -depends_on = [0x1f4, 0x1f5] -evidence = "all 301 ADV scripts contain the identical 8 sites; enc.len 15 lands pc+1 on the fall-through stmt (design-confirmed); fall-through = correct headless no-input path, proven by 279 CLEAN dialogue scenes" -confirm_by = "target->interaction-state mapping (hover-enter/leave/click) via live input capture or Frida in A2" +depends_on = [] +evidence = "Ghidra /v2: op_0x90_handler@0x41fc80 fetches x/y/w/h, forms x+w/y+h, fetches operands 5/6/7, and calls input_hotspot_register_rect_callbacks@0x403d70 on the current frame registry. The worker appends rect + callbacks to arrays and returns without changing PC. input_hotspot_poll_hover_callback@0x4040b0 returns operand 5 on entry and operand 6 on exit; input_hotspot_take_click_callback@0x404330 resets the registry and returns operand 7 on activation. All 301 ADV scripts contain the identical five control-strip records plus three keyed records." +confirm_by = "" details = """ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it "ukn" noting args -5-7 are code locations. Corpus analysis (all 301 ADV scripts) resolves it: +5-7 are code locations. Native RE plus corpus analysis resolves it: +- The opcode APPENDS a record; it is not an immediate branch. Rect bounds are stored as + `(x,y,x+w,y+h)` and the cursor hit-test compares them inclusively. +- The later input service maps targets exactly: target A = pointer enter, target B = pointer leave, + target C = activation/click. Moving directly between records emits leave first, then enter on the + next service poll. Activation consumes/resets the registry before dispatching target C. - Two forms, both ONLY in one shared ADV-chrome subroutine copied into every ADV script: * Mode A (1505 = 5x301): immediate x,y,w,h with w=h=20; the five on-screen buttons at - (684|706|728|750|772, 572), each setting one of G[0x6c9..0x6cd] to 1 / 0 / 0+run-action - (reads as hover-enter / hover-leave / click). All 3 targets real. - * Mode B (903 = 3x301): local-int operands, w=h=1, only tgt_c real -- a keyed 2-way input test. + (684|706|728|750|772, 572): History, Auto message, Message skip, Read-message skip, and + Hide window. Their enter/leave callbacks set G[0x6c9..0x6cd] to 1/0 and redraw SO001's + tooltip + generic 20x20 translucent hover overlay; target C runs the per-button action. + * Mode B (903 = 3x301): local-int operands, w=h=1, only tgt_c real. Companion op 0x97 binds + input-bit ids 0, 8, and 7 to these records for keyboard/pad activation. - Every one of the 301 scripts has EXACTLY 8 sites (5 A + 3 B); zero scene-specific use. -- Falls through (pc+1) when nothing matches -- design-confirmed (0xd0 + 15 dwords = 0xdf = label_df). -- Headless (no cursor/input) => fall through => vm0 stub already correct; the 12 EMPTY sweep scenes +- Registration returns normally to pc+1; later callbacks temporarily redirect execution. +- Headless (no cursor/input) can ignore registration; the 12 EMPTY sweep scenes are gated by state + this input-wait chrome, NOT by unmodelled 0x90. Model live in A2. """ [[opcode.semantics.args]] i = 1 -role = "" +role = "rectangle left x" observed_types = ["imm", "l-int"] [[opcode.semantics.args]] i = 2 -role = "" +role = "rectangle top y" observed_types = ["imm", "l-int"] [[opcode.semantics.args]] i = 3 -role = "" +role = "rectangle width added to x for inclusive right bound" observed_types = ["imm"] [[opcode.semantics.args]] i = 4 -role = "" +role = "rectangle height added to y for inclusive bottom bound" observed_types = ["imm"] [[opcode.semantics.args]] i = 5 -role = "" +role = "target:pointer enter" observed_types = ["imm"] [[opcode.semantics.args]] i = 6 -role = "" +role = "target:pointer leave" observed_types = ["imm"] [[opcode.semantics.args]] i = 7 -role = "" +role = "target:activate/click" observed_types = ["imm"] [[opcode]] @@ -1533,14 +1540,14 @@ argc = 0 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00415040" -category = "unknown" -summary = "" -noop_headless = false -source = "kelebek" -confidence = "low" +name = "cancel-hotspot-wait" +category = "input" +summary = "Reset the current frame's hotspot registry/input wait and clear native run-state bit 0x00800000. Used before opening History, Menu, or HIDEWIN flows." +noop_headless = true +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x93_cancel_hotspot_wait@0x416670 clears run-state bit 0x00800000, calls input_hotspot_reset@0x404130, and clears the active flag at ctx+0xc6e4 (or sets the pending flag at +0xc6e0 when already inactive)." [[opcode]] op = 0x94 @@ -1549,14 +1556,14 @@ argc = 0 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00415090" -category = "unknown" -summary = "" -noop_headless = false -source = "kelebek" -confidence = "low" +name = "arm-hotspot-wait" +category = "input" +summary = "Arm native hotspot input processing after the script has registered its rectangles." +noop_headless = true +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x94_arm_hotspot_wait@0x4166d0 sets ctx+0xc6e4=1 and calls input_hotspot_arm_cursor_tracking@0x404230 with 10000. SC0000 executes it immediately after the five control-strip and three keyed registrations." [[opcode]] op = 0x97 @@ -1565,38 +1572,38 @@ argc = 5 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "hotspot-reg?" +name = "bind-hotspot-key" category = "input" -summary = "companion register-hotspot / set-widget-action (argc5: v1 v2 1 1 ; NO code targets)" +summary = "(x)(y)(w)(h)(input_bit) - find the already-registered rectangle with identical bounds and bind an input/key bit number to it." noop_headless = true -source = "inference" -confidence = "med" +source = "investigation" +confidence = "high" depends_on = [0x90] -evidence = "interleaves with 0x90 in the shared ADV-chrome subroutine; trailing imm = action id 0x0/0x7/0x8; same widget cluster as 0x90/0x91/0x92/0x95; confirm via frida" +evidence = "Ghidra /v2: op_0x97_bind_hotspot_key@0x41ff30 builds the same inclusive rect as op 0x90 and calls input_hotspot_bind_key_bit@0x403f50. That worker searches registered rects for exact equality and stores operand 5 in the record's key-bit array. SC0000 binds bits 0, 8, and 7 to its three 1x1 keyed records." [[opcode.semantics.args]] i = 1 -role = "" +role = "rectangle left x" observed_types = ["l-int"] [[opcode.semantics.args]] i = 2 -role = "" +role = "rectangle top y" observed_types = ["l-int"] [[opcode.semantics.args]] i = 3 -role = "" +role = "rectangle width" observed_types = ["imm"] [[opcode.semantics.args]] i = 4 -role = "" +role = "rectangle height" observed_types = ["imm"] [[opcode.semantics.args]] i = 5 -role = "" +role = "input/key bit index" observed_types = ["imm"] [[opcode]] @@ -2288,14 +2295,14 @@ argc = 0 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00415BF0" -category = "unknown" -summary = "" +name = "reset-message-skip-input" +category = "input" +summary = "Reset transient message-skip/input service state after an ADV chrome action." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "med" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x101_reset_message_skip_input@0x4170a0 resets the input state rooted at ctx+0xa0ce8, clears run-state bit 0x08000000, zeroes ctx+0xa0ce8, and writes ctx+0x6da74=1 / ctx+0x6da80=0. The Auto, Message-skip, Read-skip, and Hide-window click callbacks invoke it after their 100 ms cursor re-arm sequence." [[opcode]] op = 0x107 @@ -2351,23 +2358,23 @@ argc = 2 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00415EC0" -category = "unknown" -summary = "" -noop_headless = false -source = "kelebek" -confidence = "low" +name = "get-cursor-virtual" +category = "input" +summary = "(out_x)(out_y) - read the OS cursor and convert it into AGE's virtual-screen coordinates." +noop_headless = true +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x109_get_cursor_virtual@0x428bb0 calls the cursor-position helper, converts client/display coordinates through the active VirtualFullScreen transform, and writes x/y to operands 1/2. The ADV chrome callbacks preserve x and then move y by alternating -1/+1 before op 0x10a." [[opcode.semantics.args]] i = 1 -role = "" +role = "output virtual x" observed_types = ["g-int", "l-int"] [[opcode.semantics.args]] i = 2 -role = "" +role = "output virtual y" observed_types = ["g-int", "l-int"] [[opcode]] @@ -2377,23 +2384,23 @@ argc = 2 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u0041E540" -category = "unknown" -summary = "" -noop_headless = false -source = "kelebek" -confidence = "low" +name = "set-cursor-virtual" +category = "input" +summary = "(x)(y) - convert AGE virtual-screen coordinates to client/screen coordinates and move the OS cursor." +noop_headless = true +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x10a_set_cursor_virtual@0x421590 maps virtual coordinates through the active VirtualFullScreen geometry and calls SetCursorPos. SC0000 alternates the cursor by one vertical pixel after state-changing ADV button clicks so the hover state re-enters cleanly." [[opcode.semantics.args]] i = 1 -role = "" +role = "virtual x" observed_types = ["g-int", "l-int"] [[opcode.semantics.args]] i = 2 -role = "" +role = "virtual y" observed_types = ["l-int"] [[opcode]] @@ -3248,14 +3255,14 @@ argc = 0 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00414D50" -category = "unknown" -summary = "" +name = "yield-adv-coroutine" +category = "control" +summary = "Yield/re-enter the registered ADV coroutine handler. The fifth standard chrome button uses this transition to enter the HIDEWIN/window-hidden flow." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "med" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x199_yield_adv_coroutine@0x416440 selects the registered coroutine yield-A or yield-B PC according to ctx+0x6dbc8, saves the current resume offset/state, and redirects the current frame PC. SC0000's x=772 ADV button invokes it; the SO001 tooltip at source x=528 reads Window hide, and the surrounding coroutine calls HIDEWIN.BIN." [[opcode]] op = 0x19a @@ -3264,18 +3271,18 @@ argc = 1 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00414E50" -category = "unknown" -summary = "" +name = "get-message-skip" +category = "input" +summary = "(out) - return the current all-message skip state set by op 0x88." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x19a_get_message_skip@0x4271b0 writes ctx+0x550fc to operand 1. The shared ADV redraw routine uses it to select SO001's active Message-skip icon at x=728." [[opcode.semantics.args]] i = 1 -role = "" +role = "output enabled" observed_types = ["g-int"] [[opcode]] @@ -3890,18 +3897,18 @@ argc = 1 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00414F60" -category = "unknown" -summary = "" +name = "get-auto-message" +category = "input" +summary = "(out) - return whether automatic message advance is enabled." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x1b6_get_auto_message@0x4271e0 writes `(ctx+0x55104 != 0)` to operand 1. FUN_00411230 consumes the same field to arm AutoMessageTime0/1 timers; the shared ADV redraw routine uses it for the active Auto icon." [[opcode.semantics.args]] i = 1 -role = "" +role = "output enabled" observed_types = ["g-int"] [[opcode]] @@ -3911,18 +3918,18 @@ argc = 1 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u0041B640" -category = "unknown" -summary = "" +name = "set-auto-message" +category = "input" +summary = "(enabled) - enable or disable automatic message advance." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x1b7_set_auto_message@0x41f560 stores `(operand1 != 0)` at ctx+0x55104. SC0000's x=706 Auto button reads the current value through 0x1b6, toggles modulo 2, then writes it through this opcode." [[opcode.semantics.args]] i = 1 -role = "" +role = "enabled" observed_types = ["g-int"] [[opcode]] @@ -3932,23 +3939,23 @@ argc = 2 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u0041B670" -category = "unknown" -summary = "" +name = "get-auto-message-time" +category = "input" +summary = "(selector)(out) - read an Auto-message delay from engine configuration: selector 0 = post-voice AutoMessageTime0, selector 1 = unvoiced AutoMessageTime1." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x1b8_handler@0x427210 dispatches selector 0 to config key message:AutoMessageTime0 and selector 1 to message:AutoMessageTime1, then writes the result to operand 2. CONFIG.BIN uses it to bound the two UI settings." [[opcode.semantics.args]] i = 1 -role = "" +role = "timer selector: 0=post-voice Time0, 1=unvoiced Time1" observed_types = ["imm"] [[opcode.semantics.args]] i = 2 -role = "" +role = "output milliseconds" observed_types = ["l-int"] [[opcode]] @@ -3958,23 +3965,23 @@ argc = 2 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u0041B710" -category = "unknown" -summary = "" +name = "set-auto-message-time" +category = "input" +summary = "(selector)(milliseconds) - write an Auto-message delay to engine configuration: selector 0 = post-voice AutoMessageTime0, selector 1 = unvoiced AutoMessageTime1." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x1b9_handler@0x41f5a0 dispatches selector 0/1 to config setters for message:AutoMessageTime0/1. CONFIG.BIN initializes them to 500/2000 ms and adjusts each in 500-ms steps within 500..9500." [[opcode.semantics.args]] i = 1 -role = "" +role = "timer selector: 0=post-voice Time0, 1=unvoiced Time1" observed_types = ["imm"] [[opcode.semantics.args]] i = 2 -role = "" +role = "milliseconds" observed_types = ["imm", "l-int"] [[opcode]] @@ -4031,14 +4038,14 @@ argc = 0 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "block-mark" -category = "marker" -summary = "zero-arg; follows jcc/mov, precedes mov/ret — block boundary" -noop_headless = true -source = "inference" +name = "reset-message-voice-state" +category = "input" +summary = "Reset the per-message queued-voice flag used by ADV Auto timing." +noop_headless = false +source = "investigation" confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x1bc_handler@0x416c20 writes zero to ctx+0x6dbe4 (adv_auto_voice_pending). Opcode 0xc4 sets that field when voice playback is queued; adv_input_service_poll consumes it to select the post-voice AutoMessageTime0 path." [[opcode]] op = 0x1bd @@ -4162,18 +4169,18 @@ argc = 1 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u0041B9B0" -category = "unknown" -summary = "" +name = "set-read-message-skip" +category = "input" +summary = "(enabled) - set the engine setting `message:ReadTextSkip`, which skips only previously read text." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x1ca_set_read_message_skip@0x41f880 calls the engine setting interface's setter for `message:ReadTextSkip` with operand 1. SC0000's x=750 Read-message-skip button toggles the value read by op 0x1cb." [[opcode.semantics.args]] i = 1 -role = "" +role = "enabled" observed_types = ["imm", "g-int"] [[opcode]] @@ -4183,18 +4190,18 @@ argc = 1 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00414FD0" -category = "unknown" -summary = "" +name = "get-read-message-skip" +category = "input" +summary = "(out) - read the engine setting `message:ReadTextSkip`." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x1cb_get_read_message_skip@0x4272f0 calls the engine setting interface's getter for `message:ReadTextSkip` and writes the result to operand 1. The shared ADV redraw routine uses it for the active Read-message-skip icon." [[opcode.semantics.args]] i = 1 -role = "" +role = "output enabled" observed_types = ["g-int"] [[opcode]]