From f7a275565b9eda4f6c355481c758329aebc8f507 Mon Sep 17 00:00:00 2001 From: SaltyC <27722486+MadCcc@users.noreply.github.com> Date: Sun, 25 Oct 2026 05:18:37 +0800 Subject: [PATCH 1/2] fix(rfvp): preserve short-click settings updates --- apps/godot_app/scripts/main.gd | 6 + .../scripts/runtime_regression_probe.gd | 20 +-- .../tests/runtime_regression_touch_test.gd | 13 ++ bridge/rfvp_runtime/README.md | 11 ++ .../cmake/PrepareRfvpSources.cmake | 1 + bridge/rfvp_runtime/include/rfvp_host.h | 2 + bridge/rfvp_runtime/rust/aetherkiri_host.rs | 53 +++++-- bridge/rfvp_runtime/rust/input_host.rs | 144 ++++++++++++++++++ bridge/rfvp_runtime/rust/text_snapshot.rs | 9 ++ .../src/rfvp_runtime_provider.cpp | 5 + doc/runtime_regression.md | 3 +- 11 files changed, 240 insertions(+), 27 deletions(-) create mode 100644 bridge/rfvp_runtime/rust/input_host.rs diff --git a/apps/godot_app/scripts/main.gd b/apps/godot_app/scripts/main.gd index 9f3456343..14f922075 100644 --- a/apps/godot_app/scripts/main.gd +++ b/apps/godot_app/scripts/main.gd @@ -2487,6 +2487,8 @@ var input_trace_enabled := false var input_trace_accum := 0.0 var input_trace_received := 0 var input_trace_forwarded := 0 +var input_total_forwarded := 0 +var input_total_send_failed := 0 var input_trace_blocked := 0 var input_trace_throttled := 0 var input_trace_busy := 0 @@ -17449,8 +17451,10 @@ func _on_game_virtual_key_event( pressed, key_code, modifiers, unicode_codepoint )) input_trace_forwarded += 1 + input_total_forwarded += 1 if result != ENGINE_RESULT_OK: input_trace_send_failed += 1 + input_total_send_failed += 1 if input_trace_enabled: _write_probe_marker( "game_virtual_key pressed=%s key=0x%02X modifiers=0x%02X result=%d" % [ @@ -18076,8 +18080,10 @@ func _send_game_pointer_event(event_type: int, pointer_id: int, x: float, y: flo result, ]) input_trace_forwarded += 1 + input_total_forwarded += 1 if result != ENGINE_RESULT_OK: input_trace_send_failed += 1 + input_total_send_failed += 1 if input_trace_enabled and event_type in [POINTER_DOWN, POINTER_UP]: var trace_line := "input_event type=%d pid=%d x=%.1f y=%.1f button=%d result=%d loading=%s runtime=%s" % [ event_type, diff --git a/apps/godot_app/scripts/runtime_regression_probe.gd b/apps/godot_app/scripts/runtime_regression_probe.gd index 7e60bd0e6..f8db02c5f 100644 --- a/apps/godot_app/scripts/runtime_regression_probe.gd +++ b/apps/godot_app/scripts/runtime_regression_probe.gd @@ -240,8 +240,8 @@ func tap(action: Dictionary, label: String) -> bool: if mapped.distance_to(source) > 1.0: record({"event": "failed", "label": label, "reason": "touch coordinate round trip", "source": [source.x, source.y], "mapped": [mapped.x, mapped.y]}) return false - var forwarded := int(app.input_trace_forwarded) - var failed := int(app.input_trace_send_failed) + var forwarded := int(app.input_total_forwarded) + var failed := int(app.input_total_send_failed) var pointer_id := int(action.get("touch_index", 0)) var event := InputEventScreenTouch.new() event.index = pointer_id @@ -250,7 +250,7 @@ func tap(action: Dictionary, label: String) -> bool: if not app._handle_game_pointer_event(event): record({"event": "failed", "label": label, "reason": "touch press not handled"}) return false - if not await advance(int(action.get("hold_frames", 1))): + if int(action.get("hold_frames", 1)) > 0 and not await advance(int(action.get("hold_frames", 1))): return false event = InputEventScreenTouch.new() event.index = pointer_id @@ -266,8 +266,8 @@ func tap(action: Dictionary, label: String) -> bool: return false if not await advance(1): return false - var sent := int(app.input_trace_forwarded) - forwarded - if sent < 3 or int(app.input_trace_send_failed) != failed: + var sent := int(app.input_total_forwarded) - forwarded + if sent < 3 or int(app.input_total_send_failed) != failed: record({"event": "failed", "label": label, "reason": "touch not forwarded", "forwarded": sent, "state": state()}) return false record({"event": "input", "label": label, "kind": "touch", "source": [source.x, source.y], "viewport": [position.x, position.y], "forwarded": sent}) @@ -284,8 +284,8 @@ func two_finger_tap(action: Dictionary, label: String) -> bool: if mapped.distance_to(source) > 1.0: record({"event": "failed", "label": label, "reason": "gesture coordinate round trip"}) return false - var forwarded := int(app.input_trace_forwarded) - var failed := int(app.input_trace_send_failed) + var forwarded := int(app.input_total_forwarded) + var failed := int(app.input_total_send_failed) # Both contacts arrive before a frame can flush the pending primary tap. # The real gesture handler chooses the secondary button and cancels the # contacts; injecting a native right-click here would bypass that code. @@ -298,8 +298,8 @@ func two_finger_tap(action: Dictionary, label: String) -> bool: if not app._handle_game_pointer_event(event): record({"event": "failed", "label": label, "reason": "gesture not handled"}) return false - var sent := int(app.input_trace_forwarded) - forwarded - if sent < 3 or int(app.input_trace_send_failed) != failed: + var sent := int(app.input_total_forwarded) - forwarded + if sent < 3 or int(app.input_total_send_failed) != failed: record({"event": "failed", "label": label, "reason": "gesture not forwarded", "forwarded": sent}) return false record({"event": "input", "label": label, "kind": "two_finger_tap", "source": [source.x, source.y], "forwarded": sent}) @@ -379,7 +379,7 @@ func run(game_path: String) -> int: if result != app.ENGINE_RESULT_OK: record({"event": "failed", "label": label, "reason": "input rejected", "error": app.player.get_last_error()}) return 1 - if not await advance(int(action.get("hold_frames", 3))): + if int(action.get("hold_frames", 3)) > 0 and not await advance(int(action.get("hold_frames", 3))): return 1 app.player.send_pointer_event(app.POINTER_UP, 0, pos.x, pos.y, 0.0, 0.0, int(action.get("button_index", 0))) elif kind == "key": diff --git a/apps/godot_app/tests/runtime_regression_touch_test.gd b/apps/godot_app/tests/runtime_regression_touch_test.gd index ae768b5ef..3ecbe6fe7 100644 --- a/apps/godot_app/tests/runtime_regression_touch_test.gd +++ b/apps/godot_app/tests/runtime_regression_touch_test.gd @@ -5,6 +5,8 @@ const Probe = preload("res://scripts/runtime_regression_probe.gd") class RecordingPlayer: extends Node var events: Array[Dictionary] = [] + func is_initialized() -> bool: + return true func get_frame_source_size() -> Vector2i: return Vector2i(1280, 720) func get_renderer_info() -> String: @@ -18,6 +20,7 @@ class RecordingPlayer: class TouchApp: extends "res://scripts/main.gd" var force_busy := false + var reset_trace_counters := false func _ready() -> void: pass func _process(_delta: float) -> void: @@ -30,6 +33,9 @@ class TouchApp: pass func _probe_advance(frames: int) -> bool: for _frame in frames: + if reset_trace_counters: + input_trace_forwarded = 0 + input_trace_send_failed = 0 _flush_delayed_touch_releases() _flush_pending_touch_press_if_ready() await get_tree().process_frame @@ -78,6 +84,13 @@ func _run() -> void: assert(player.events[2].time - player.events[1].time >= app.TOUCH_CLICK_HOLD_MS) assert(app.delayed_touch_releases.is_empty()) + player.events.clear() + app.reset_trace_counters = true + assert(await probe.tap({"x": 1200, "y": 300, "hold_frames": 0}, "same-frame-touch")) + _check_edges(player.events, app, 100000) + assert(app.delayed_touch_releases.is_empty()) + app.reset_trace_counters = false + # A stationary hold is dispatched before release; it must not be turned # into a second tap or leave the native button held afterwards. player.events.clear() diff --git a/bridge/rfvp_runtime/README.md b/bridge/rfvp_runtime/README.md index e003f8167..59c073622 100644 --- a/bridge/rfvp_runtime/README.md +++ b/bridge/rfvp_runtime/README.md @@ -106,6 +106,17 @@ Godot's input mapping regression covers rfvp at a mismatched presentation size while preserving KiriKiri's surface mapping. Games open on a native worker thread, matching the application's asynchronous startup, and then tick, handle input and close on the host thread. +Pointer events retain their order across VM ticks. Each mouse button changes +state at most once per tick, so a short tap cannot publish its press and release +edges together and bypass a slider's setting callback. Consecutive moves are +coalesced; cancellation and pause discard queued input and clear held buttons. +Host pointer regressions cover short taps, repeated clicks and cancellation. + +The provider's on-demand JSON diagnostics expose the two global regions, +their boundary, and loaded text slots' speed and reveal counts. Runtime probes +can check both the script's option display and the value consumed by the text +engine, including after a save-directory seed is loaded in a fresh process. + The macOS arm64 Debug app has also been smoke-tested through the normal library import/launch flow, presenting the painter's 1024x640 frame via Godot/Metal. A local smoke test with the original Shift-JIS script of diff --git a/bridge/rfvp_runtime/cmake/PrepareRfvpSources.cmake b/bridge/rfvp_runtime/cmake/PrepareRfvpSources.cmake index 05efd7adf..655f04689 100644 --- a/bridge/rfvp_runtime/cmake/PrepareRfvpSources.cmake +++ b/bridge/rfvp_runtime/cmake/PrepareRfvpSources.cmake @@ -37,6 +37,7 @@ function(aetherkiri_prepare_rfvp root rfvp_dir output) rfvp_replace("${output}/crates/rfvp/Cargo.toml" "crate-type = [\"rlib\", \"cdylib\"]" "crate-type = [\"rlib\", \"staticlib\"]") file(COPY "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/../rust/aetherkiri_host.rs" DESTINATION "${src}") + file(COPY "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/../rust/input_host.rs" DESTINATION "${src}") file(APPEND "${src}/lib.rs" "\npub mod aetherkiri_host;\n") foreach(os ios android) rfvp_replace("${src}/lib.rs" diff --git a/bridge/rfvp_runtime/include/rfvp_host.h b/bridge/rfvp_runtime/include/rfvp_host.h index 80da4a1e3..1d2ab5218 100644 --- a/bridge/rfvp_runtime/include/rfvp_host.h +++ b/bridge/rfvp_runtime/include/rfvp_host.h @@ -59,6 +59,8 @@ int32_t aether_rfvp_frame(void* runtime, uint32_t* width, uint32_t* height, uint int32_t aether_rfvp_gpu_frame(void* runtime, uint64_t* texture, uint32_t* width, uint32_t* height, uint64_t* serial); int32_t aether_rfvp_read(void* runtime, void* pixels, size_t size); +int32_t aether_rfvp_debug(void* runtime, char* output, uint32_t size, + uint32_t* written); const char* aether_rfvp_error(void* runtime); uint32_t aether_rfvp_log(char* output, size_t size); #ifdef __cplusplus diff --git a/bridge/rfvp_runtime/rust/aetherkiri_host.rs b/bridge/rfvp_runtime/rust/aetherkiri_host.rs index 3e695a1a6..5741906be 100644 --- a/bridge/rfvp_runtime/rust/aetherkiri_host.rs +++ b/bridge/rfvp_runtime/rust/aetherkiri_host.rs @@ -238,6 +238,8 @@ fn parse(path: &Path, nls: Nls) -> Result { Ok(parser) } +include!("input_host.rs"); + struct Core { world: Box, parser: Parser, @@ -247,6 +249,7 @@ struct Core { renderer: SoftRenderer, size: (u32, u32), serial: u64, + pointer_input: HostPointerInput, paused: bool, paused_at: Option, dissolve: DissolveType, @@ -324,6 +327,7 @@ impl Core { renderer, size, serial: 0, + pointer_input: HostPointerInput::default(), paused: false, paused_at: None, dissolve: DissolveType::None, @@ -336,6 +340,7 @@ impl Core { return Ok(0); } let gd = &mut *self.world; + self.pointer_input.apply_frame(&mut gd.inputs_manager, self.size); gd.motion_manager.text_manager.set_render_scale(1.0); gd.time_mut_ref() .set_external_delta(Duration::from_millis(delta as u64)); @@ -444,7 +449,7 @@ impl Core { self.world.video_manager.pause_host(paused, elapsed); // BGM, effects and movie soundtracks share the upstream audio backend. self.world.audio_manager().backend().pause_host(paused); - self.world.inputs_manager.cancel_host_input(); + self.pointer_input.cancel(&mut self.world.inputs_manager); self.paused = paused; Ok(0) } @@ -620,27 +625,19 @@ pub unsafe extern "C" fn aether_rfvp_input( x.is_finite() && y.is_finite() && wheel.is_finite(), "Invalid input coordinates" ); - let input = &mut core.world.inputs_manager; if modifiers & (1 << 30) != 0 { - input.cancel_host_input(); + core.pointer_input.cancel(&mut core.world.inputs_manager); return Ok(0); } if (1..=4).contains(&kind) { - input.notify_mouse_move(x as i32, y as i32); - input.set_mouse_in( - x >= 0.0 && y >= 0.0 && x < core.size.0 as f64 && y < core.size.1 as f64, - ); + core.pointer_input.enqueue(PointerEvent { + kind, x: x as i32, y: y as i32, button, + wheel: wheel.round().clamp(-1200.0, 1200.0) as i32, + })?; + return Ok(0); } - let mouse = if button == 1 { - KeyCode::MouseRight - } else { - KeyCode::MouseLeft - }; + let input = &mut core.world.inputs_manager; match kind { - 1 => input.notify_mouse_down(mouse), - 2 => (), - 3 => input.notify_mouse_up(mouse), - 4 => input.notify_mouse_wheel(wheel.round().clamp(-1200.0, 1200.0) as i32), 5 => { if let Some(key) = key(code) { input.notify_keycode_down(key, false); @@ -721,6 +718,30 @@ pub unsafe extern "C" fn aether_rfvp_error(p: *mut c_void) -> *const c_char { .as_ref() .map_or(ptr::null(), |s| s.error.as_ptr()) } +#[no_mangle] +pub unsafe extern "C" fn aether_rfvp_debug( + p: *mut c_void, output: *mut c_char, size: u32, written: *mut u32, +) -> i32 { + if !written.is_null() { *written = 0; } + if output.is_null() || written.is_null() { return -1; } + call(p, |session| { + let core = session.core()?; + let globals = GLOBAL.lock().unwrap(); + let bytes = serde_json::to_vec(&serde_json::json!({ + "runtime": "rfvp", "frame_serial": core.serial, + "global_base": globals.non_volatile_count(), + "game_globals": globals.snapshot_non_volatile(), + "system_globals": globals.snapshot_volatile_globals(), + "text": core.world.motion_manager.text_manager.host_text_diagnostics(), + }))?; + if bytes.len() >= size as usize { return Ok(-1); } + ptr::copy_nonoverlapping(bytes.as_ptr(), output.cast(), bytes.len()); + *output.add(bytes.len()) = 0; + *written = bytes.len() as u32; + Ok(0) + }) +} + #[no_mangle] pub unsafe extern "C" fn aether_rfvp_log(output: *mut c_char, size: usize) -> u32 { if output.is_null() || size < 4096 { diff --git a/bridge/rfvp_runtime/rust/input_host.rs b/bridge/rfvp_runtime/rust/input_host.rs new file mode 100644 index 000000000..9458f11c3 --- /dev/null +++ b/bridge/rfvp_runtime/rust/input_host.rs @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: MPL-2.0 +use crate::subsystem::resources::input_manager::InputManager; + +#[derive(Clone, Copy)] +struct PointerEvent { + kind: u32, + x: i32, + y: i32, + button: i32, + wheel: i32, +} + +#[derive(Default)] +struct HostPointerInput { + pending: VecDeque, + pressed: u8, +} + +impl HostPointerInput { + fn enqueue(&mut self, event: PointerEvent) -> Result<()> { + if event.kind == 2 && self.pending.back().is_some_and(|last| last.kind == 2) { + *self.pending.back_mut().unwrap() = event; + return Ok(()); + } + ensure!(self.pending.len() < 4096, "rfvp pointer input queue is full"); + self.pending.push_back(event); + Ok(()) + } + + fn position(input: &mut InputManager, event: PointerEvent, size: (u32, u32)) { + input.notify_mouse_move(event.x, event.y); + input.set_mouse_in(event.x >= 0 && event.y >= 0 + && event.x < size.0 as i32 && event.y < size.1 as i32); + } + + fn apply_frame(&mut self, input: &mut InputManager, size: (u32, u32)) { + // A short touch can deliver both edges between VM ticks. FVP sliders + // update their preview, then skip the setting callback if InputGetUp + // is already set. Preserve each button transition for a separate tick. + let mut changed = 0u8; + while let Some(event) = self.pending.front().copied() { + let bit = 1 << u8::from(event.button == 1); + let transition = matches!(event.kind, 1 | 3) + && (self.pressed & bit != 0) != (event.kind == 1); + if transition && changed & bit != 0 { + if event.kind == 3 { + // Keep the final release position available to this tick's + // drag callback without publishing the release edge yet. + Self::position(input, event, size); + } + break; + } + self.pending.pop_front(); + Self::position(input, event, size); + let button = if event.button == 1 { KeyCode::MouseRight } else { KeyCode::MouseLeft }; + match event.kind { + 1 => input.notify_mouse_down(button), + 3 => input.notify_mouse_up(button), + 4 => input.notify_mouse_wheel(event.wheel), + _ => (), + } + if transition { + changed |= bit; + if event.kind == 1 { self.pressed |= bit; } + else { self.pressed &= !bit; } + } + } + } + + fn cancel(&mut self, input: &mut InputManager) { + self.pending.clear(); + self.pressed = 0; + input.cancel_host_input(); + } +} + +#[cfg(test)] +mod host_pointer_tests { + use super::*; + + fn event(kind: u32, x: i32) -> PointerEvent { + PointerEvent { kind, x, y: 20, button: 0, wheel: 0 } + } + + fn frame(queue: &mut HostPointerInput, input: &mut InputManager) { + queue.apply_frame(input, (100, 100)); + input.begin_frame(); + } + + #[test] + fn short_click_keeps_press_and_release_in_distinct_vm_frames() { + let mut input = InputManager::new(); + let mut queue = HostPointerInput::default(); + queue.enqueue(event(1, 10)).unwrap(); + queue.enqueue(event(3, 30)).unwrap(); + frame(&mut queue, &mut input); + assert_ne!(input.get_input_down() & (1 << 4), 0); + assert_ne!(input.get_input_state() & (1 << 4), 0); + assert_eq!(input.get_input_up() & (1 << 4), 0); + assert_eq!(input.get_cursor_x(), 30); + frame(&mut queue, &mut input); + assert_eq!(input.get_input_down() & (1 << 4), 0); + assert_eq!(input.get_input_state() & (1 << 4), 0); + assert_ne!(input.get_input_up() & (1 << 4), 0); + frame(&mut queue, &mut input); + assert_eq!(input.get_input_up(), 0); + } + + #[test] + fn repeated_short_clicks_preserve_order_and_positions() { + let mut input = InputManager::new(); + let mut queue = HostPointerInput::default(); + input.set_click(0); + for x in [10, 30, 50] { + queue.enqueue(event(1, x)).unwrap(); + queue.enqueue(event(3, x)).unwrap(); + } + for x in [10, 30, 50] { + frame(&mut queue, &mut input); + assert_eq!(input.get_cursor_x(), x); + assert_ne!(input.get_input_down() & (1 << 4), 0); + assert!(input.get_event().is_none()); + frame(&mut queue, &mut input); + assert_eq!(input.get_event().unwrap().get_x(), x); + assert_ne!(input.get_input_up() & (1 << 4), 0); + } + assert!(queue.pending.is_empty()); + } + + #[test] + fn cancellation_drops_queued_clicks_and_held_buttons() { + let mut input = InputManager::new(); + let mut queue = HostPointerInput::default(); + queue.enqueue(event(1, 10)).unwrap(); + queue.enqueue(event(3, 10)).unwrap(); + frame(&mut queue, &mut input); + queue.cancel(&mut input); + frame(&mut queue, &mut input); + assert_eq!(input.get_input_down(), 0); + assert_eq!(input.get_input_up(), 0); + assert_eq!(input.get_input_state(), 0); + assert!(input.get_event().is_none()); + } +} diff --git a/bridge/rfvp_runtime/rust/text_snapshot.rs b/bridge/rfvp_runtime/rust/text_snapshot.rs index 89e6c650f..32b3f7a4a 100644 --- a/bridge/rfvp_runtime/rust/text_snapshot.rs +++ b/bridge/rfvp_runtime/rust/text_snapshot.rs @@ -54,6 +54,15 @@ mod host_snapshot_tests { } impl TextManager { + pub(crate) fn host_text_diagnostics(&self) -> serde_json::Value { + serde_json::Value::Array(self.items.iter().enumerate() + .filter(|(_, item)| item.loaded) + .map(|(id, item)| serde_json::json!({ + "id": id, "speed": item.speed, "total_chars": item.total_chars, + "visible_chars": item.visible_chars, "suspended": item.is_suspended, + })).collect()) + } + pub(crate) fn capture_host_playback(&self) -> Vec { self.items .iter() diff --git a/bridge/rfvp_runtime/src/rfvp_runtime_provider.cpp b/bridge/rfvp_runtime/src/rfvp_runtime_provider.cpp index 69e41f311..8fd30f218 100644 --- a/bridge/rfvp_runtime/src/rfvp_runtime_provider.cpp +++ b/bridge/rfvp_runtime/src/rfvp_runtime_provider.cpp @@ -237,6 +237,10 @@ engine_result_t TextInput(void*, uint32_t* flags) { if (!flags) return ENGINE_RESULT_INVALID_ARGUMENT; *flags = 0; return ENGINE_RESULT_OK; } +engine_result_t Debug(void* p, char* output, uint32_t size, uint32_t* written) { + if (!p) return ENGINE_RESULT_INVALID_ARGUMENT; + return cast(p)->result(aether_rfvp_debug(cast(p)->core, output, size, written)); +} const char* Error(void* p) { return p ? cast(p)->error.c_str() : "Invalid rfvp runtime"; } const engine_runtime_provider_v1_t& Provider() { static const auto provider = [] { @@ -249,6 +253,7 @@ const engine_runtime_provider_v1_t& Provider() { p.get_godot_native_frame_texture = NativeFrame; p.get_frame_rendered_flag = Rendered; p.get_renderer_info = Renderer; p.get_text_input_state = TextInput; p.get_last_error = Error; + p.get_plugin_debug_info = Debug; return p; }(); return provider; diff --git a/doc/runtime_regression.md b/doc/runtime_regression.md index 1db1578a0..64d67a1f7 100644 --- a/doc/runtime_regression.md +++ b/doc/runtime_regression.md @@ -98,7 +98,8 @@ trip, input is suppressed or rejected, or the release does not finish. `click` use this path. Use it for iOS button regressions; ordinary `click` defaults to direct player pointer input. Touch supports only the primary button; `hold_frames` defaults to 1 and larger values also exercise stationary -holds. +holds. Set `hold_frames` to 0 for pointer actions to deliver press and release +without advancing a frame between them, covering short taps and clicks. `two_finger_tap` sends two simultaneous contacts through the same handler and requires a forwarded secondary gesture. It exercises two-finger return without From c7b31bbca5b11d9c54734d909aa33623be25ecf7 Mon Sep 17 00:00:00 2001 From: SaltyC <27722486+MadCcc@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:39:10 +0800 Subject: [PATCH 2/2] test(godot): exercise production Mouse-mode regression input --- .../scripts/runtime_regression_probe.gd | 120 +++++++++++++++++- .../tests/runtime_regression_touch_test.gd | 35 ++++- doc/runtime_regression.md | 14 +- tools/run_runtime_regression.py | 4 +- 4 files changed, 168 insertions(+), 5 deletions(-) diff --git a/apps/godot_app/scripts/runtime_regression_probe.gd b/apps/godot_app/scripts/runtime_regression_probe.gd index f8db02c5f..3e1f526e8 100644 --- a/apps/godot_app/scripts/runtime_regression_probe.gd +++ b/apps/godot_app/scripts/runtime_regression_probe.gd @@ -305,9 +305,110 @@ func two_finger_tap(action: Dictionary, label: String) -> bool: record({"event": "input", "label": label, "kind": "two_finger_tap", "source": [source.x, source.y], "forwarded": sent}) return true +func prepare_virtual_mouse() -> bool: + app._sync_game_virtual_controls() + var controls = app.game_virtual_controls + if controls == null or not controls.visible or not app._can_forward_virtual_controls_input(): + return false + if not controls.is_panel_open(): + if not controls.is_menu_open(): + controls.menu_button.emit_signal("pressed") + controls.virtual_controls_button.emit_signal("pressed") + if controls.is_touch_mode(): + controls.input_mode_button.emit_signal("pressed") + return controls.is_panel_open() and controls.input_mode_button.text == "Mode: Mouse" + +func virtual_mouse_click(action: Dictionary, label: String) -> bool: + if int(action.get("button_index", 0)) != 0 or not prepare_virtual_mouse(): + record({"event": "failed", "label": label, "reason": "virtual mouse unavailable"}) + return false + var controls = app.game_virtual_controls + var source := point(action) + var frame: Vector2i = app.player.get_frame_source_size() + var target: Vector2 = app._map_surface_point_to_viewport(source) + if source.x < 0 or source.y < 0 or source.x >= frame.x or source.y >= frame.y \ + or app._map_viewport_point(target).distance_to(source) > 1.0: + record({"event": "failed", "label": label, "reason": "invalid virtual mouse target"}) + return false + # Use the real Mouse-mode trackpad recognizer. A finger's position is + # separate from the cursor hotspot; no native click is injected here. + var safe: Rect2 = controls._safe_rect + var touchpad := Vector2(-1, -1) + for y in [0.28, 0.42, 0.58]: + for x in [0.55, 0.45, 0.65]: + var candidate := safe.position + safe.size * Vector2(x, y) + if not controls._visible_interactive_control_at(candidate) and not controls._dpad_contains_point(candidate): + touchpad = candidate + break + if touchpad.x >= 0: + break + if touchpad.x < 0: + record({"event": "failed", "label": label, "reason": "no virtual trackpad area"}) + return false + var edges: Array[Dictionary] = [] + var observe := func(pressed: bool, button: int, modifiers: int, position: Vector2): + edges.append({"pressed": pressed, "button": button, "modifiers": modifiers, + "position": [position.x, position.y], "frame": Engine.get_process_frames()}) + controls.pointer_button_requested.connect(observe) + var forwarded := int(app.input_total_forwarded) + var failed := int(app.input_total_send_failed) + var drags := 0 + while controls.cursor_screen_position().distance_to(target) > 1.0 and drags < 32: + var delta: Vector2 = target - controls.cursor_screen_position() + var room: Vector2 = Vector2(minf(touchpad.x - safe.position.x, safe.end.x - touchpad.x), + minf(touchpad.y - safe.position.y, safe.end.y - touchpad.y)) * 0.8 + delta = delta.limit_length(minf(room.x, room.y)) + var down := InputEventScreenTouch.new() + down.index = 40 + down.position = touchpad + down.pressed = true + var handled: bool = controls.routes_pointer(down) + # Crossing the drag threshold also prevents a tiny final movement + # from being mistaken for another trackpad tap. + for movement in [delta.normalized() * maxf(delta.length(), 12.0), delta]: + var drag := InputEventScreenDrag.new() + drag.index = down.index + drag.position = touchpad + movement + handled = controls.routes_pointer(drag) and handled + var up := InputEventScreenTouch.new() + up.index = down.index + up.position = touchpad + delta + up.pressed = false + handled = controls.routes_pointer(up) and handled + drags += 1 + if not handled or not edges.is_empty() or not await advance(1): + break + var positioned: bool = controls.cursor_screen_position().distance_to(target) <= 1.0 + var handled := false + if positioned and edges.is_empty(): + var down := InputEventScreenTouch.new() + down.index = 41 + down.position = touchpad + down.pressed = true + handled = controls.routes_pointer(down) + if int(action.get("hold_frames", 3)) > 0: + handled = await advance(int(action.get("hold_frames", 3))) and handled + var up := InputEventScreenTouch.new() + up.index = down.index + up.position = touchpad + up.pressed = false + handled = controls.routes_pointer(up) and handled + controls.pointer_button_requested.disconnect(observe) + var sent := int(app.input_total_forwarded) - forwarded + var valid_edges: bool = edges.size() == 2 and edges[0].pressed and not edges[1].pressed \ + and edges[0].button == 0 and edges[1].button == 0 and edges[0].frame == edges[1].frame + if not positioned or not handled or not valid_edges or sent < 2 or int(app.input_total_send_failed) != failed: + record({"event": "failed", "label": label, "reason": "virtual mouse gesture not forwarded", + "positioned": positioned, "edges": edges, "forwarded": sent, "state": state()}) + return false + record({"event": "input", "label": label, "kind": "virtual_mouse", "mode": controls.input_mode_button.text, + "source": [source.x, source.y], "viewport": [target.x, target.y], + "touchpad": [touchpad.x, touchpad.y], "drags": drags, "edges": edges, "forwarded": sent}) + return true + func run(game_path: String) -> int: var pointer_input := String(config.get("pointer_input", "mouse")) - if pointer_input not in ["mouse", "touch"]: + if pointer_input not in ["mouse", "touch", "virtual_mouse"]: record({"event": "failed", "reason": "invalid pointer_input"}) return 1 var launch_mode := String(config.get("launch_mode", "probe")) @@ -358,6 +459,23 @@ func run(game_path: String) -> int: elif kind == "snapshot": snapshots[label] = state().duplicate(true) record({"event": "snapshot", "label": label, "state": snapshots[label]}) + elif kind == "virtual_mouse_click" or (kind == "click" and pointer_input == "virtual_mouse"): + if not await virtual_mouse_click(action, label): + return 1 + elif kind == "virtual_key": + if not prepare_virtual_mouse(): + record({"event": "failed", "label": label, "reason": "virtual controls unavailable"}) + return 1 + var control: Variant = app.game_virtual_controls.get(String(action.get("control", "escape_button"))) + if not control is Button or not control.visible: + record({"event": "failed", "label": label, "reason": "missing virtual key"}) + return 1 + control.emit_signal("button_down") + if not await advance(int(action.get("hold_frames", 3))): + control.emit_signal("button_up") + return 1 + control.emit_signal("button_up") + record({"event": "input", "label": label, "kind": "virtual_key", "control": control.name}) elif kind == "tap" or (kind == "click" and pointer_input == "touch"): if not await tap(action, label): return 1 diff --git a/apps/godot_app/tests/runtime_regression_touch_test.gd b/apps/godot_app/tests/runtime_regression_touch_test.gd index 3ecbe6fe7..f6e5d7eb2 100644 --- a/apps/godot_app/tests/runtime_regression_touch_test.gd +++ b/apps/godot_app/tests/runtime_regression_touch_test.gd @@ -1,6 +1,8 @@ extends SceneTree const Probe = preload("res://scripts/runtime_regression_probe.gd") +const VirtualControls = preload("res://scripts/game_virtual_controls.gd") +const DesignTokens = preload("res://scripts/ui/aether_design_tokens.gd") class RecordingPlayer: extends Node @@ -119,11 +121,42 @@ func _run() -> void: assert(player.events.is_empty()) assert(probe.report.back().reason == "gesture not forwarded") + # Mouse mode uses relative touchpad motion and generates both native + # button edges on finger release, even when the finger is held for frames. + app.force_busy = false + app.game_running = true + app.cached_startup_state = app.STARTUP_SUCCEEDED + var controls := VirtualControls.new() + app.add_child(controls) + controls.setup(DesignTokens.new()) + controls.layout(Vector2(1000, 750), Rect2(0, 0, 1000, 750)) + app.game_virtual_controls = controls + controls.pointer_move_requested.connect(app._on_game_virtual_pointer_move) + controls.pointer_button_requested.connect(app._on_game_virtual_pointer_button) + player.events.clear() + assert(await probe.virtual_mouse_click({"x": 1200, "y": 300, "hold_frames": 3}, "mouse-mode-trackpad")) + var edges: Array[Dictionary] = [] + for event in player.events: + assert(event.pointer == app.VIRTUAL_CONTROLS_POINTER_ID) + if event.kind != app.POINTER_MOVE: + edges.append(event) + assert(edges.size() == 2) + assert(edges[0].kind == app.POINTER_DOWN and edges[1].kind == app.POINTER_UP) + assert(edges[0].position.distance_to(Vector2(1200, 300)) <= 1.0) + assert(edges[1].position.distance_to(Vector2(1200, 300)) <= 1.0) + assert(probe.report.back().mode == "Mode: Mouse") + assert(probe.report.back().drags > 0) + assert(probe.report.back().edges[0].frame == probe.report.back().edges[1].frame) + player.events.clear() + app.app_lifecycle_paused = true + assert(not await probe.virtual_mouse_click({"x": 200, "y": 300}, "paused-mouse-mode")) + assert(player.events.is_empty()) + DirAccess.remove_absolute(ProjectSettings.globalize_path(directory.path_join("regression-result.json"))) DirAccess.remove_absolute(ProjectSettings.globalize_path(directory)) app.ui_motion.free() app.free() - print("Runtime regression touch mapping, mobile release and suppression checks passed") + print("Runtime regression touch, virtual Mouse-mode routing and suppression checks passed") quit(0) func _check_edges(events: Array[Dictionary], app: Node, pointer: int) -> void: diff --git a/doc/runtime_regression.md b/doc/runtime_regression.md index 64d67a1f7..0cc951f3c 100644 --- a/doc/runtime_regression.md +++ b/doc/runtime_regression.md @@ -84,7 +84,8 @@ Run directories are retained for investigation and can be removed afterwards. } ``` -Actions are `move`, `click`, `tap`, `two_finger_tap`, `key`, `wait`, `snapshot`, `assert`, and `reopen`. Pointer +Actions are `move`, `click`, `tap`, `two_finger_tap`, `virtual_mouse_click`, +`virtual_key`, `key`, `wait`, `snapshot`, `assert`, and `reopen`. Pointer coordinates use the source frame's pixels. A click can select a visible, enabled button with a partial `button` dictionary instead of coordinates, or use `point_from` to find a saved rectangle (`x`, `y`, `w`, `h`). @@ -101,6 +102,17 @@ button; `hold_frames` defaults to 1 and larger values also exercise stationary holds. Set `hold_frames` to 0 for pointer actions to deliver press and release without advancing a frame between them, covering short taps and clicks. +`virtual_mouse_click` opens the production virtual-control panel in +`Mode: Mouse`, moves its cursor using ScreenTouch/ScreenDrag trackpad gestures, +then taps the trackpad at a separate finger position. The real gesture +recognizer generates the click through its connected application handlers. +It verifies the cursor mapping, emitted button order, same-frame button edges, +and successful forwarding. The finger is held for 3 frames by default; Mouse +mode still emits both native edges together on release. +`--pointer-input virtual_mouse` makes every `click` use this path on either platform. +`virtual_key` presses a visible virtual button selected by its `control` +property (default `escape_button`) through its button-down/up signals. + `two_finger_tap` sends two simultaneous contacts through the same handler and requires a forwarded secondary gesture. It exercises two-finger return without bypassing gesture recognition by sending a native right-click directly. diff --git a/tools/run_runtime_regression.py b/tools/run_runtime_regression.py index 9e7335b12..c6dd747e9 100644 --- a/tools/run_runtime_regression.py +++ b/tools/run_runtime_regression.py @@ -48,8 +48,8 @@ def arguments(): parser.add_argument("--output", type=Path, required=True) parser.add_argument("--seed", type=Path, help="Copy this writable-state tree into the isolated run") parser.add_argument("--game", help="Override the case's game_path on this host or device") - parser.add_argument("--pointer-input", choices=("mouse", "touch"), - help="Use the app's ScreenTouch handler for clicks instead of direct player pointer input") + parser.add_argument("--pointer-input", choices=("mouse", "touch", "virtual_mouse"), + help="Click through direct player input, ScreenTouch, or the virtual Mouse-mode trackpad") parser.add_argument("--timeout", type=int, default=180) parser.add_argument("--device") parser.add_argument("--bundle", help="Installed development app bundle ID")