From 0499651c380f097c0e55dc751e470c1ff45b8d24 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 04:42:56 +0000 Subject: [PATCH 1/2] fix(input): accept Windows keys sent without a scan code Co-Authored-By: Joshua Tubbs --- CHANGELOG.md | 2 + crates/freight-fate/src/app.rs | 2 + crates/freight-fate/src/app/sdl_shell.rs | 24 +-- crates/freight-fate/src/app/win_keys.rs | 203 +++++++++++++++++++++++ 4 files changed, 221 insertions(+), 10 deletions(-) create mode 100644 crates/freight-fate/src/app/win_keys.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f49856b..596ac187 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Fixed +- **Menus accept Enter, Space, and letters from braille notetakers and automation tools.** + - **Sitting through a training course now tires you, about half as fast as driving.** A multi-day course counts only its last daytime class day. - **A course that leaves you drowsy now tells you to sleep before driving.** Night classes are judged by the city's local clock. diff --git a/crates/freight-fate/src/app.rs b/crates/freight-fate/src/app.rs index 759b0142..e12ffb91 100644 --- a/crates/freight-fate/src/app.rs +++ b/crates/freight-fate/src/app.rs @@ -44,6 +44,8 @@ pub mod sdl_shell; pub mod speech_delivery; pub mod synth_music; pub mod testing; +#[cfg(any(windows, test))] +mod win_keys; pub use context::{ share, Clipboard, ContextParts, GameContext, MemoryClipboard, Services, SharedState, diff --git a/crates/freight-fate/src/app/sdl_shell.rs b/crates/freight-fate/src/app/sdl_shell.rs index 539251b4..2c86e106 100644 --- a/crates/freight-fate/src/app/sdl_shell.rs +++ b/crates/freight-fate/src/app/sdl_shell.rs @@ -139,6 +139,20 @@ impl SdlShell { .position_centered() .build() .map_err(|e| e.to_string())?; + // The dummy driver (every headless run: CI, the agent server under + // FREIGHT_FATE_NO_SPEECH, the playtest benches) has no native window, + // and the sdl2 crate PANICS rather than erring when asked for one -- + // a windowed headless boot died here the day the handle arrived. + #[cfg(target_os = "windows")] + let window_handle = if video.current_video_driver() == "dummy" { + None + } else { + window_handle(&window) + }; + #[cfg(target_os = "windows")] + if let Some(handle) = window_handle { + super::win_keys::install(handle); + } // A dummy window has no GPU surface. Automatic renderer selection can // still enter native graphics drivers before falling back to software; // AMD's driver fail-fasts there in restricted Windows environments. @@ -149,16 +163,6 @@ impl SdlShell { canvas }; let canvas = canvas.build().map_err(|e| e.to_string())?; - // The dummy driver (every headless run: CI, the agent server under - // FREIGHT_FATE_NO_SPEECH, the playtest benches) has no native window, - // and the sdl2 crate PANICS rather than erring when asked for one -- - // a windowed headless boot died here the day the handle arrived. - #[cfg(target_os = "windows")] - let window_handle = if video.current_video_driver() == "dummy" { - None - } else { - window_handle(canvas.window()) - }; #[cfg(target_os = "windows")] if let Some(handle) = window_handle { log::info!( diff --git a/crates/freight-fate/src/app/win_keys.rs b/crates/freight-fate/src/app/win_keys.rs new file mode 100644 index 00000000..f3b0e1c3 --- /dev/null +++ b/crates/freight-fate/src/app/win_keys.rs @@ -0,0 +1,203 @@ +//! SDL drops Windows keys with zero scan codes except arrows; braille notetakers and automation tools can send keys that way. + +#[cfg(windows)] +use std::cell::Cell; +#[cfg(windows)] +use std::sync::OnceLock; + +#[cfg(windows)] +use windows_sys::Win32::Foundation::{HWND, LPARAM, LRESULT, WPARAM}; +#[cfg(windows)] +use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ + MapVirtualKeyW, VkKeyScanW, MAPVK_VK_TO_VSC_EX, VK_PACKET, +}; +#[cfg(windows)] +use windows_sys::Win32::UI::WindowsAndMessaging::{ + CallWindowProcW, DefWindowProcW, SetWindowLongPtrW, GWLP_WNDPROC, WM_CHAR, WM_KEYDOWN, + WM_KEYUP, WM_SYSKEYDOWN, WM_SYSKEYUP, WNDPROC, +}; + +const SCAN_CODE_MASK: u64 = 0xff << 16; +const EXTENDED_KEY_MASK: u64 = 1 << 24; + +fn fill_scan_code(lparam: isize, mapped: u32) -> isize { + let value = lparam as u64; + if value & SCAN_CODE_MASK != 0 || mapped == 0 { + return lparam; + } + + let mut patched = value | (u64::from(mapped & 0xff) << 16); + if is_extended_scan(mapped) { + patched |= EXTENDED_KEY_MASK; + } + patched as isize +} + +fn key_lparam(scan: u32, extended: bool, up: bool) -> isize { + let mut value = 1 | (u64::from(scan & 0xff) << 16); + if extended { + value |= EXTENDED_KEY_MASK; + } + if up { + value |= 0xc000_0000; + } + value as isize +} + +fn is_extended_scan(mapped: u32) -> bool { + matches!(mapped >> 8, 0xe0 | 0xe1) +} + +#[cfg(windows)] +static PREVIOUS_WNDPROC: OnceLock = OnceLock::new(); + +#[cfg(windows)] +thread_local! { + static PACKET_CHAR_PENDING: Cell = const { Cell::new(false) }; + static PACKET_KEY_TO_RELEASE: Cell> = const { Cell::new(None) }; +} + +#[cfg(windows)] +pub(super) fn install(hwnd: isize) { + PREVIOUS_WNDPROC.get_or_init(|| { + // SAFETY: hwnd came from the live SDL Win32 window; the replacement + // procedure forwards every message to the procedure returned here. + let previous = unsafe { + SetWindowLongPtrW( + hwnd as HWND, + GWLP_WNDPROC, + window_proc as *const () as usize as isize, + ) + }; + if previous == 0 { + log::warn!("keyboard: could not subclass the SDL window"); + return None; + } + // SAFETY: SetWindowLongPtrW returned the previous non-null WNDPROC. + unsafe { std::mem::transmute::(previous) } + }); +} + +#[cfg(windows)] +fn forward(hwnd: HWND, message: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { + match PREVIOUS_WNDPROC.get().copied().flatten() { + // SAFETY: the original SDL procedure is retained for this live window. + Some(previous) => unsafe { CallWindowProcW(Some(previous), hwnd, message, wparam, lparam) }, + // SAFETY: this is only reached if subclass installation failed. + None => unsafe { DefWindowProcW(hwnd, message, wparam, lparam) }, + } +} + +#[cfg(windows)] +unsafe extern "system" fn window_proc( + hwnd: HWND, + message: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + let packet_key = wparam == usize::from(VK_PACKET); + match message { + WM_KEYDOWN | WM_SYSKEYDOWN if packet_key => { + PACKET_CHAR_PENDING.with(|pending| pending.set(true)); + PACKET_KEY_TO_RELEASE.with(|key| key.set(None)); + } + WM_KEYUP | WM_SYSKEYUP if packet_key => { + PACKET_CHAR_PENDING.with(|pending| pending.set(false)); + if let Some((vk, mapped)) = PACKET_KEY_TO_RELEASE.with(Cell::take) { + let key_up = key_lparam(mapped, is_extended_scan(mapped), true); + let _ = forward(hwnd, WM_KEYUP, vk as WPARAM, key_up); + } + return forward(hwnd, message, wparam, lparam); + } + WM_CHAR => { + if PACKET_CHAR_PENDING.with(|pending| pending.replace(false)) { + synthesize_packet_key(hwnd, wparam as u16); + } + } + WM_KEYDOWN | WM_KEYUP | WM_SYSKEYDOWN | WM_SYSKEYUP + if lparam as u64 & SCAN_CODE_MASK == 0 => + { + // SAFETY: MapVirtualKeyW accepts any virtual-key code and does + // not retain or dereference the supplied value. + let mapped = unsafe { MapVirtualKeyW(wparam as u32, MAPVK_VK_TO_VSC_EX) }; + return forward(hwnd, message, wparam, fill_scan_code(lparam, mapped)); + } + _ => {} + } + forward(hwnd, message, wparam, lparam) +} + +#[cfg(windows)] +fn synthesize_packet_key(hwnd: HWND, character: u16) { + // SAFETY: VkKeyScanW maps a UTF-16 code unit using the current keyboard + // layout and does not retain the character. + let translated = unsafe { VkKeyScanW(character) } as u16; + let vk = u32::from(translated & 0xff); + if vk == 0xff { + return; + } + + // SAFETY: MapVirtualKeyW accepts the virtual-key value returned above. + let mapped = unsafe { MapVirtualKeyW(vk, MAPVK_VK_TO_VSC_EX) }; + if mapped == 0 { + return; + } + + let key_down = key_lparam(mapped, is_extended_scan(mapped), false); + let _ = forward(hwnd, WM_KEYDOWN, vk as WPARAM, key_down); + PACKET_KEY_TO_RELEASE.with(|key| key.set(Some((vk, mapped)))); +} + +#[cfg(test)] +mod tests { + use super::{fill_scan_code, key_lparam}; + + #[test] + fn fills_return_scan_code_without_extended_flag() { + let patched = fill_scan_code(0, 0x1c) as u64; + assert_eq!((patched >> 16) & 0xff, 0x1c); + assert_eq!((patched >> 24) & 1, 0); + } + + #[test] + fn fills_up_arrow_scan_code_and_extended_flag() { + let patched = fill_scan_code(0, 0xe048) as u64; + assert_eq!((patched >> 16) & 0xff, 0x48); + assert_eq!((patched >> 24) & 1, 1); + } + + #[test] + fn leaves_an_existing_scan_code_unchanged() { + let lparam = 0x0039_0007; + assert_eq!(fill_scan_code(lparam, 0xe048), lparam); + } + + #[test] + fn leaves_an_unmapped_key_unchanged() { + let lparam = 0x1234_0007; + assert_eq!(fill_scan_code(lparam, 0), lparam); + } + + #[test] + fn preserves_sender_extended_flag() { + let lparam = 1 << 24; + assert_eq!( + fill_scan_code(lparam, 0x1c) as u64, + (1 << 24) | (0x1c << 16) + ); + } + + #[test] + fn preserves_repeat_count_and_key_up_context_bits() { + let lparam = 0xe000_0007u32 as isize; + let patched = fill_scan_code(lparam, 0x1c) as u64; + assert_eq!(patched & 0xffff, 7); + assert_eq!(patched & 0xe000_0000, 0xe000_0000); + } + + #[test] + fn builds_key_down_and_key_up_lparams() { + assert_eq!(key_lparam(0x1c, false, false), 0x001c_0001); + assert_eq!(key_lparam(0xe048, true, true) as u64, 0xc148_0001); + } +} From 56c0fe9c554794a2599fe22db635dd1481701a93 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 04:44:12 +0000 Subject: [PATCH 2/2] docs(changelog): say what the notetaker fix changes Co-Authored-By: Joshua Tubbs --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 596ac187..a29fa339 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- **Menus accept Enter, Space, and letters from braille notetakers and automation tools.** +- **Menus accept Enter, Space, and letters from braille notetakers and automation tools.** Like the BrailleNote Evolve, they could arrow but not select. - **Sitting through a training course now tires you, about half as fast as driving.** A multi-day course counts only its last daytime class day.