From d45220c865fcc53f0ff993f33cd5f92e3fb4c04b Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Wed, 15 Jul 2026 17:29:21 +0200 Subject: [PATCH 1/2] fix(input): injected keys never produced an isKeyPressed edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit keys_pressed is computed in begin_frame as (keys_down && !prev_keys_down), but prev_keys_down is copied from keys_down in end_frame. A REAL key arrives in poll_events(), which runs BEFORE begin_frame, so its edge is computed. An INJECTED key (bloom_inject_key_down) is raised from the game loop — AFTER begin_frame — so end_frame folded it into prev_keys_down before begin_frame ever looked at it, and the edge was never computed at all. isKeyDown() saw injected keys; isKeyPressed() never did. That silently made the injection API useless for anything edge-triggered — every menu and every UI in any game on this engine, which is the real cause behind "synthetic input doesn't reach menus". Injected keys are now staged in pending_key_down/up and applied at the TOP of begin_frame, before the edge loop. Real input (set_key_down/up from the message pump) is untouched. An injection on frame N now reads exactly like a real press on frame N+1. Verified: a shooter harness drives MAIN -> SETTINGS -> ESC-back -> PLAY entirely through injectKeyDown and asserts the menu state at each step. Claude-Session: https://claude.ai/code/session_01J1UWgMcrTNvwWeXcJ1T3Fp --- native/shared/src/ffi_core/game_loop.rs | 4 +-- native/shared/src/input.rs | 42 +++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/native/shared/src/ffi_core/game_loop.rs b/native/shared/src/ffi_core/game_loop.rs index 695573e..e6f433a 100644 --- a/native/shared/src/ffi_core/game_loop.rs +++ b/native/shared/src/ffi_core/game_loop.rs @@ -348,7 +348,7 @@ macro_rules! __bloom_ffi_game_loop { #[no_mangle] pub extern "C" fn bloom_inject_key_down(key: f64) { $crate::ffi::guard("bloom_inject_key_down", move || { - engine().input.set_key_down(key as usize); + engine().input.inject_key_down(key as usize); }) } @@ -356,7 +356,7 @@ macro_rules! __bloom_ffi_game_loop { #[no_mangle] pub extern "C" fn bloom_inject_key_up(key: f64) { $crate::ffi::guard("bloom_inject_key_up", move || { - engine().input.set_key_up(key as usize); + engine().input.inject_key_up(key as usize); }) } diff --git a/native/shared/src/input.rs b/native/shared/src/input.rs index 527cdbe..fb091de 100644 --- a/native/shared/src/input.rs +++ b/native/shared/src/input.rs @@ -18,6 +18,21 @@ pub struct InputState { keys_released: [bool; MAX_KEYS], prev_keys_down: [bool; MAX_KEYS], + // Injected keys (bloom_inject_key_down/up) are staged here and applied at the + // top of begin_frame, rather than written straight into keys_down. + // + // They HAVE to be, and the reason is the frame order: a real key arrives in + // poll_events(), which runs BEFORE begin_frame(), so the edge + // (keys_down && !prev_keys_down) is computed for it. An injected key is set + // from the game loop — AFTER begin_frame — so end_frame() copied it into + // prev_keys_down before begin_frame ever got to look at it, and the edge was + // never computed at all. is_key_down() saw the key; is_key_pressed() never + // did. That silently made the injection API useless for anything + // edge-triggered, which is to say every menu and every UI in every game + // built on this engine. + pending_key_down: [bool; MAX_KEYS], + pending_key_up: [bool; MAX_KEYS], + pub mouse_x: f64, pub mouse_y: f64, pub mouse_delta_x: f64, @@ -83,6 +98,8 @@ impl InputState { keys_down: [false; MAX_KEYS], keys_released: [false; MAX_KEYS], prev_keys_down: [false; MAX_KEYS], + pending_key_down: [false; MAX_KEYS], + pending_key_up: [false; MAX_KEYS], mouse_x: 0.0, mouse_y: 0.0, mouse_delta_x: 0.0, @@ -117,6 +134,20 @@ impl InputState { } pub fn begin_frame(&mut self) { + // Apply injected keys BEFORE the edge computation below, so an injection + // made during the previous frame reads exactly like a real key press: + // down this frame, not-down last frame => keys_pressed. See the note on + // pending_key_down. + for i in 0..MAX_KEYS { + if self.pending_key_down[i] { + self.keys_down[i] = true; + self.pending_key_down[i] = false; + } + if self.pending_key_up[i] { + self.keys_down[i] = false; + self.pending_key_up[i] = false; + } + } if self.cursor_disabled { self.mouse_delta_x = self.raw_delta_x; self.mouse_delta_y = self.raw_delta_y; @@ -160,9 +191,20 @@ impl InputState { } // Keyboard + /// Real key events, delivered from the platform's message pump. These run + /// before begin_frame(), so writing keys_down directly is correct here. pub fn set_key_down(&mut self, key: usize) { if key < MAX_KEYS { self.keys_down[key] = true; } } pub fn set_key_up(&mut self, key: usize) { if key < MAX_KEYS { self.keys_down[key] = false; } } + /// Synthetic key events (bloom_inject_key_down/up), which callers raise from + /// inside the game loop. Staged, not written — see pending_key_down. + pub fn inject_key_down(&mut self, key: usize) { + if key < MAX_KEYS { self.pending_key_down[key] = true; self.pending_key_up[key] = false; } + } + pub fn inject_key_up(&mut self, key: usize) { + if key < MAX_KEYS { self.pending_key_up[key] = true; self.pending_key_down[key] = false; } + } + pub fn is_key_pressed(&self, key: usize) -> bool { key < MAX_KEYS && self.keys_pressed[key] } pub fn is_key_down(&self, key: usize) -> bool { key < MAX_KEYS && self.keys_down[key] } pub fn is_key_released(&self, key: usize) -> bool { key < MAX_KEYS && self.keys_released[key] } From 3eb04145b7249fc6bca9e38b8cd7f43973a45b1c Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Wed, 15 Jul 2026 17:29:33 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(windows):=20Shift/Ctrl/Alt=20never=20re?= =?UTF-8?q?gistered=20=E2=80=94=20resolve=20the=20generic=20modifier=20VK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit map_keycode only knew the SIDE-SPECIFIC modifier codes (VK_LSHIFT 0xA0, VK_LCONTROL 0xA2, ...). But Windows delivers the GENERIC VK in WM_KEYDOWN/UP — VK_SHIFT (0x10) for either shift, VK_CONTROL (0x11) for either ctrl, VK_MENU (0x12) for alt. The side-specific codes come back only from GetKeyState, never in the message wParam. So map_keycode(0x10) hit `_ => 0` and the press was dropped: every Shift and Ctrl vanished before it reached isKeyDown(). Consequence in the shooter: sprint (Shift) and dodge (Ctrl) silently never fired on Windows. Only letters/arrows/F-keys ever worked — which is why WASD, Space, R and Q were fine and nobody spotted it. resolve_modifier_vk() recovers the side from lParam — scancode for shift (LShift 0x2A / RShift 0x36), the extended-key bit for ctrl/alt (the right-hand ones are extended) — and runs before map_keycode in BOTH wndprocs (standalone + embedded subclass). Verified with a real OS keypress, not injection: SendInput with KEYEVENTF_SCANCODE 0x2A produces a genuine WM_KEYDOWN with wParam=0x10, and a probe latch then reads isKeyDown(LEFT_SHIFT)=1 (it read 0 before this change). In-game: holding Shift now takes the player 4.50 -> 9.00 m/s. Claude-Session: https://claude.ai/code/session_01J1UWgMcrTNvwWeXcJ1T3Fp --- native/windows/src/lib.rs | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/native/windows/src/lib.rs b/native/windows/src/lib.rs index ccf1b24..72a89d6 100644 --- a/native/windows/src/lib.rs +++ b/native/windows/src/lib.rs @@ -61,6 +61,26 @@ fn map_keycode(vk: u32) -> usize { } } +// Windows reports the GENERIC modifier VKs — VK_SHIFT (0x10), VK_CONTROL (0x11), +// VK_MENU (0x12) — in WM_KEYDOWN/UP for BOTH the left and right keys. The +// side-specific codes (VK_LSHIFT 0xA0, etc.) come back only from GetKeyState, +// never in the message wParam. map_keycode knows only the side-specific codes, +// so a raw generic VK mapped to 0 and was dropped: every Shift and Ctrl press +// vanished, which is why sprint (Shift) and dodge (Ctrl) silently never fired on +// Windows. Recover the side from lParam — scancode for Shift (LShift = 0x2A, +// RShift = 0x36), the extended-key bit for Ctrl/Alt (the right-hand ones are +// flagged extended). +fn resolve_modifier_vk(vk: u32, lparam: isize) -> u32 { + let scancode = ((lparam >> 16) & 0xFF) as u32; + let extended = (lparam >> 24) & 1 != 0; + match vk { + 0x10 => if scancode == 0x36 { 0xA1 } else { 0xA0 }, // VK_SHIFT -> R/L + 0x11 => if extended { 0xA3 } else { 0xA2 }, // VK_CONTROL -> R/L + 0x12 => if extended { 0xA5 } else { 0xA4 }, // VK_MENU -> R/L + _ => vk, + } +} + // --------------------------------------------------------------------------- // Crash reporting (title-freeze / EN-020 investigation): the game was dying // with empty stderr, no WER event, and no dump — i.e. invisibly. Catch @@ -246,7 +266,7 @@ mod win32 { LRESULT(0) } WM_KEYDOWN => { - let bloom_key = map_keycode(wparam.0 as u32); + let bloom_key = map_keycode(resolve_modifier_vk(wparam.0 as u32, lparam.0)); if bloom_key > 0 { if let Some(eng) = ENGINE.get_mut() { eng.input.set_key_down(bloom_key); @@ -275,7 +295,7 @@ mod win32 { DefWindowProcW(hwnd, msg, wparam, lparam) } WM_KEYUP => { - let bloom_key = map_keycode(wparam.0 as u32); + let bloom_key = map_keycode(resolve_modifier_vk(wparam.0 as u32, lparam.0)); if bloom_key > 0 { if let Some(eng) = ENGINE.get_mut() { eng.input.set_key_up(bloom_key); @@ -494,11 +514,11 @@ mod win32 { } } WM_KEYDOWN => { - let k = map_keycode(wparam.0 as u32); + let k = map_keycode(resolve_modifier_vk(wparam.0 as u32, lparam.0)); if k > 0 { if let Some(eng) = ENGINE.get_mut() { eng.input.set_key_down(k); } } } WM_KEYUP => { - let k = map_keycode(wparam.0 as u32); + let k = map_keycode(resolve_modifier_vk(wparam.0 as u32, lparam.0)); if k > 0 { if let Some(eng) = ENGINE.get_mut() { eng.input.set_key_up(k); } } } WM_MOUSEMOVE => {