diff --git a/architecture.md b/architecture.md index f81bdc273..e22887066 100644 --- a/architecture.md +++ b/architecture.md @@ -129,6 +129,7 @@ The `src/bin/roms.rs` file is a library binary (accessed via `cargo run --bin ro | `src/platform/headless_capture.rs` | Headless frame-capture runner behind `--headless`. Loads a ROM, advances a fixed number of frames with no window or audio, and writes the final frame as a PNG; with `--capture-every K` it also writes `_.png` at every frame N that is a multiple of K, so a series of checkpoints costs one run. `run_if_requested()` is the dispatch entry point called from `main.rs`; the frame loop is bounded by `MAX_TICKS_PER_FRAME` so a ROM that never renders fails instead of hanging an unattended script. | | `src/platform/ram_init.rs` | `initialize_ram` helper applying a `RamInitMode` (`Zero`, `Random`, `SeededRandom`) to a byte buffer. Shared by the NES and SNES cores; re-exported as `nes::console::initialize_ram` for the historical call sites. | | `src/platform/rom_extensions.rs` | The one table of which ROM file extension means which console (`ROM_EXTENSIONS`, over the catalog's `Platform`, which lives here and converts to `SystemType`). `rom_loader`, the ROM catalog and its disk scan read it, and the web page receives it through the wasm binding `rom_extension_table()` at start-up (nr-di6), so the copies cannot drift. | +| `src/platform/key_bindings.rs` | The one table of keyboard bindings (`KEY_BINDINGS`): which key drives which console input (joypad, Power Pad, SNES pad on the NES, Vs. coin/service, Super Scope) on which console, rows for one key tried in order. The desktop keyboard (`frontends/native/keyboard/controller_mapping.rs`) looks every key up here, and the web page reads the same rows through the wasm binding `key_binding_table()` (nr-tlf). A binding only one shell has is declared `DesktopOnly`/`WebOnly` in its row, and a test pins the full list of those. | | `src/platform/rom_loader.rs` | Shared "ROM path to ready-to-run `Console`" loading — `detect_system_type()` (by file extension) and `load_console()`. Used by both the native frontend and headless capture so the two cannot drift. The console comes back powered on and ready to run, as the web frontend's `load_rom` leaves it; no frontend resets a console it has just loaded (nr-sc7). | | `src/platform/png_utils.rs` | `write_rgb_png()` — writes an RGB888 buffer as an 8-bit PNG, creating missing parent directories. Fallible: every failure, including a buffer that does not match the given dimensions, is returned as an `io::Error` rather than panicking. | | `src/platform/test_roms.rs` | Minimal synthetic ROMs (`minimal_nes_rom`, `minimal_gb_rom`, `minimal_gba_rom`, `minimal_snes_rom`) for platform-level tests that only need a console to construct. Note they render an identical screen every frame, so tests that must distinguish frame counts use a real ROM from `roms/` instead. | @@ -455,7 +456,7 @@ The SNES (Super Nintendo Entertainment System) module now includes active 65816 | `src/frontends/native/` | Desktop frontend using winit + OpenGL. | | `src/frontends/native/event_loop.rs` | Main event loop — holds `Console` enum, handles input events, frame timing, VSync, autorun integration, pause/resume, and hot-reload of ROMs. NES/Game Boy-specific features (debugger, PPU viewer, Zapper, SNES mouse) use `Console` accessors instead of variant branching. | | `src/frontends/native/audio.rs` | Native audio device setup and sample queuing. | -| `src/frontends/native/keyboard/` | Keyboard input handling, split into focused modules: `mod.rs` (entry points `handle_key_pressed`/`handle_key_released`/`keyboard_target_ports` + `KeyOutcome`), `hotkeys.rs` (system/debugger/cartridge-switch hotkeys), `console_keyboard.rs` (per-console press dispatch), and `controller_mapping.rs` (key→button mapping tables). | +| `src/frontends/native/keyboard/` | Keyboard input handling, split into focused modules: `mod.rs` (entry points `handle_key_pressed`/`handle_key_released`/`keyboard_target_ports` + `KeyOutcome`), `hotkeys.rs` (system/debugger/cartridge-switch hotkeys), `console_keyboard.rs` (per-console press dispatch), and `controller_mapping.rs` (winit key → `platform::key_bindings` lookup and dispatch; it holds no bindings of its own). | | `src/frontends/native/gamepad.rs` | Gamepad input using gilrs — maps controller axes/buttons to NES joypads. | | `src/frontends/native/mouse.rs` | Mouse input — Zapper light gun, SNES mouse, and Arkanoid paddle coordinate mapping. | | `src/frontends/native/gl_wrapper.rs` | OpenGL context management for native windows. | @@ -553,7 +554,7 @@ The web frontend is bundled with **Vite** (config at `vite.config.ts`, root: `we | `web/src/app.ts` | Application bootstrapper — initializes the WASM module, selects `WasmNes` / `WasmGb` / `WasmGba` by ROM extension, sets up the render loop, and coordinates all subsystems. | | `web/src/console/` | The one list of consoles the web frontend runs (`consoles.ts`): the `ConsoleKind` type and a per-console table (frame pixel format, stereo and sample scaling, filter family and default filter, help-overlay key bindings, save-state support, fresh instance per Start, Palette and Colors buttons) that every web module reads, so adding a console to the web is one row plus its wasm binding. Screen size, frame rate and audio sample rate are reported by the binding at runtime and are not copied there. `console_set_declared_once.test.ts` fails if a module redeclares the console set. | | `web/src/audio/` | Audio resampling (`audio_resampler.ts`), frame timing (`frame_limiter.ts`, `frame_plan.ts`). | -| `web/src/input/` | Gamepad API (`gamepad.ts`), GBA keyboard mapping (`keyboard_mapping.ts`), keyboard/gamepad routing (`input_routing.ts`), mouse input (`mouse_input.ts`), pointer lock (`pointer_lock.ts`). | +| `web/src/input/` | Gamepad API (`gamepad.ts`), keyboard bindings applied from the wasm `key_binding_table()` (`key_bindings.ts`), keyboard/gamepad routing (`input_routing.ts`), mouse input (`mouse_input.ts`), pointer lock (`pointer_lock.ts`). | | `web/src/display/` | Canvas sizing (`canvas_size.ts`), zoom controls (`zoom_controls.ts`), cursor visibility, crosshair overlay, and console-specific filter selection (`filters.ts`; SNES shares the NES looks, GBA has its own set, and the page's first game starts on its console's default look), which look the Filter button names while a look's images are still being fetched (`filter_selection.ts`), the GBA looks' WebGL 1 passes (`gba_pipeline.ts`: AGB-001, Switch Online, GBA SP and LCD Grid, ported from the desktop slang presets with their GLSL in `web/src/shaders/gba-*.glsl` and images in `web/src/assets/gba-*.png`) and the LCD Grid's console-art scale (`gba_lcd_grid.ts`), the "Colors" button's visibility and the page's colour-correction choice (`cgb_color_correction.ts`; its label is the core's `color_label`), and when the "Palette" button (F8 as a button) shows (`palette_button.ts`). | | `web/src/rom/` | ROM file listing (`rom_list.ts`: reads `roms/roms.json` and crawls the server's directory listings only when the manifest is missing or empty), the manifest generator (`rom_manifest.ts`, Node-only, run by the `rom-manifest` Vite plugin at build and dev-server start; it follows the `web/roms/*` symlinks, each top-level entry being its own containment root, and lists every served file since the build cannot load the wasm extension table, leaving `rom_list.ts` to filter; a ROM added after the build appears in the picker only after the next `vite build` or dev-server start), extension-to-console detection (`rom_extensions.ts`, holding the table start-up installs from the wasm binding's `rom_extension_table()` and setting the `#rom` picker's `accept` from it), selection UI (`rom_selection.ts`), autorun context, and the side panel's "Game Boy games run on" choice (`gb_hardware_choice.ts`: kept in `localStorage` under `neser.gbHardware`, handed to `WasmGb::set_original_games_on_color`, applied when an original Game Boy game starts or is Reset). | | `web/src/firmware/` | SNES coprocessor firmware in the browser version (nr-auv, nr-608, nr-72o, nr-tfq): `snes_firmware_store.ts` keeps one file per chip (keyed `dsp1`, `dsp2`, `dsp3`, `dsp4`) in its own IndexedDB database (`neser-firmware`), `snes_firmware_dialog.ts` asks for a chip's file once (a `` in `index.html`, checking the size and then genuineness through the wasm `snes_dsp_firmware_is_genuine`) and renders the sidebar's "SNES firmware" block, one row per stored chip with its own Replace… and Forget, and `snes_firmware_words.ts` holds `SNES_FIRMWARE_CHIPS` and every agreed string. `app.ts` asks `snes_rom_dsp_chip` before a SNES load and hands the file over with `WasmSnes::set_dsp_firmware`. | diff --git a/docs/retrospectives/nr-tlf.md b/docs/retrospectives/nr-tlf.md new file mode 100644 index 000000000..4bf8daaff --- /dev/null +++ b/docs/retrospectives/nr-tlf.md @@ -0,0 +1,24 @@ +# nr-tlf: retrospective + +## A `#[cfg(test)]` accessor in core code turned two clippy legs red + +**What happened.** To let the native keyboard tests read a Power Pad's and an SNES pad's state, +I added two test-only accessors, `Bus::controller_state` and `InputPorts::port1_state`, under +plain `#[cfg(test)]`. The host test run and host clippy passed. The full gate then failed at +`cargo clippy --target wasm32-unknown-unknown … --features wasm --all-targets` +(`method controller_state is never used`). The `--features frontend` leg fails the same way. + +**Why.** `frontends::native` is compiled only with `feature = "native"`. In the wasm and the +frontend-only builds the accessors are compiled into the test target, but their only callers +are not, so `-D warnings` rejects them as dead code. + +**Cost.** One full gate run (about 15 minutes) and one extra commit. The delta review also +flagged it. + +**Prevent by.** A test helper added in a core module (`src/nes`, `src/snes`, `src/platform`) +for tests that live under `src/frontends/native` is gated `#[cfg(all(test, feature = "native"))]`, +the condition its callers compile under. Run the two non-host clippy legs from CLAUDE.md +before the full gate whenever a `#[cfg(test)]` item is added outside the module whose tests +use it. + +**Seen before.** None found. diff --git a/src/frontends/native/keyboard/console_keyboard.rs b/src/frontends/native/keyboard/console_keyboard.rs index 717c09561..d967d2fcc 100644 --- a/src/frontends/native/keyboard/console_keyboard.rs +++ b/src/frontends/native/keyboard/console_keyboard.rs @@ -8,7 +8,8 @@ use super::{KeyOutcome, controller_mapping, hotkeys, keyboard_target_ports}; use crate::frontends::native::app_state::NativeAppState; use crate::platform::audio::EmulatorAudio; -use crate::platform::emulator::Console; +use crate::platform::emulator::{Console, SystemType}; +use crate::platform::key_bindings::Input; use winit::keyboard::KeyCode; /// Handles a key-press event for a [`Console::GameBoy`]. @@ -22,13 +23,7 @@ pub(super) fn handle_gameboy_key_pressed( app_state: &mut NativeAppState, audio: Option<&dyn EmulatorAudio>, ) -> KeyOutcome { - handle_single_joypad_key_pressed( - console, - key_code, - app_state, - audio, - controller_mapping::gameboy_key_to_button_id, - ) + handle_single_joypad_key_pressed(console, key_code, app_state, audio) } pub(super) fn handle_gba_key_pressed( @@ -37,13 +32,7 @@ pub(super) fn handle_gba_key_pressed( app_state: &mut NativeAppState, audio: Option<&dyn EmulatorAudio>, ) -> KeyOutcome { - handle_single_joypad_key_pressed( - console, - key_code, - app_state, - audio, - controller_mapping::gba_key_to_button_id, - ) + handle_single_joypad_key_pressed(console, key_code, app_state, audio) } pub(super) fn handle_snes_key_pressed( @@ -55,13 +44,7 @@ pub(super) fn handle_snes_key_pressed( if !app_state.modifiers.control_key() && handle_super_scope_key(console, key_code, true) { return KeyOutcome::Continue; } - handle_single_joypad_key_pressed( - console, - key_code, - app_state, - audio, - controller_mapping::snes_key_to_button_id, - ) + handle_single_joypad_key_pressed(console, key_code, app_state, audio) } /// With a Super Scope connected, the Select key (4) flips its Turbo switch and the Start @@ -81,8 +64,11 @@ pub(super) fn handle_super_scope_key( let Some(port) = (0..=1u8).find(|&port| ports.has_superscope_on_port(port)) else { return false; }; - match key_code { - KeyCode::Digit4 => { + let action = controller_mapping::desktop_rows(SystemType::Snes, key_code) + .map(|b| b.input) + .find(|input| matches!(input, Input::SuperScopeTurbo | Input::SuperScopePause)); + match action { + Some(Input::SuperScopeTurbo) => { if pressed && let Some(on) = ports.toggle_superscope_turbo(port) { console .app_context() @@ -91,11 +77,11 @@ pub(super) fn handle_super_scope_key( } true } - KeyCode::Digit5 => { + Some(_) => { ports.set_superscope_pause(port, pressed); true } - _ => false, + None => false, } } @@ -104,7 +90,6 @@ fn handle_single_joypad_key_pressed( key_code: KeyCode, app_state: &mut NativeAppState, audio: Option<&dyn EmulatorAudio>, - key_to_button_id: fn(KeyCode) -> Option, ) -> KeyOutcome { // Generic hotkeys that work for any system. if app_state.modifiers.control_key() { @@ -144,7 +129,8 @@ fn handle_single_joypad_key_pressed( KeyCode::F10 => return KeyOutcome::StepOver, KeyCode::F11 => return KeyOutcome::StepInto, _ => { - if let Some(btn_id) = key_to_button_id(key_code) { + if let Some(btn_id) = controller_mapping::pad_button_id(console.system_type(), key_code) + { console.set_button(0, btn_id, true); } } @@ -474,6 +460,81 @@ mod tests { ); } + /// Every desktop SNES pad row of the shared table (nr-tlf) presses the button its id + /// names on port 1, exactly as setting that id directly does. + #[test] + fn every_desktop_snes_pad_row_presses_its_button() { + use crate::platform::emulator::SystemType; + use crate::platform::key_bindings::{Input, Shell, bindings_of}; + let port1 = |console: &Console| { + console + .as_snes() + .unwrap() + .input_ports() + .unwrap() + .port1_state() + }; + let mut checked = 0; + for b in bindings_of(Shell::Desktop).filter(|b| b.console == SystemType::Snes) { + let Input::Pad(_, button) = b.input else { + continue; + }; + let key = crate::frontends::native::keyboard::controller_mapping::winit_key(b.key); + let mut by_key = make_snes_console("pad.sfc"); + handle_key_pressed(&mut by_key, key, &mut make_state(), None); + let mut by_id = make_snes_console("pad.sfc"); + by_id.set_button(0, button.id(), true); + assert_ne!(port1(&by_id).pressed, 0, "{b:?}"); + assert_eq!(port1(&by_key), port1(&by_id), "{b:?}"); + checked += 1; + } + assert_eq!(checked, 16); + } + + /// Every desktop Game Boy and GBA row of the shared table (nr-tlf) presses its button. + #[test] + fn every_desktop_gb_and_gba_pad_row_presses_its_button() { + use crate::platform::emulator::SystemType; + use crate::platform::key_bindings::{Input, PadButton, Shell, bindings_of}; + for b in bindings_of(Shell::Desktop) { + let Input::Pad(_, button) = b.input else { + continue; + }; + let key = crate::frontends::native::keyboard::controller_mapping::winit_key(b.key); + let mut state = make_state(); + match b.console { + SystemType::GameBoy => { + let mut console = make_gameboy_console(); + handle_key_pressed(&mut console, key, &mut state, None); + assert_ne!( + console.get_joypad_button_states(0) & (1 << button.id()), + 0, + "{b:?}" + ); + } + SystemType::Gba => { + let mask = match button { + PadButton::A => GBA_KEY_A, + PadButton::B => GBA_KEY_B, + PadButton::Select => GBA_KEY_SELECT, + PadButton::Start => GBA_KEY_START, + PadButton::Right => GBA_KEY_RIGHT, + PadButton::Left => GBA_KEY_LEFT, + PadButton::Up => GBA_KEY_UP, + PadButton::Down => GBA_KEY_DOWN, + PadButton::R => GBA_KEY_R, + PadButton::L => GBA_KEY_L, + PadButton::X | PadButton::Y => panic!("{b:?}: the GBA has no X/Y"), + }; + let mut console = make_gba_console(); + handle_key_pressed(&mut console, key, &mut state, None); + assert_eq!(gba_keyinput(&console) & mask, 0, "{b:?}"); + } + _ => {} + } + } + } + #[test] fn gba_keyboard_maps_all_ten_buttons() { let cases = [ diff --git a/src/frontends/native/keyboard/controller_mapping.rs b/src/frontends/native/keyboard/controller_mapping.rs index 4c07b6d4b..33d6e6678 100644 --- a/src/frontends/native/keyboard/controller_mapping.rs +++ b/src/frontends/native/keyboard/controller_mapping.rs @@ -1,278 +1,312 @@ //! Keyboard-to-controller button mapping for the native frontend. //! -//! Maps winit `KeyCode`s to NES/SNES/Power Pad/GB/GBA button presses and -//! releases. `handle_controller_key` routes NES/SNES/Power Pad keys to the -//! configured ports; the `*_key_to_button_id` functions map keys for the -//! single-joypad consoles (GB, GBA, SNES). - -use crate::nes::input::{Button, PowerPadButton, SnesButton}; -use crate::platform::emulator::Console; +//! Every binding lives in [`crate::platform::key_bindings`], the table the web shell reads +//! too; this module only turns a winit [`KeyCode`] into that table's [`Key`] and hands the +//! rows to the console. `handle_controller_key` routes NES keys to the configured ports; +//! [`pad_button_id`] gives the button of the single-joypad consoles (GB, GBA, SNES). + +use crate::nes::input::button_from_id as nes_button; +use crate::platform::emulator::{Console, SystemType}; +use crate::platform::key_bindings::{Input, Key, KeyBinding, Player, Shell, bindings_for}; use winit::keyboard::KeyCode; -/// Maps a key code to a Game Boy button ID (0=A,1=B,2=Select,3=Start,4=Up,5=Down,6=Left,7=Right). -/// -/// Uses the same physical-position layout as the NES P1 keys so that -/// players feel at home: WASD for D-pad, T=A, R=B, 4=Select, 5=Start. -/// Arrow keys are also mapped to the D-pad for convenience. -pub(super) fn gameboy_key_to_button_id(key_code: KeyCode) -> Option { - use Button::{A, B, Down, Left, Right, Select, Start, Up}; - match key_code { - KeyCode::KeyT => Some(A as u8), - KeyCode::KeyR => Some(B as u8), - KeyCode::Digit4 => Some(Select as u8), - KeyCode::Digit5 => Some(Start as u8), - KeyCode::KeyW | KeyCode::ArrowUp => Some(Up as u8), - KeyCode::KeyS | KeyCode::ArrowDown => Some(Down as u8), - KeyCode::KeyA | KeyCode::ArrowLeft => Some(Left as u8), - KeyCode::KeyD | KeyCode::ArrowRight => Some(Right as u8), - _ => None, - } +/// The winit name of every key the table can bind: a name mapping, never a binding. +pub(super) const WINIT_KEYS: &[(KeyCode, Key)] = &[ + (KeyCode::Digit0, Key::Digit0), + (KeyCode::Digit1, Key::Digit1), + (KeyCode::Digit2, Key::Digit2), + (KeyCode::Digit3, Key::Digit3), + (KeyCode::Digit4, Key::Digit4), + (KeyCode::Digit5, Key::Digit5), + (KeyCode::Digit6, Key::Digit6), + (KeyCode::Digit7, Key::Digit7), + (KeyCode::Digit8, Key::Digit8), + (KeyCode::Digit9, Key::Digit9), + (KeyCode::KeyA, Key::KeyA), + (KeyCode::KeyB, Key::KeyB), + (KeyCode::KeyC, Key::KeyC), + (KeyCode::KeyD, Key::KeyD), + (KeyCode::KeyE, Key::KeyE), + (KeyCode::KeyF, Key::KeyF), + (KeyCode::KeyG, Key::KeyG), + (KeyCode::KeyI, Key::KeyI), + (KeyCode::KeyJ, Key::KeyJ), + (KeyCode::KeyK, Key::KeyK), + (KeyCode::KeyL, Key::KeyL), + (KeyCode::KeyM, Key::KeyM), + (KeyCode::KeyO, Key::KeyO), + (KeyCode::KeyP, Key::KeyP), + (KeyCode::KeyQ, Key::KeyQ), + (KeyCode::KeyR, Key::KeyR), + (KeyCode::KeyS, Key::KeyS), + (KeyCode::KeyT, Key::KeyT), + (KeyCode::KeyU, Key::KeyU), + (KeyCode::KeyV, Key::KeyV), + (KeyCode::KeyW, Key::KeyW), + (KeyCode::KeyX, Key::KeyX), + (KeyCode::KeyY, Key::KeyY), + (KeyCode::KeyZ, Key::KeyZ), + (KeyCode::Minus, Key::Minus), + (KeyCode::Comma, Key::Comma), + (KeyCode::Period, Key::Period), + (KeyCode::ArrowUp, Key::ArrowUp), + (KeyCode::ArrowDown, Key::ArrowDown), + (KeyCode::ArrowLeft, Key::ArrowLeft), + (KeyCode::ArrowRight, Key::ArrowRight), +]; + +/// The table's name for a winit key, if the table can bind it. +fn key_from_winit(key_code: KeyCode) -> Option { + WINIT_KEYS + .iter() + .find(|&&(code, _)| code == key_code) + .map(|&(_, key)| key) } -/// Maps a key code to a GBA button ID. -/// -/// Extends the Game Boy/NES-style keyboard layout with Q=L and E=R, -/// matching the native NES/SNES shoulder-button positions. -pub(super) fn gba_key_to_button_id(key_code: KeyCode) -> Option { - match key_code { - KeyCode::KeyQ => Some(8), // L - KeyCode::KeyE => Some(9), // R - _ => gameboy_key_to_button_id(key_code), - } +/// The winit key for one of the table's keys. +#[cfg(test)] +pub(super) fn winit_key(key: Key) -> KeyCode { + WINIT_KEYS + .iter() + .find(|&&(_, k)| k == key) + .map(|&(code, _)| code) + .expect("every Key has a winit KeyCode") } -/// Maps a key code to a SNES button ID. -/// -/// Extends the Game Boy/NES-style keyboard layout with the SNES face and -/// shoulder buttons. Button IDs follow the platform convention plus the -/// SNES-only `X`/`Y` (see [`crate::snes::input::button_from_id`]): -/// `0=A, 1=B, 2=Select, 3=Start, 4=Up, 5=Down, 6=Left, 7=Right, 8=L, 9=R, -/// 10=X, 11=Y`. -pub(super) fn snes_key_to_button_id(key_code: KeyCode) -> Option { - match key_code { - KeyCode::KeyQ => Some(8), // L - KeyCode::KeyE => Some(9), // R - KeyCode::KeyY => Some(10), // X - KeyCode::KeyG => Some(11), // Y - _ => gameboy_key_to_button_id(key_code), - } +/// The desktop rows for `key_code` on `console`, in the order they are tried. +pub(super) fn desktop_rows( + console: SystemType, + key_code: KeyCode, +) -> impl Iterator { + key_from_winit(key_code) + .into_iter() + .flat_map(move |key| bindings_for(Shell::Desktop, console, key)) +} + +/// The platform button id `key_code` presses on the single joypad of `console` (GB, GBA, +/// SNES), if any. +pub(super) fn pad_button_id(console: SystemType, key_code: KeyCode) -> Option { + desktop_rows(console, key_code).find_map(|b| match b.input { + Input::Pad(Player::One, button) => Some(button.id()), + _ => None, + }) } // ── Controller key mapping ──────────────────────────────────────────────────── -/// Maps a [`KeyCode`] to NES/SNES/Power Pad button presses or releases. +/// Applies the NES rows bound to a [`KeyCode`]: presses or releases its NES, SNES or +/// Power Pad buttons, or the Vs. System coin and service inputs. /// /// `ports` is the set of NES ports keyboard input should be routed to, -/// determined by [`keyboard_target_ports`]. P1 keys (WASD etc.) are sent to the -/// first port in `ports` (`ports.first()`); P2-specific keys (IJKL etc.) are sent -/// to the second port in `ports` (`ports.get(1)`), if present. +/// determined by [`super::keyboard_target_ports`]. Player 1 keys (WASD etc.) are sent to the +/// first port in `ports`; player 2 keys (IJKL etc.) to the second, if present. pub(super) fn handle_controller_key( console: &mut Console, key_code: KeyCode, pressed: bool, ports: &[u8], +) { + apply_nes_rows( + console, + desktop_rows(SystemType::Nes, key_code), + pressed, + ports, + ); +} + +/// Tries `rows` in order and stops at the first one the plugged device accepts. +pub(super) fn apply_nes_rows<'a>( + console: &mut Console, + rows: impl IntoIterator, + pressed: bool, + ports: &[u8], ) { let Some(nes) = console.as_nes_mut() else { return; }; - match key_code { - // ── Player 1: 1/2/3 → Power Pad buttons ────────────────────────── - KeyCode::Digit1 => pp_p1(nes, PowerPadButton::One, pressed, ports), - KeyCode::Digit2 => pp_p1(nes, PowerPadButton::Two, pressed, ports), - KeyCode::Digit3 => pp_p1(nes, PowerPadButton::Three, pressed, ports), - - // ── Player 1: QWEASD (D-pad / SNES L/R / Power Pad) ────────────── - KeyCode::KeyQ => pp_or_snes_p1(nes, PowerPadButton::Four, SnesButton::L, pressed, ports), - KeyCode::KeyW => pp_or_btn_or_snes_p1( - nes, - PowerPadButton::Five, - Button::Up, - SnesButton::Up, - pressed, - ports, - ), - KeyCode::KeyE => pp_or_snes_p1(nes, PowerPadButton::Six, SnesButton::R, pressed, ports), - KeyCode::KeyA => pp_or_btn_or_snes_p1( - nes, - PowerPadButton::Seven, - Button::Left, - SnesButton::Left, - pressed, - ports, - ), - KeyCode::KeyS => pp_or_btn_or_snes_p1( - nes, - PowerPadButton::Eight, - Button::Down, - SnesButton::Down, - pressed, - ports, - ), - KeyCode::KeyD => pp_or_btn_or_snes_p1( - nes, - PowerPadButton::Nine, - Button::Right, - SnesButton::Right, - pressed, - ports, - ), - - // ── Player 1: ZXC → Power Pad ──────────────────────────────────── - KeyCode::KeyZ => pp_p1(nes, PowerPadButton::Ten, pressed, ports), - KeyCode::KeyX => pp_p1(nes, PowerPadButton::Eleven, pressed, ports), - KeyCode::KeyC => pp_p1(nes, PowerPadButton::Twelve, pressed, ports), - - // ── Player 1: T/R = A/B (joypad or SNES Y/X) ───────────────────── - KeyCode::KeyT => btn_or_snes_p1(nes, Button::A, SnesButton::Y, pressed, ports), - KeyCode::KeyR => btn_or_snes_p1(nes, Button::B, SnesButton::X, pressed, ports), - - // ── Player 1: F/G = SNES B/A only ──────────────────────────────── - KeyCode::KeyF => snes_p1(nes, SnesButton::B, pressed, ports), - KeyCode::KeyG => snes_p1(nes, SnesButton::A, pressed, ports), - - // ── Player 1: 4/5 = Select/Start ───────────────────────────────── - KeyCode::Digit4 => btn_or_snes_p1(nes, Button::Select, SnesButton::Select, pressed, ports), - KeyCode::Digit5 => btn_or_snes_p1(nes, Button::Start, SnesButton::Start, pressed, ports), - - // ── Player 2: 7/8 → Power Pad; 9 = PP3/Select; 0 = Start ───────── - KeyCode::Digit7 => pp_p2(nes, PowerPadButton::One, pressed, ports), - KeyCode::Digit8 => pp_p2(nes, PowerPadButton::Two, pressed, ports), - KeyCode::Digit9 => pp_or_btn_p2(nes, PowerPadButton::Three, Button::Select, pressed, ports), - KeyCode::Digit0 => btn_p2(nes, Button::Start, pressed, ports), - - // ── Player 2: UIOJKL M,. = D-pad / Power Pad ───────────────────── - KeyCode::KeyU => pp_p2(nes, PowerPadButton::Four, pressed, ports), - KeyCode::KeyI => pp_or_btn_p2(nes, PowerPadButton::Five, Button::Up, pressed, ports), - KeyCode::KeyO => pp_or_btn_p2(nes, PowerPadButton::Six, Button::A, pressed, ports), - KeyCode::KeyJ => pp_or_btn_p2(nes, PowerPadButton::Seven, Button::Left, pressed, ports), - KeyCode::KeyK => pp_or_btn_p2(nes, PowerPadButton::Eight, Button::Down, pressed, ports), - KeyCode::KeyL => pp_or_btn_p2(nes, PowerPadButton::Nine, Button::Right, pressed, ports), - KeyCode::KeyM => pp_p2(nes, PowerPadButton::Ten, pressed, ports), - KeyCode::Comma => pp_p2(nes, PowerPadButton::Eleven, pressed, ports), - KeyCode::Period => pp_p2(nes, PowerPadButton::Twelve, pressed, ports), - KeyCode::KeyP => btn_p2(nes, Button::B, pressed, ports), - - // ── VS System: coin insert / service button ────────────────────── - // A press inserts one coin; the core times the coin pulse, so the release is moot. - KeyCode::Digit6 => { - if pressed { - nes.insert_vs_coin(0); + let port_of = |player| match player { + Player::One => ports.first().copied(), + Player::Two => ports.get(1).copied(), + }; + for row in rows { + let accepted = match row.input { + Input::PowerPad(player, button) => { + let Some(port) = port_of(player) else { return }; + nes.set_power_pad_button(port, button, pressed) + } + Input::SnesPadOnNes(player, button) => { + let Some(port) = port_of(player) else { return }; + nes.set_snes_button(port, button.on_nes_snes_pad(), pressed) } + Input::Pad(player, button) => { + let Some(port) = port_of(player) else { return }; + if let Some(button) = nes_button(button.id()) { + nes.set_button(port, button, pressed); + } + true + } + // A press inserts one coin; the core times the coin pulse, so the release is moot. + Input::VsCoin => { + if pressed { + nes.insert_vs_coin(0); + } + true + } + Input::VsService => { + nes.set_vs_service_button(pressed); + true + } + Input::SuperScopeTurbo | Input::SuperScopePause => false, + }; + if accepted { + return; } - KeyCode::Minus => nes.set_vs_service_button(pressed), - - _ => {} } } -// ── Player-1 button helpers (route to the primary keyboard port) ─────────────────────────────────────── - -fn pp_p1(nes: &mut crate::nes::console::Nes, pp: PowerPadButton, pressed: bool, ports: &[u8]) { - if let Some(&port) = ports.first() { - nes.set_power_pad_button(port, pp, pressed); - } -} +#[cfg(test)] +mod tests { -fn snes_p1(nes: &mut crate::nes::console::Nes, snes: SnesButton, pressed: bool, ports: &[u8]) { - if let Some(&port) = ports.first() { - nes.set_snes_button(port, snes, pressed); - } -} + use crate::frontends::native::app_state::NativeAppState; + use crate::frontends::native::keyboard::test_support::*; + use crate::frontends::native::keyboard::{handle_key_pressed, handle_key_released}; -fn btn_or_snes_p1( - nes: &mut crate::nes::console::Nes, - btn: Button, - snes: SnesButton, - pressed: bool, - ports: &[u8], -) { - if let Some(&port) = ports.first() - && !nes.set_snes_button(port, snes, pressed) - { - nes.set_button(port, btn, pressed); - } -} + use winit::keyboard::KeyCode; -fn pp_or_snes_p1( - nes: &mut crate::nes::console::Nes, - pp: PowerPadButton, - snes: SnesButton, - pressed: bool, - ports: &[u8], -) { - if let Some(&port) = ports.first() - && !nes.set_power_pad_button(port, pp, pressed) - { - nes.set_snes_button(port, snes, pressed); + #[test] + fn snes_key_mapping_covers_face_and_shoulder_buttons() { + // Base GB-style keys still map. + let snes = |key| super::pad_button_id(crate::platform::emulator::SystemType::Snes, key); + assert_eq!(snes(KeyCode::KeyT), Some(0)); // A + assert_eq!(snes(KeyCode::KeyR), Some(1)); // B + assert_eq!(snes(KeyCode::Digit4), Some(2)); // Select + assert_eq!(snes(KeyCode::Digit5), Some(3)); // Start + // SNES additions. + assert_eq!(snes(KeyCode::KeyQ), Some(8)); // L + assert_eq!(snes(KeyCode::KeyE), Some(9)); // R + assert_eq!(snes(KeyCode::KeyY), Some(10)); // X + assert_eq!(snes(KeyCode::KeyG), Some(11)); // Y + assert_eq!(snes(KeyCode::F1), None); } -} -fn pp_or_btn_or_snes_p1( - nes: &mut crate::nes::console::Nes, - pp: PowerPadButton, - btn: Button, - snes: SnesButton, - pressed: bool, - ports: &[u8], -) { - if let Some(&port) = ports.first() - && !nes.set_power_pad_button(port, pp, pressed) - && !nes.set_snes_button(port, snes, pressed) - { - nes.set_button(port, btn, pressed); - } -} + // ── The shared key table (nr-tlf) ───────────────────────────────────────── -// ── Player-2-only button helpers ───────────────────────────────────────────── + use crate::nes::input::PowerPadButton; + use crate::platform::emulator::SystemType; + use crate::platform::key_bindings::{ + Input, Key, KeyBinding, PadButton, Player, Shell, Shells, bindings_of, + }; -fn btn_p2(nes: &mut crate::nes::console::Nes, btn: Button, pressed: bool, ports: &[u8]) { - if let Some(&port) = ports.get(1) { - nes.set_button(port, btn, pressed); + fn row(key: Key, input: Input) -> KeyBinding { + KeyBinding { + console: SystemType::Nes, + key, + input, + shells: Shells::Both, + } } -} -fn pp_p2(nes: &mut crate::nes::console::Nes, pp: PowerPadButton, pressed: bool, ports: &[u8]) { - if let Some(&port) = ports.get(1) { - nes.set_power_pad_button(port, pp, pressed); + /// The NES dispatch does what the rows say, whatever key they hang off: a Power Pad row + /// a joypad rejects falls through to the next row. + #[test] + fn nes_keys_follow_the_rows_they_are_given() { + let rows = [ + row(Key::KeyZ, Input::PowerPad(Player::Two, PowerPadButton::One)), + row(Key::KeyZ, Input::Pad(Player::Two, PadButton::Start)), + ]; + let mut console = make_nes_console(); + super::apply_nes_rows(&mut console, &rows, true, &[1, 2]); + assert_ne!(console.get_joypad_button_states(2) & BIT_START, 0); + assert_eq!(console.get_joypad_button_states(1), 0); } -} -fn pp_or_btn_p2( - nes: &mut crate::nes::console::Nes, - pp: PowerPadButton, - btn: Button, - pressed: bool, - ports: &[u8], -) { - if let Some(&port) = ports.get(1) - && !nes.set_power_pad_button(port, pp, pressed) - { - nes.set_button(port, btn, pressed); + /// Every key the table binds is one the desktop can receive. + #[test] + fn every_table_key_has_a_winit_key() { + for b in crate::platform::key_bindings::KEY_BINDINGS { + assert!( + super::WINIT_KEYS.iter().any(|&(_, key)| key == b.key), + "{:?} has no winit KeyCode", + b.key + ); + } } -} - -#[cfg(test)] -mod tests { - - use crate::frontends::native::app_state::NativeAppState; - use crate::frontends::native::keyboard::test_support::*; - use crate::frontends::native::keyboard::{handle_key_pressed, handle_key_released}; - use winit::keyboard::KeyCode; + /// Every desktop NES Power Pad and SNES-pad row reaches that device when it is plugged + /// into its player's port: the earlier rows of the chain are the ones a device takes. + #[test] + fn every_desktop_nes_power_pad_and_snes_pad_row_reaches_its_device() { + use crate::nes::input::{ControllerState, ControllerType}; + let mut checked = 0; + for b in bindings_of(Shell::Desktop).filter(|b| b.console == SystemType::Nes) { + let (player, device, bit) = match b.input { + Input::PowerPad(player, button) => (player, ControllerType::PowerPad, button as u8), + Input::SnesPadOnNes(player, button) => ( + player, + ControllerType::SnesController, + button.on_nes_snes_pad() as u8, + ), + _ => continue, + }; + let port = if player == Player::One { 1 } else { 2 }; + let mut console = make_nes_console(); + let nes = console.as_nes_mut().unwrap(); + nes.bus().borrow_mut().set_controller_type(port, device); + let mut state = make_state(); + handle_key_pressed(&mut console, super::winit_key(b.key), &mut state, None); + let nes = console.as_nes_mut().unwrap(); + let pressed = match nes.bus().borrow().controller_state(port) { + ControllerState::PowerPad(s) => s.button_states, + ControllerState::SnesAdapter(s) => s.button_states, + _ => panic!("{b:?}: no {device:?} on port {port}"), + }; + assert_eq!(pressed, 1 << bit, "{b:?}"); + handle_key_released(&mut console, super::winit_key(b.key), 0, false); + let nes = console.as_nes_mut().unwrap(); + let released = match nes.bus().borrow().controller_state(port) { + ControllerState::PowerPad(s) => s.button_states, + ControllerState::SnesAdapter(s) => s.button_states, + _ => unreachable!(), + }; + assert_eq!(released, 0, "{b:?} release"); + checked += 1; + } + assert_eq!(checked, 24 + 12, "24 Power Pad rows and 12 SNES-pad rows"); + } + /// The Vs. service key (-) holds the service line ($4016 bit 2) while it is held. #[test] - fn snes_key_mapping_covers_face_and_shoulder_buttons() { - // Base GB-style keys still map. - assert_eq!(super::snes_key_to_button_id(KeyCode::KeyT), Some(0)); // A - assert_eq!(super::snes_key_to_button_id(KeyCode::KeyR), Some(1)); // B - assert_eq!(super::snes_key_to_button_id(KeyCode::Digit4), Some(2)); // Select - assert_eq!(super::snes_key_to_button_id(KeyCode::Digit5), Some(3)); // Start - // SNES additions. - assert_eq!(super::snes_key_to_button_id(KeyCode::KeyQ), Some(8)); // L - assert_eq!(super::snes_key_to_button_id(KeyCode::KeyE), Some(9)); // R - assert_eq!(super::snes_key_to_button_id(KeyCode::KeyY), Some(10)); // X - assert_eq!(super::snes_key_to_button_id(KeyCode::KeyG), Some(11)); // Y - assert_eq!(super::snes_key_to_button_id(KeyCode::F1), None); + fn test_minus_holds_the_vs_service_button() { + let mut console = make_vs_console(); + let mut state = make_state(); + let service = |console: &mut crate::platform::emulator::Console| { + let nes = console.as_nes_mut().unwrap(); + nes.bus().borrow_mut().read(0x4016, false) & 0x04 + }; + assert_eq!(service(&mut console), 0); + handle_key_pressed(&mut console, KeyCode::Minus, &mut state, None); + assert_ne!(service(&mut console), 0); + handle_key_released(&mut console, KeyCode::Minus, 0, false); + assert_eq!(service(&mut console), 0); + } + + /// Every desktop NES joypad row presses its button on its player's port. + #[test] + fn every_desktop_nes_pad_row_presses_its_button() { + for b in bindings_of(Shell::Desktop).filter(|b| b.console == SystemType::Nes) { + let Input::Pad(player, button) = b.input else { + continue; + }; + let mut console = make_nes_console(); + let mut state = make_state(); + handle_key_pressed(&mut console, super::winit_key(b.key), &mut state, None); + let port = if player == Player::One { 1 } else { 2 }; + assert_ne!( + console.get_joypad_button_states(port) & (1 << button.id()), + 0, + "{b:?}" + ); + } } // ── VS System coin key ──────────────────────────────────────────────────── diff --git a/src/frontends/native/keyboard/mod.rs b/src/frontends/native/keyboard/mod.rs index 1c8f2a16c..b7c7375e4 100644 --- a/src/frontends/native/keyboard/mod.rs +++ b/src/frontends/native/keyboard/mod.rs @@ -101,12 +101,12 @@ pub fn handle_key_released( controller_mapping::handle_controller_key(console, key_code, false, ports); } SystemType::GameBoy => { - if let Some(btn_id) = controller_mapping::gameboy_key_to_button_id(key_code) { + if let Some(btn_id) = controller_mapping::pad_button_id(SystemType::GameBoy, key_code) { console.set_button(0, btn_id, false); } } SystemType::Gba => { - if let Some(btn_id) = controller_mapping::gba_key_to_button_id(key_code) { + if let Some(btn_id) = controller_mapping::pad_button_id(SystemType::Gba, key_code) { console.set_button(0, btn_id, false); } } @@ -114,7 +114,7 @@ pub fn handle_key_released( if console_keyboard::handle_super_scope_key(console, key_code, false) { return; } - if let Some(btn_id) = controller_mapping::snes_key_to_button_id(key_code) { + if let Some(btn_id) = controller_mapping::pad_button_id(SystemType::Snes, key_code) { console.set_button(0, btn_id, false); } } diff --git a/src/frontends/web/wasm.rs b/src/frontends/web/wasm.rs index d3398f320..a5d232926 100644 --- a/src/frontends/web/wasm.rs +++ b/src/frontends/web/wasm.rs @@ -1051,6 +1051,72 @@ pub fn rom_extension_table() -> js_sys::Array { .collect() } +/// One row of the shared key table as the web page applies it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WebKeyBinding { + /// The lower-cased `KeyboardEvent.key` the page matches. + pub key: &'static str, + /// The web console kind: `nes`, `gb`, `gba` or `snes`. + pub console: &'static str, + /// `pad`, `snesPad`, `vsCoin`, `scopeTurbo` or `scopePause`. + pub input: &'static str, + /// The keyboard player, 1 or 2. + pub player: u8, + /// The button id the wasm setter for `input` takes, or 0 for an input without one. + pub button: u8, +} + +/// The web rows of `platform::key_bindings::KEY_BINDINGS`, in the order the page tries them. +/// An input the page cannot apply (a Power Pad, the Vs. service button) has no name here, +/// so declaring such a row for the web leaves it out, and a test goes red. +pub fn web_key_binding_rows() -> Vec { + use crate::platform::key_bindings::{Input, Player, Shell, bindings_of}; + bindings_of(Shell::Web) + .filter_map(|b| { + let player = |p| if p == Player::One { 1 } else { 2 }; + let (input, player, button) = match b.input { + Input::Pad(p, button) => ("pad", player(p), button.id()), + Input::SnesPadOnNes(p, button) => { + ("snesPad", player(p), button.on_nes_snes_pad() as u8) + } + Input::VsCoin => ("vsCoin", 1, 0), + Input::SuperScopeTurbo => ("scopeTurbo", 1, 0), + Input::SuperScopePause => ("scopePause", 1, 0), + Input::PowerPad(..) | Input::VsService => return None, + }; + Some(WebKeyBinding { + key: b.key.web_key(), + console: console_key(b.console), + input, + player, + button, + }) + }) + .collect() +} + +/// Every keyboard binding of the web shell, as `{key, console, input, player, button}` +/// objects: the page's copy of `platform::key_bindings::KEY_BINDINGS`. +#[wasm_bindgen] +pub fn key_binding_table() -> js_sys::Array { + web_key_binding_rows() + .into_iter() + .map(|row| { + let object = js_sys::Object::new(); + let set = |name: &str, value: JsValue| { + js_sys::Reflect::set(&object, &JsValue::from_str(name), &value) + .expect("setting a field on a fresh object"); + }; + set("key", JsValue::from_str(row.key)); + set("console", JsValue::from_str(row.console)); + set("input", JsValue::from_str(row.input)); + set("player", JsValue::from(row.player)); + set("button", JsValue::from(row.button)); + JsValue::from(object) + }) + .collect() +} + #[wasm_bindgen] pub fn gamepad_init_toast_message(gamepads_enabled: bool, detected_controllers: usize) -> String { shared_gamepad_init_toast_message(gamepads_enabled, detected_controllers) @@ -1087,6 +1153,53 @@ mod tests { use crate::nes::debugging::snapshot; use crate::platform::app_context::AppContext; + // ── The shared key table (nr-tlf) ───────────────────────────────────────── + + use crate::platform::key_bindings::{Shell, bindings_of}; + + fn row(key: &str, console: &str, input: &str) -> Vec { + web_key_binding_rows() + .into_iter() + .filter(|r| r.key == key && r.console == console && r.input == input) + .collect() + } + + /// A row the web shell cannot apply would be a binding missing on the web with nothing + /// red to say so: every web row must have an input the page handles. + #[test] + fn every_web_row_reaches_the_page() { + assert_eq!( + web_key_binding_rows().len(), + bindings_of(Shell::Web).count() + ); + } + + #[test] + fn web_rows_carry_the_id_their_wasm_setter_takes() { + // WasmNes::set_button and the GB/GBA/SNES set_button take platform ids. + assert_eq!(row("w", "nes", "pad")[0].button, 4); + assert_eq!(row("y", "snes", "pad")[0].button, 10); // X + assert_eq!(row("b", "gba", "pad")[0].button, 9); // R + // WasmNes::set_snes_button takes its own order: 0=B, 1=Y, …, 8=A, 9=X, 10=L, 11=R. + assert_eq!(row("r", "nes", "snesPad")[0].button, 0); // B + assert_eq!(row("t", "nes", "snesPad")[0].button, 8); // A + assert_eq!(row("q", "nes", "snesPad")[0].button, 10); // L + } + + #[test] + fn web_rows_keep_the_order_a_shell_tries_them() { + let w: Vec<_> = web_key_binding_rows() + .into_iter() + .filter(|r| r.key == "w" && r.console == "nes") + .map(|r| r.input) + .collect(); + assert_eq!(w, ["snesPad", "pad"]); + assert_eq!(row("6", "nes", "vsCoin").len(), 1); + assert_eq!(row("i", "snes", "pad")[0].player, 2); + assert_eq!(row("4", "snes", "scopeTurbo").len(), 1); + assert_eq!(row("5", "snes", "scopePause").len(), 1); + } + #[test] fn test_serialize_debugger_snapshot_json_includes_oam_field() { let nes = Nes::new(AppContext::new_with_config(Config::default())); diff --git a/src/frontends/web/wasm_tests.rs b/src/frontends/web/wasm_tests.rs index 2a04ac1f7..a301c2449 100644 --- a/src/frontends/web/wasm_tests.rs +++ b/src/frontends/web/wasm_tests.rs @@ -3,7 +3,7 @@ use crate::nes::bus::ControllerStateWrapper; use crate::nes::console::SaveState; use crate::nes::input::ArkanoidState; -use crate::wasm::{WasmNes, gamepad_init_toast_message, rom_extension_table}; +use crate::wasm::{WasmNes, gamepad_init_toast_message, key_binding_table, rom_extension_table}; use crate::wasm_gb::WasmGb; use crate::wasm_gba::WasmGba; use crate::wasm_snes::WasmSnes; @@ -219,6 +219,39 @@ fn rom_extension_table_pairs_each_extension_with_its_console() { assert_eq!(pairs, expected); } +/// The page receives every web row of the shared key table, field for field. +#[wasm_bindgen_test] +fn key_binding_table_has_exactly_the_web_rows() { + let field = + |row: &wasm_bindgen::JsValue, name: &str| js_sys::Reflect::get(row, &name.into()).unwrap(); + let rows: Vec<(String, String, String, u8, u8)> = key_binding_table() + .iter() + .map(|row| { + ( + field(&row, "key").as_string().unwrap(), + field(&row, "console").as_string().unwrap(), + field(&row, "input").as_string().unwrap(), + field(&row, "player").as_f64().unwrap() as u8, + field(&row, "button").as_f64().unwrap() as u8, + ) + }) + .collect(); + let expected: Vec<_> = crate::wasm::web_key_binding_rows() + .into_iter() + .map(|r| { + ( + r.key.to_string(), + r.console.to_string(), + r.input.to_string(), + r.player, + r.button, + ) + }) + .collect(); + assert!(!rows.is_empty()); + assert_eq!(rows, expected); +} + #[wasm_bindgen_test] fn render_frame_returns_expected_size() { let mut nes = WasmNes::new(); diff --git a/src/nes/bus/bus.rs b/src/nes/bus/bus.rs index c0cdfc249..6c5a1480d 100644 --- a/src/nes/bus/bus.rs +++ b/src/nes/bus/bus.rs @@ -830,6 +830,15 @@ impl Bus { } } + /// The state of the controller in port 1 or 2, whatever its type. + // Only the native keyboard tests read it. + #[cfg(all(test, feature = "native"))] + pub(crate) fn controller_state(&self, port: u8) -> crate::nes::input::ControllerState { + self.controllers[(port - 1) as usize] + .borrow() + .capture_state() + } + /// Set the controller type for a specific port. pub fn set_controller_type(&mut self, port: u8, controller_type: ControllerType) { if !(1..=2).contains(&port) { diff --git a/src/platform/key_bindings.rs b/src/platform/key_bindings.rs new file mode 100644 index 000000000..9dbdb14bd --- /dev/null +++ b/src/platform/key_bindings.rs @@ -0,0 +1,618 @@ +//! Which keyboard key drives which console input: the one table both shells read. +//! +//! The native shell looks every key up here (`frontends::native::keyboard`), and the web +//! shell reads the same rows through the wasm binding `key_binding_table`. A binding is +//! therefore one row, and a binding that only one shell has says so in its row +//! ([`Shells::DesktopOnly`] / [`Shells::WebOnly`]); the tests below pin the full list of +//! those, so a one-shell binding is always a decision someone wrote down. +//! +//! For one key on one console, rows apply in table order: a shell tries each row and stops +//! at the first one the plugged device accepts (a Power Pad, then an SNES pad on the NES +//! port, then the joypad; a Super Scope before the SNES pad's Select/Start). Which port a +//! [`Player`] is, and gamepad, Four Score, Zapper and Mouse routing, stay with each shell. + +use crate::nes::input::{PowerPadButton, SnesButton as NesSnesButton}; +use crate::platform::emulator::SystemType; + +/// A key, named as the W3C `KeyboardEvent.code` (and winit's `KeyCode`) names it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Key { + Digit0, + Digit1, + Digit2, + Digit3, + Digit4, + Digit5, + Digit6, + Digit7, + Digit8, + Digit9, + KeyA, + KeyB, + KeyC, + KeyD, + KeyE, + KeyF, + KeyG, + KeyI, + KeyJ, + KeyK, + KeyL, + KeyM, + KeyO, + KeyP, + KeyQ, + KeyR, + KeyS, + KeyT, + KeyU, + KeyV, + KeyW, + KeyX, + KeyY, + KeyZ, + Minus, + Comma, + Period, + ArrowUp, + ArrowDown, + ArrowLeft, + ArrowRight, +} + +impl Key { + /// The lower-cased `KeyboardEvent.key` the web shell matches for this key on a US + /// layout: the web reads the character typed, the desktop the physical key. + pub const fn web_key(self) -> &'static str { + match self { + Key::Digit0 => "0", + Key::Digit1 => "1", + Key::Digit2 => "2", + Key::Digit3 => "3", + Key::Digit4 => "4", + Key::Digit5 => "5", + Key::Digit6 => "6", + Key::Digit7 => "7", + Key::Digit8 => "8", + Key::Digit9 => "9", + Key::KeyA => "a", + Key::KeyB => "b", + Key::KeyC => "c", + Key::KeyD => "d", + Key::KeyE => "e", + Key::KeyF => "f", + Key::KeyG => "g", + Key::KeyI => "i", + Key::KeyJ => "j", + Key::KeyK => "k", + Key::KeyL => "l", + Key::KeyM => "m", + Key::KeyO => "o", + Key::KeyP => "p", + Key::KeyQ => "q", + Key::KeyR => "r", + Key::KeyS => "s", + Key::KeyT => "t", + Key::KeyU => "u", + Key::KeyV => "v", + Key::KeyW => "w", + Key::KeyX => "x", + Key::KeyY => "y", + Key::KeyZ => "z", + Key::Minus => "-", + Key::Comma => ",", + Key::Period => ".", + Key::ArrowUp => "arrowup", + Key::ArrowDown => "arrowdown", + Key::ArrowLeft => "arrowleft", + Key::ArrowRight => "arrowright", + } + } +} + +/// A joypad button of any console. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PadButton { + A, + B, + Select, + Start, + Up, + Down, + Left, + Right, + L, + R, + X, + Y, +} + +impl PadButton { + /// The platform button id every console's `button_from_id` and `Console::set_button` + /// take: `0=A, 1=B, 2=Select, 3=Start, 4=Up, 5=Down, 6=Left, 7=Right, 8=L, 9=R, 10=X, 11=Y`. + pub const fn id(self) -> u8 { + match self { + PadButton::A => 0, + PadButton::B => 1, + PadButton::Select => 2, + PadButton::Start => 3, + PadButton::Up => 4, + PadButton::Down => 5, + PadButton::Left => 6, + PadButton::Right => 7, + PadButton::L => 8, + PadButton::R => 9, + PadButton::X => 10, + PadButton::Y => 11, + } + } + + /// The button of an SNES controller in an NES port, whose discriminant is also the id + /// the wasm binding `WasmNes::set_snes_button` takes. + pub const fn on_nes_snes_pad(self) -> NesSnesButton { + match self { + PadButton::A => NesSnesButton::A, + PadButton::B => NesSnesButton::B, + PadButton::Select => NesSnesButton::Select, + PadButton::Start => NesSnesButton::Start, + PadButton::Up => NesSnesButton::Up, + PadButton::Down => NesSnesButton::Down, + PadButton::Left => NesSnesButton::Left, + PadButton::Right => NesSnesButton::Right, + PadButton::L => NesSnesButton::L, + PadButton::R => NesSnesButton::R, + PadButton::X => NesSnesButton::X, + PadButton::Y => NesSnesButton::Y, + } + } +} + +/// The keyboard player a key belongs to; each shell decides which port that player is. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Player { + One, + Two, +} + +/// What a key does to the console. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Input { + /// The joypad (NES, GB, GBA, SNES console). + Pad(Player, PadButton), + /// A Power Pad on the NES. + PowerPad(Player, PowerPadButton), + /// An SNES controller in an NES port. + SnesPadOnNes(Player, PadButton), + /// One Vs. System coin into slot 1, on the press; the core times the pulse. + VsCoin, + /// The Vs. System service button, held while the key is. + VsService, + /// A plugged Super Scope's Turbo switch, flipped on the press. + SuperScopeTurbo, + /// A plugged Super Scope's Pause button. + SuperScopePause, +} + +/// Which shells honour a binding. A one-shell binding is a difference somebody declared. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Shells { + Both, + DesktopOnly, + WebOnly, +} + +/// One of the two shells. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Shell { + Desktop, + Web, +} + +impl Shells { + /// Whether `shell` honours a binding declared for these shells. + pub const fn include(self, shell: Shell) -> bool { + matches!( + (self, shell), + (Shells::Both, _) + | (Shells::DesktopOnly, Shell::Desktop) + | (Shells::WebOnly, Shell::Web) + ) + } +} + +/// One key bound to one console input. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct KeyBinding { + pub console: SystemType, + pub key: Key, + pub input: Input, + pub shells: Shells, +} + +/// Every keyboard binding of both shells. +pub const KEY_BINDINGS: &[KeyBinding] = &[ + // ── NES, player 1 ───────────────────────────────────────────────────────────── + desktop(NES, Digit1, PowerPad(One, PP::One)), + desktop(NES, Digit2, PowerPad(One, PP::Two)), + desktop(NES, Digit3, PowerPad(One, PP::Three)), + desktop(NES, KeyQ, PowerPad(One, PP::Four)), + both(NES, KeyQ, SnesPadOnNes(One, L)), + desktop(NES, KeyW, PowerPad(One, PP::Five)), + both(NES, KeyW, SnesPadOnNes(One, Up)), + both(NES, KeyW, Pad(One, Up)), + desktop(NES, KeyE, PowerPad(One, PP::Six)), + both(NES, KeyE, SnesPadOnNes(One, R)), + desktop(NES, KeyA, PowerPad(One, PP::Seven)), + both(NES, KeyA, SnesPadOnNes(One, Left)), + both(NES, KeyA, Pad(One, Left)), + desktop(NES, KeyS, PowerPad(One, PP::Eight)), + both(NES, KeyS, SnesPadOnNes(One, Down)), + both(NES, KeyS, Pad(One, Down)), + desktop(NES, KeyD, PowerPad(One, PP::Nine)), + both(NES, KeyD, SnesPadOnNes(One, Right)), + both(NES, KeyD, Pad(One, Right)), + desktop(NES, KeyZ, PowerPad(One, PP::Ten)), + desktop(NES, KeyX, PowerPad(One, PP::Eleven)), + desktop(NES, KeyC, PowerPad(One, PP::Twelve)), + desktop(NES, KeyT, SnesPadOnNes(One, Y)), + desktop(NES, KeyT, Pad(One, A)), + web(NES, KeyT, SnesPadOnNes(One, A)), + web(NES, KeyT, Pad(One, B)), + desktop(NES, KeyR, SnesPadOnNes(One, X)), + desktop(NES, KeyR, Pad(One, B)), + web(NES, KeyR, SnesPadOnNes(One, B)), + web(NES, KeyR, Pad(One, A)), + desktop(NES, KeyF, SnesPadOnNes(One, B)), + web(NES, KeyY, SnesPadOnNes(One, X)), + desktop(NES, KeyG, SnesPadOnNes(One, A)), + web(NES, KeyG, SnesPadOnNes(One, Y)), + both(NES, Digit4, SnesPadOnNes(One, Select)), + both(NES, Digit4, Pad(One, Select)), + both(NES, Digit5, SnesPadOnNes(One, Start)), + both(NES, Digit5, Pad(One, Start)), + // ── NES, player 2 ───────────────────────────────────────────────────────────── + desktop(NES, Digit7, PowerPad(Two, PP::One)), + desktop(NES, Digit8, PowerPad(Two, PP::Two)), + desktop(NES, Digit9, PowerPad(Two, PP::Three)), + both(NES, Digit9, Pad(Two, Select)), + both(NES, Digit0, Pad(Two, Start)), + desktop(NES, KeyU, PowerPad(Two, PP::Four)), + desktop(NES, KeyI, PowerPad(Two, PP::Five)), + both(NES, KeyI, Pad(Two, Up)), + desktop(NES, KeyO, PowerPad(Two, PP::Six)), + both(NES, KeyO, Pad(Two, A)), + desktop(NES, KeyJ, PowerPad(Two, PP::Seven)), + both(NES, KeyJ, Pad(Two, Left)), + desktop(NES, KeyK, PowerPad(Two, PP::Eight)), + both(NES, KeyK, Pad(Two, Down)), + desktop(NES, KeyL, PowerPad(Two, PP::Nine)), + both(NES, KeyL, Pad(Two, Right)), + desktop(NES, KeyM, PowerPad(Two, PP::Ten)), + desktop(NES, Comma, PowerPad(Two, PP::Eleven)), + desktop(NES, Period, PowerPad(Two, PP::Twelve)), + both(NES, KeyP, Pad(Two, B)), + // ── NES, Vs. System ─────────────────────────────────────────────────────────── + both(NES, Digit6, VsCoin), + desktop(NES, Minus, VsService), + // ── Game Boy ────────────────────────────────────────────────────────────────── + desktop(GB, KeyT, Pad(One, A)), + web(GB, KeyT, Pad(One, B)), + desktop(GB, KeyR, Pad(One, B)), + web(GB, KeyR, Pad(One, A)), + both(GB, Digit4, Pad(One, Select)), + both(GB, Digit5, Pad(One, Start)), + both(GB, KeyW, Pad(One, Up)), + desktop(GB, ArrowUp, Pad(One, Up)), + both(GB, KeyS, Pad(One, Down)), + desktop(GB, ArrowDown, Pad(One, Down)), + both(GB, KeyA, Pad(One, Left)), + desktop(GB, ArrowLeft, Pad(One, Left)), + both(GB, KeyD, Pad(One, Right)), + desktop(GB, ArrowRight, Pad(One, Right)), + // ── Game Boy Advance ────────────────────────────────────────────────────────── + desktop(GBA, KeyT, Pad(One, A)), + web(GBA, KeyG, Pad(One, A)), + desktop(GBA, KeyR, Pad(One, B)), + web(GBA, KeyF, Pad(One, B)), + desktop(GBA, KeyQ, Pad(One, L)), + web(GBA, KeyV, Pad(One, L)), + desktop(GBA, KeyE, Pad(One, R)), + web(GBA, KeyB, Pad(One, R)), + both(GBA, Digit4, Pad(One, Select)), + both(GBA, Digit5, Pad(One, Start)), + both(GBA, KeyW, Pad(One, Up)), + desktop(GBA, ArrowUp, Pad(One, Up)), + both(GBA, KeyS, Pad(One, Down)), + desktop(GBA, ArrowDown, Pad(One, Down)), + both(GBA, KeyA, Pad(One, Left)), + desktop(GBA, ArrowLeft, Pad(One, Left)), + both(GBA, KeyD, Pad(One, Right)), + desktop(GBA, ArrowRight, Pad(One, Right)), + // ── SNES, player 1 ──────────────────────────────────────────────────────────── + both(SNES, KeyT, Pad(One, A)), + both(SNES, KeyR, Pad(One, B)), + both(SNES, KeyY, Pad(One, X)), + both(SNES, KeyG, Pad(One, Y)), + both(SNES, KeyQ, Pad(One, L)), + both(SNES, KeyE, Pad(One, R)), + both(SNES, Digit4, SuperScopeTurbo), + both(SNES, Digit4, Pad(One, Select)), + both(SNES, Digit5, SuperScopePause), + both(SNES, Digit5, Pad(One, Start)), + both(SNES, KeyW, Pad(One, Up)), + desktop(SNES, ArrowUp, Pad(One, Up)), + both(SNES, KeyS, Pad(One, Down)), + desktop(SNES, ArrowDown, Pad(One, Down)), + both(SNES, KeyA, Pad(One, Left)), + desktop(SNES, ArrowLeft, Pad(One, Left)), + both(SNES, KeyD, Pad(One, Right)), + desktop(SNES, ArrowRight, Pad(One, Right)), + // ── SNES, player 2 ──────────────────────────────────────────────────────────── + web(SNES, KeyI, Pad(Two, Up)), + web(SNES, KeyK, Pad(Two, Down)), + web(SNES, KeyJ, Pad(Two, Left)), + web(SNES, KeyL, Pad(Two, Right)), + web(SNES, KeyP, Pad(Two, B)), + web(SNES, KeyO, Pad(Two, A)), + web(SNES, Digit9, Pad(Two, Select)), + web(SNES, Digit0, Pad(Two, Start)), +]; + +use Input::{Pad, PowerPad, SnesPadOnNes, SuperScopePause, SuperScopeTurbo, VsCoin, VsService}; +use Key::*; +use PadButton::{A, B, Down, L, Left, R, Right, Select, Start, Up, X, Y}; +use Player::{One, Two}; +use PowerPadButton as PP; +const NES: SystemType = SystemType::Nes; +const GB: SystemType = SystemType::GameBoy; +const GBA: SystemType = SystemType::Gba; +const SNES: SystemType = SystemType::Snes; + +const fn row(console: SystemType, key: Key, input: Input, shells: Shells) -> KeyBinding { + KeyBinding { + console, + key, + input, + shells, + } +} + +const fn both(console: SystemType, key: Key, input: Input) -> KeyBinding { + row(console, key, input, Shells::Both) +} + +const fn desktop(console: SystemType, key: Key, input: Input) -> KeyBinding { + row(console, key, input, Shells::DesktopOnly) +} + +const fn web(console: SystemType, key: Key, input: Input) -> KeyBinding { + row(console, key, input, Shells::WebOnly) +} + +/// The rows `shell` applies when `key` is pressed or released on `console`, in the order +/// it tries them. +pub fn bindings_for( + shell: Shell, + console: SystemType, + key: Key, +) -> impl Iterator { + KEY_BINDINGS + .iter() + .filter(move |b| b.console == console && b.key == key && b.shells.include(shell)) +} + +/// Every row `shell` honours, in table order. +pub fn bindings_of(shell: Shell) -> impl Iterator { + KEY_BINDINGS.iter().filter(move |b| b.shells.include(shell)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn describe(b: &KeyBinding) -> String { + format!("{:?} {:?} {:?}", b.console, b.key, b.input) + } + + fn one_shell_rows(shells: Shells) -> Vec { + KEY_BINDINGS + .iter() + .filter(|b| b.shells == shells) + .map(describe) + .collect() + } + + /// The parity test: every binding one shell has and the other lacks. Each line is a + /// difference that existed when the table was made (nr-tlf) or that somebody declared + /// since; adding a one-shell row means adding its line here on purpose. Whether to + /// close any of them is a player-visible decision for the navigator. + #[test] + fn one_shell_bindings_are_exactly_the_declared_differences() { + let desktop_only = [ + // The NES Power Pad has no keys on the web. + "Nes Digit1 PowerPad(One, One)", + "Nes Digit2 PowerPad(One, Two)", + "Nes Digit3 PowerPad(One, Three)", + "Nes KeyQ PowerPad(One, Four)", + "Nes KeyW PowerPad(One, Five)", + "Nes KeyE PowerPad(One, Six)", + "Nes KeyA PowerPad(One, Seven)", + "Nes KeyS PowerPad(One, Eight)", + "Nes KeyD PowerPad(One, Nine)", + "Nes KeyZ PowerPad(One, Ten)", + "Nes KeyX PowerPad(One, Eleven)", + "Nes KeyC PowerPad(One, Twelve)", + // T and R are A and B on the desktop, B and A on the web (NES pad, SNES pad). + "Nes KeyT SnesPadOnNes(One, Y)", + "Nes KeyT Pad(One, A)", + "Nes KeyR SnesPadOnNes(One, X)", + "Nes KeyR Pad(One, B)", + "Nes KeyF SnesPadOnNes(One, B)", + "Nes KeyG SnesPadOnNes(One, A)", + "Nes Digit7 PowerPad(Two, One)", + "Nes Digit8 PowerPad(Two, Two)", + "Nes Digit9 PowerPad(Two, Three)", + "Nes KeyU PowerPad(Two, Four)", + "Nes KeyI PowerPad(Two, Five)", + "Nes KeyO PowerPad(Two, Six)", + "Nes KeyJ PowerPad(Two, Seven)", + "Nes KeyK PowerPad(Two, Eight)", + "Nes KeyL PowerPad(Two, Nine)", + "Nes KeyM PowerPad(Two, Ten)", + "Nes Comma PowerPad(Two, Eleven)", + "Nes Period PowerPad(Two, Twelve)", + // The Vs. System service button is bound on the desktop only. + "Nes Minus VsService", + "GameBoy KeyT Pad(One, A)", + "GameBoy KeyR Pad(One, B)", + "GameBoy ArrowUp Pad(One, Up)", + "GameBoy ArrowDown Pad(One, Down)", + "GameBoy ArrowLeft Pad(One, Left)", + "GameBoy ArrowRight Pad(One, Right)", + "Gba KeyT Pad(One, A)", + "Gba KeyR Pad(One, B)", + "Gba KeyQ Pad(One, L)", + "Gba KeyE Pad(One, R)", + "Gba ArrowUp Pad(One, Up)", + "Gba ArrowDown Pad(One, Down)", + "Gba ArrowLeft Pad(One, Left)", + "Gba ArrowRight Pad(One, Right)", + "Snes ArrowUp Pad(One, Up)", + "Snes ArrowDown Pad(One, Down)", + "Snes ArrowLeft Pad(One, Left)", + "Snes ArrowRight Pad(One, Right)", + ]; + let web_only = [ + "Nes KeyT SnesPadOnNes(One, A)", + "Nes KeyT Pad(One, B)", + "Nes KeyR SnesPadOnNes(One, B)", + "Nes KeyR Pad(One, A)", + "Nes KeyY SnesPadOnNes(One, X)", + "Nes KeyG SnesPadOnNes(One, Y)", + "GameBoy KeyT Pad(One, B)", + "GameBoy KeyR Pad(One, A)", + "Gba KeyG Pad(One, A)", + "Gba KeyF Pad(One, B)", + "Gba KeyV Pad(One, L)", + "Gba KeyB Pad(One, R)", + // The SNES console has a keyboard player 2 on the web only. + "Snes KeyI Pad(Two, Up)", + "Snes KeyK Pad(Two, Down)", + "Snes KeyJ Pad(Two, Left)", + "Snes KeyL Pad(Two, Right)", + "Snes KeyP Pad(Two, B)", + "Snes KeyO Pad(Two, A)", + "Snes Digit9 Pad(Two, Select)", + "Snes Digit0 Pad(Two, Start)", + ]; + assert_eq!(one_shell_rows(Shells::DesktopOnly), desktop_only); + assert_eq!(one_shell_rows(Shells::WebOnly), web_only); + } + + /// Every binding both shells share, pinned like the differences, so a shared row + /// that changes meaning (or moves to one shell) turns this red. + #[test] + fn both_shells_share_exactly_these_bindings() { + let shared = [ + "Nes KeyQ SnesPadOnNes(One, L)", + "Nes KeyW SnesPadOnNes(One, Up)", + "Nes KeyW Pad(One, Up)", + "Nes KeyE SnesPadOnNes(One, R)", + "Nes KeyA SnesPadOnNes(One, Left)", + "Nes KeyA Pad(One, Left)", + "Nes KeyS SnesPadOnNes(One, Down)", + "Nes KeyS Pad(One, Down)", + "Nes KeyD SnesPadOnNes(One, Right)", + "Nes KeyD Pad(One, Right)", + "Nes Digit4 SnesPadOnNes(One, Select)", + "Nes Digit4 Pad(One, Select)", + "Nes Digit5 SnesPadOnNes(One, Start)", + "Nes Digit5 Pad(One, Start)", + "Nes Digit9 Pad(Two, Select)", + "Nes Digit0 Pad(Two, Start)", + "Nes KeyI Pad(Two, Up)", + "Nes KeyO Pad(Two, A)", + "Nes KeyJ Pad(Two, Left)", + "Nes KeyK Pad(Two, Down)", + "Nes KeyL Pad(Two, Right)", + "Nes KeyP Pad(Two, B)", + "Nes Digit6 VsCoin", + "GameBoy Digit4 Pad(One, Select)", + "GameBoy Digit5 Pad(One, Start)", + "GameBoy KeyW Pad(One, Up)", + "GameBoy KeyS Pad(One, Down)", + "GameBoy KeyA Pad(One, Left)", + "GameBoy KeyD Pad(One, Right)", + "Gba Digit4 Pad(One, Select)", + "Gba Digit5 Pad(One, Start)", + "Gba KeyW Pad(One, Up)", + "Gba KeyS Pad(One, Down)", + "Gba KeyA Pad(One, Left)", + "Gba KeyD Pad(One, Right)", + "Snes KeyT Pad(One, A)", + "Snes KeyR Pad(One, B)", + "Snes KeyY Pad(One, X)", + "Snes KeyG Pad(One, Y)", + "Snes KeyQ Pad(One, L)", + "Snes KeyE Pad(One, R)", + "Snes Digit4 SuperScopeTurbo", + "Snes Digit4 Pad(One, Select)", + "Snes Digit5 SuperScopePause", + "Snes Digit5 Pad(One, Start)", + "Snes KeyW Pad(One, Up)", + "Snes KeyS Pad(One, Down)", + "Snes KeyA Pad(One, Left)", + "Snes KeyD Pad(One, Right)", + ]; + assert_eq!(one_shell_rows(Shells::Both), shared); + } + + /// A shell stops at the first row the device accepts, and a joypad, the Vs. coin and the + /// Vs. service button always accept, so a row after one of those would never be + /// reached; nor is the same row declared twice. + #[test] + fn every_row_a_shell_honours_is_reachable() { + for shell in [Shell::Desktop, Shell::Web] { + for b in bindings_of(shell) { + let rows: Vec<_> = bindings_for(shell, b.console, b.key).collect(); + let always = |r: &&&KeyBinding| { + matches!(r.input, Input::Pad(..) | Input::VsCoin | Input::VsService) + }; + if let Some(at) = rows.iter().position(|r| always(&r)) { + assert_eq!( + at, + rows.len() - 1, + "{shell:?}: a row of {:?} {:?} follows one that always accepts", + b.console, + b.key + ); + } + let same = rows.iter().filter(|r| r.input == b.input).count(); + assert_eq!(same, 1, "{shell:?} {} is declared twice", describe(b)); + } + } + } + + #[test] + fn pad_button_ids_are_each_consoles_button_ids() { + use crate::nes::input::{Button, button_from_id as nes_button}; + use crate::snes::input::{SnesButton, button_from_id as snes_button}; + assert_eq!(nes_button(PadButton::A.id()), Some(Button::A)); + assert_eq!(nes_button(PadButton::B.id()), Some(Button::B)); + assert_eq!(nes_button(PadButton::Select.id()), Some(Button::Select)); + assert_eq!(nes_button(PadButton::Right.id()), Some(Button::Right)); + assert_eq!(snes_button(PadButton::L.id()), Some(SnesButton::L)); + assert_eq!(snes_button(PadButton::R.id()), Some(SnesButton::R)); + assert_eq!(snes_button(PadButton::X.id()), Some(SnesButton::X)); + assert_eq!(snes_button(PadButton::Y.id()), Some(SnesButton::Y)); + } + + #[test] + fn web_keys_are_the_characters_a_us_layout_types() { + assert_eq!(Key::KeyW.web_key(), "w"); + assert_eq!(Key::Digit6.web_key(), "6"); + assert_eq!(Key::Minus.web_key(), "-"); + assert_eq!(Key::Comma.web_key(), ","); + assert_eq!(Key::ArrowUp.web_key(), "arrowup"); + } +} diff --git a/src/platform/mod.rs b/src/platform/mod.rs index ae935cdb9..e51381082 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -9,6 +9,7 @@ pub mod frame_benchmark; pub mod frontend_toasts; pub mod headless_capture; pub mod image_cache; +pub mod key_bindings; pub mod metadata; pub mod png_utils; pub mod ram_init; diff --git a/src/snes/input/mod.rs b/src/snes/input/mod.rs index 63b2c73fd..cdf30bff9 100644 --- a/src/snes/input/mod.rs +++ b/src/snes/input/mod.rs @@ -768,6 +768,13 @@ impl InputPorts { device.is_mouse().then(|| device.capture_state()) } + /// The state of whatever device is in port 1. + // Only the native keyboard tests read it. + #[cfg(all(test, feature = "native"))] + pub(crate) fn port1_state(&self) -> SnesControllerState { + self.port1.capture_state() + } + /// The state of the Super Scope on the given physical port (aim, buttons, Turbo), or /// `None` when that port has no Super Scope. pub fn superscope_state(&self, port: u8) -> Option { diff --git a/web/src/app.ts b/web/src/app.ts index 0cd915626..79734c2d6 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -4,6 +4,7 @@ import init, { WasmGba, WasmSnes, gamepad_init_toast_message, + key_binding_table, rom_extension_table, snes_firmware_chips_json, snes_dsp_firmware_is_genuine, @@ -64,8 +65,14 @@ import { configureEmulatorAudioSampleRate } from "./audio/audio_output_rate"; import { getPlaybackAudioSamples } from "./audio/playback_samples"; import { planFrame } from "./audio/frame_plan"; import { createSineScroller } from "./ui/sine_scroller"; -import { getKeyboardControllerTarget } from "./input/input_routing"; -import { applyVsCoinKey, gbaKeyboardButtonForEvent } from "./input/keyboard_mapping"; +import { + type KeyBindingRow, + type KeyBindingSink, + applyKeyBindings, + keyBindingsFor, + keyboardPorts, + parseKeyBindingTable, +} from "./input/key_bindings"; import { remapLegacySnesButtonId } from "./input/snes_button_mapping"; import { initTouchControls, isTouchDevice, isHandheldDevice } from "./input/touch_controls"; import { dispatchWebShortcutAction } from "./shortcuts/shortcut_actions"; @@ -78,7 +85,6 @@ import { import { createCrosshair } from "./display/crosshair"; import { createSuperScopeSession, - superScopeKeyAction, superScopeTurboMessage, type ClickCaptureSession, } from "./input/super_scope"; @@ -2812,35 +2818,6 @@ async function populateRomSelect() { // Set initial button states (all disabled until a ROM is loaded) updateEmulationButtons(); -// Keyboard input mappings for both controllers -// Controller 1: W=Up, S=Down, A=Left, D=Right, R=B, T=A, Y=X, G=Y, Q=L, E=R, 4=Select, 5=Start -const keyToButtonController1: Record = { - 'w': { button: 4, snesButton: 4, name: 'Up' }, // NES Up / SNES Up - 's': { button: 5, snesButton: 5, name: 'Down' }, // NES Down / SNES Down - 'a': { button: 6, snesButton: 6, name: 'Left' }, // NES Left / SNES Left - 'd': { button: 7, snesButton: 7, name: 'Right' }, // NES Right / SNES Right - 'r': { button: 0, snesButton: 0, name: 'B' }, // NES A fallback / SNES B - 't': { button: 1, snesButton: 8, name: 'A' }, // NES B fallback / SNES A - 'y': { snesButton: 9, name: 'X' }, // SNES X only - 'g': { snesButton: 1, name: 'Y' }, // SNES Y only - 'q': { snesButton: 10, name: 'L' }, // SNES L only - 'e': { snesButton: 11, name: 'R' }, // SNES R only - '4': { button: 2, snesButton: 2, name: 'Select' }, // NES Select / SNES Select - '5': { button: 3, snesButton: 3, name: 'Start' } // NES Start / SNES Start -}; - -// Controller 2: I=Up, K=Down, J=Left, L=Right, P=B, O=A, 9=Select, 0=Start -const keyToButtonController2: Record = { - 'i': { button: 4, name: 'Up' }, // Button 4 = Up - 'k': { button: 5, name: 'Down' }, // Button 5 = Down - 'j': { button: 6, name: 'Left' }, // Button 6 = Left - 'l': { button: 7, name: 'Right' }, // Button 7 = Right - 'p': { button: 1, name: 'B' }, // Button 1 = B - 'o': { button: 0, name: 'A' }, // Button 0 = A - '9': { button: 2, name: 'Select' }, // Button 2 = Select - '0': { button: 3, name: 'Start' } // Button 3 = Start -}; - // Track connected gamepads for routing let connectedGamepads: Gamepad[] = []; @@ -2907,33 +2884,52 @@ function toggleFilterAction() { updateFilterToggleButtonLabel(); } -function applyKeyboardMapping(event: KeyboardEvent, mapping: { button?: number; snesButton?: number; name: string } | undefined, controller: number, targets: number[], pressed: boolean) { - if (!mapping || !targets.includes(controller)) { - return; - } - event.preventDefault(); +let keyBindingTable: KeyBindingRow[] | null = null; - // Try SNES button mapping first when available. - if (nes && mapping.snesButton !== undefined) { - const handledAsSnes = nes.set_snes_button(controller, mapping.snesButton, pressed); - if (handledAsSnes) { - return; +/** + * The web rows of the key table both shells read (Rust `platform::key_bindings`). Read on the + * first key event with an emulator, by which time the wasm module is initialised. + */ +function keyBindings(): KeyBindingRow[] { + keyBindingTable ??= parseKeyBindingTable(key_binding_table()); + return keyBindingTable; +} + +/** What a key-binding row may do to the running console. */ +function keyBindingSink(active: ActiveEmulator): KeyBindingSink { + switch (active.kind) { + case "nes": { + const nesInst = active.inst; + return { + pad: (port, button, pressed) => applyJoypadButtonIfAllowed(nesInst, port, button, pressed), + snesPad: (port, button, pressed) => nesInst.set_snes_button(port, button, pressed), + vsCoin: () => nesInst.insert_vs_coin(0), + }; + } + case "snes": { + const snesInst = active.inst; + return { + pad: (port, button, pressed) => { + if (!shouldSuppressSnesJoypadInput(snesInst, port)) { + snesInst.set_button(port, button, pressed); + } + }, + scope: (action, pressed, repeat) => applySuperScopeKey(snesInst, action, pressed, repeat), + }; } - } - if (emulator?.kind === "snes" && mapping.snesButton !== undefined) { - if (!shouldSuppressSnesJoypadInput(emulator.inst, controller)) { - emulator.inst.set_button(controller, remapLegacySnesButtonId(mapping.snesButton), pressed); + default: { + const inst = active.inst; + return { pad: (port, button, pressed) => inst.set_button(port, button, pressed) }; } - return; } +} - if (mapping.button !== undefined) { - if (nes) { - applyJoypadButtonIfAllowed(nes, controller, mapping.button, pressed); - } else if (emulator) { - // GB: direct button routing (no mouse/zapper suppression needed). - emulator.inst.set_button(controller, mapping.button, pressed); - } +/** Apply the key table to a key event; the browser's default is prevented for a console key. */ +function applyKeyboardBindings(event: KeyboardEvent, active: ActiveEmulator, pressed: boolean) { + const ports = keyboardPorts(active.kind, connectedGamepads.length, nes?.is_four_score_enabled?.() ?? false); + const rows = keyBindingsFor(keyBindings(), active.kind, event.key); + if (applyKeyBindings(rows, keyBindingSink(active), ports, event, pressed)) { + event.preventDefault(); } } @@ -2997,21 +2993,19 @@ function releaseSuperScopeButtons() { } /** - * With a Super Scope connected the Select key (4) flips its Turbo switch and the Start key - * (5) is its Pause button; neither then reaches port 1, so one press never pauses twice. - * Returns true when the key was the scope's. + * A plugged Super Scope takes the table's scope keys (4 flips its Turbo switch, 5 is its + * Pause button) before they reach port 1, so one press never pauses twice. Returns false + * when no scope is plugged in. */ -function handleSuperScopeKey(event: KeyboardEvent, pressed: boolean): boolean { +function applySuperScopeKey(snesInst: WasmSnes, action: "turbo" | "pause", pressed: boolean, repeat: boolean): boolean { const port = superScopePort(); - const action = superScopeKeyAction(event.key.toLowerCase()); - if (emulator?.kind !== "snes" || port === null || action === null) { + if (port === null) { return false; } - event.preventDefault(); if (action === "pause") { - emulator.inst.set_superscope_pause(port, pressed); - } else if (pressed && !event.repeat) { - const on = emulator.inst.toggle_superscope_turbo(port); + snesInst.set_superscope_pause(port, pressed); + } else if (pressed && !repeat) { + const on = snesInst.toggle_superscope_turbo(port); if (on !== undefined) { toastOverlay.show(superScopeTurboMessage(on)); } @@ -3051,32 +3045,7 @@ async function handleKeyDown(event: KeyboardEvent) { return; } - if (emulator.kind === "gba") { - const button = gbaKeyboardButtonForEvent(event); - if (button !== null) { - event.preventDefault(); - emulator.inst.set_button(1, button, true); - } - return; - } - - if (handleSuperScopeKey(event, true)) { - return; - } - - if (nes && applyVsCoinKey(nes, event, true)) { - event.preventDefault(); - return; - } - - const key = event.key.toLowerCase(); - const targets = getKeyboardControllerTarget( - connectedGamepads.length, - nes?.is_four_score_enabled?.() ?? false - ); - - applyKeyboardMapping(event, keyToButtonController1[key], targets[0] ?? 1, targets, true); - applyKeyboardMapping(event, keyToButtonController2[key], targets[1] ?? 2, targets, true); + applyKeyboardBindings(event, emulator, true); } function handleKeyUp(event: KeyboardEvent) { @@ -3093,32 +3062,7 @@ function handleKeyUp(event: KeyboardEvent) { return; } - if (emulator.kind === "gba") { - const button = gbaKeyboardButtonForEvent(event); - if (button !== null) { - event.preventDefault(); - emulator.inst.set_button(1, button, false); - } - return; - } - - if (handleSuperScopeKey(event, false)) { - return; - } - - if (nes && applyVsCoinKey(nes, event, false)) { - event.preventDefault(); - return; - } - - const key = event.key.toLowerCase(); - const targets = getKeyboardControllerTarget( - connectedGamepads.length, - nes?.is_four_score_enabled?.() ?? false - ); - - applyKeyboardMapping(event, keyToButtonController1[key], targets[0] ?? 1, targets, false); - applyKeyboardMapping(event, keyToButtonController2[key], targets[1] ?? 2, targets, false); + applyKeyboardBindings(event, emulator, false); } document.addEventListener('keydown', handleKeyDown); diff --git a/web/src/input/key_bindings.test.ts b/web/src/input/key_bindings.test.ts new file mode 100644 index 000000000..dd35dd9f7 --- /dev/null +++ b/web/src/input/key_bindings.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; + +import { + type KeyBindingRow, + type KeyBindingSink, + applyKeyBindings, + keyBindingsFor, + keyboardPorts, + parseKeyBindingTable, +} from "./key_bindings"; + +/** A few rows shaped as the wasm binding `key_binding_table()` returns them. */ +const RAW = [ + { key: "w", console: "nes", input: "snesPad", player: 1, button: 4 }, + { key: "w", console: "nes", input: "pad", player: 1, button: 4 }, + { key: "o", console: "nes", input: "pad", player: 2, button: 0 }, + { key: "6", console: "nes", input: "vsCoin", player: 1, button: 0 }, + { key: "y", console: "nes", input: "snesPad", player: 1, button: 9 }, + { key: "g", console: "gba", input: "pad", player: 1, button: 0 }, + { key: "4", console: "snes", input: "scopeTurbo", player: 1, button: 0 }, + { key: "4", console: "snes", input: "pad", player: 1, button: 2 }, + { key: "5", console: "snes", input: "scopePause", player: 1, button: 0 }, + { key: "5", console: "snes", input: "pad", player: 1, button: 3 }, +]; + +const TABLE: KeyBindingRow[] = parseKeyBindingTable(RAW); + +/** A sink that records what reached the console. */ +function recorder(opts: { snesPadAccepts?: boolean; scopePlugged?: boolean } = {}) { + const calls: string[] = []; + const sink: KeyBindingSink = { + pad: (port, button, pressed) => { calls.push(`pad ${port} ${button} ${pressed}`); }, + snesPad: (port, button, pressed) => { + calls.push(`snesPad ${port} ${button} ${pressed}`); + return opts.snesPadAccepts ?? false; + }, + vsCoin: () => { calls.push("coin"); }, + scope: (action, pressed, repeat) => { + if (!opts.scopePlugged) { + return false; + } + calls.push(`scope ${action} ${pressed} ${repeat}`); + return true; + }, + }; + return { calls, sink }; +} + +function press(console: string, key: string, ports: number[], sink: KeyBindingSink, pressed = true, repeat = false) { + return applyKeyBindings(keyBindingsFor(TABLE, console, key), sink, ports, { repeat }, pressed); +} + +describe("parseKeyBindingTable", () => { + it("keeps every row and its order", () => { + expect(TABLE).toHaveLength(RAW.length); + expect(keyBindingsFor(TABLE, "nes", "w").map((row) => row.input)).toEqual(["snesPad", "pad"]); + }); + + it("refuses an input the page cannot apply, so no binding is silently dropped", () => { + expect(() => parseKeyBindingTable([{ key: "-", console: "nes", input: "vsService", player: 1, button: 0 }])) + .toThrow(/vsService/); + }); +}); + +describe("keyBindingsFor", () => { + it("matches the key case-insensitively, as typed with Shift", () => { + expect(keyBindingsFor(TABLE, "nes", "W")).toHaveLength(2); + }); + + it("has nothing for an unbound key or another console", () => { + expect(keyBindingsFor(TABLE, "nes", "x")).toEqual([]); + expect(keyBindingsFor(TABLE, "gb", "w")).toEqual([]); + }); +}); + +describe("applyKeyBindings", () => { + it("falls through to the joypad when no SNES pad takes the key", () => { + const { calls, sink } = recorder(); + expect(press("nes", "w", [1, 2], sink)).toBe(true); + expect(calls).toEqual(["snesPad 1 4 true", "pad 1 4 true"]); + }); + + it("stops at the SNES pad when one is plugged in", () => { + const { calls, sink } = recorder({ snesPadAccepts: true }); + press("nes", "w", [1, 2], sink); + expect(calls).toEqual(["snesPad 1 4 true"]); + }); + + it("routes player 2 keys to the second keyboard port", () => { + const { calls, sink } = recorder(); + press("nes", "o", [3, 4], sink, false); + expect(calls).toEqual(["pad 4 0 false"]); + }); + + it("does nothing, and is not handled, for a player with no keyboard port", () => { + const { calls, sink } = recorder(); + expect(press("nes", "o", [], sink)).toBe(false); + expect(press("nes", "w", [], sink)).toBe(false); + expect(calls).toEqual([]); + }); + + it("inserts one Vs. coin on the press, whatever the ports, and none on repeat or release (nr-use)", () => { + const { calls, sink } = recorder(); + expect(press("nes", "6", [], sink)).toBe(true); + expect(press("nes", "6", [], sink, true, true)).toBe(true); + expect(press("nes", "6", [], sink, false)).toBe(true); + expect(calls).toEqual(["coin"]); + }); + + it("gives 4 and 5 to a plugged Super Scope before Select and Start", () => { + const { calls, sink } = recorder({ scopePlugged: true }); + press("snes", "4", [1, 2], sink, true, false); + press("snes", "5", [1, 2], sink, false); + expect(calls).toEqual(["scope turbo true false", "scope pause false false"]); + }); + + it("keeps 4 and 5 as Select and Start without a scope", () => { + const { calls, sink } = recorder(); + press("snes", "4", [1, 2], sink); + press("snes", "5", [1, 2], sink); + expect(calls).toEqual(["pad 1 2 true", "pad 1 3 true"]); + }); + + it("is not handled for a key with no rows", () => { + const { calls, sink } = recorder(); + expect(press("gba", "q", [1], sink)).toBe(false); + expect(calls).toEqual([]); + }); +}); + +describe("an SNES-only key on an NES joypad", () => { + it("does nothing to the joypad yet is still the console's key, as before the table", () => { + const { calls, sink } = recorder(); + expect(press("nes", "y", [1, 2], sink)).toBe(true); + expect(calls).toEqual(["snesPad 1 9 true"]); + }); +}); + +describe("keyboardPorts", () => { + it("always drives the GBA's one joypad on port 1, whatever gamepads are connected", () => { + expect(keyboardPorts("gba", 0, false)).toEqual([1]); + expect(keyboardPorts("gba", 1, false)).toEqual([1]); + expect(keyboardPorts("gba", 2, false)).toEqual([1]); + }); + + it("leaves the first ports to gamepads on the other consoles", () => { + expect(keyboardPorts("nes", 0, false)).toEqual([1, 2]); + expect(keyboardPorts("nes", 1, false)).toEqual([2]); + expect(keyboardPorts("snes", 2, false)).toEqual([]); + expect(keyboardPorts("nes", 1, true)).toEqual([2, 3]); + }); +}); diff --git a/web/src/input/key_bindings.ts b/web/src/input/key_bindings.ts new file mode 100644 index 000000000..bacb2e2cc --- /dev/null +++ b/web/src/input/key_bindings.ts @@ -0,0 +1,122 @@ +/** + * The web shell's keyboard bindings. They are not declared here: every row comes from the + * wasm binding `key_binding_table()`, the web rows of Rust's `platform::key_bindings`, which + * the desktop shell reads too. This module only applies the rows. + * + * For one key on one console the rows are tried in order, stopping at the first one the + * plugged device takes: an SNES pad before the NES joypad, a Super Scope before Select/Start. + */ + +import { getKeyboardControllerTarget } from "./input_routing"; + +export type KeyBindingInput = "pad" | "snesPad" | "vsCoin" | "scopeTurbo" | "scopePause"; + +export interface KeyBindingRow { + /** The lower-cased `KeyboardEvent.key`. */ + key: string; + /** The web console kind: "nes", "gb", "gba" or "snes". */ + console: string; + input: KeyBindingInput; + /** The keyboard player, routed to the first or second keyboard port. */ + player: 1 | 2; + /** The id the wasm setter for `input` takes. */ + button: number; +} + +/** The console calls one row can make; each optional one exists only where the console has it. */ +export interface KeyBindingSink { + pad(port: number, button: number, pressed: boolean): void; + /** An SNES pad in an NES port; false when the port holds none. */ + snesPad?(port: number, button: number, pressed: boolean): boolean; + vsCoin?(): void; + /** A plugged Super Scope; false when none is. */ + scope?(action: "turbo" | "pause", pressed: boolean, repeat: boolean): boolean; +} + +const INPUTS: readonly KeyBindingInput[] = ["pad", "snesPad", "vsCoin", "scopeTurbo", "scopePause"]; + +/** + * Check the rows `key_binding_table()` returned. An input this page cannot apply throws + * rather than being dropped: a dropped row is a binding missing on the web, which is what + * this table exists to prevent. + */ +export function parseKeyBindingTable(raw: readonly unknown[]): KeyBindingRow[] { + return raw.map((value) => { + const row = value as KeyBindingRow; + if (!INPUTS.includes(row.input)) { + throw new Error(`The web shell cannot apply the key binding input "${String(row.input)}"`); + } + return { key: row.key, console: row.console, input: row.input, player: row.player, button: row.button }; + }); +} + +/** The rows bound to `key` (a `KeyboardEvent.key`) on `console`, in the order they are tried. */ +export function keyBindingsFor(table: readonly KeyBindingRow[], console: string, key: string): KeyBindingRow[] { + const typed = key.toLowerCase(); + return table.filter((row) => row.console === console && row.key === typed); +} + +/** + * The ports the keyboard drives on `console`, player 1's first. The GBA has one joypad, which + * the keyboard always drives; elsewhere connected gamepads take the first ports. + */ +export function keyboardPorts(console: string, gamepadCount: number, fourScoreEnabled: boolean): number[] { + return console === "gba" ? [1] : getKeyboardControllerTarget(gamepadCount, fourScoreEnabled); +} + +/** + * Apply `rows` for one key event. `ports` are the keyboard's ports (player 1 first). Returns + * true when the key was the console's, so the caller prevents the browser's default: also + * when it reached a port whose device took none of its rows (an SNES-only key on a joypad). + */ +export function applyKeyBindings( + rows: readonly KeyBindingRow[], + sink: KeyBindingSink, + ports: readonly number[], + event: Pick, + pressed: boolean, +): boolean { + let reachedPort = false; + for (const row of rows) { + switch (row.input) { + case "vsCoin": + // A press inserts one coin and the core times the pulse, so repeats and the + // release insert nothing: a coin line held ten frames reads as jammed (nr-use). + if (pressed && !event.repeat) { + sink.vsCoin?.(); + } + return true; + case "scopeTurbo": + case "scopePause": + if (sink.scope?.(row.input === "scopeTurbo" ? "turbo" : "pause", pressed, event.repeat)) { + return true; + } + break; + case "snesPad": { + const port = portOf(row, ports); + if (port === null) { + return false; + } + reachedPort = true; + if (sink.snesPad?.(port, row.button, pressed)) { + return true; + } + break; + } + case "pad": { + const port = portOf(row, ports); + if (port === null) { + return false; + } + sink.pad(port, row.button, pressed); + return true; + } + } + } + return reachedPort; +} + +function portOf(row: KeyBindingRow, ports: readonly number[]): number | null { + const port = row.player === 1 ? (ports[0] ?? 1) : (ports[1] ?? 2); + return ports.includes(port) ? port : null; +} diff --git a/web/src/input/keyboard_mapping.test.ts b/web/src/input/keyboard_mapping.test.ts deleted file mode 100644 index 387b1bfed..000000000 --- a/web/src/input/keyboard_mapping.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - applyVsCoinKey, - gbaKeyboardButtonForEvent, - snesKeyboardButtonForEvent -} from "./keyboard_mapping"; - -function event(key: string, code = key): Pick { - return { key, code }; -} - -it("maps GBA keyboard face buttons", () => { - expect(gbaKeyboardButtonForEvent(event("g", "KeyG"))).toBe(0); - expect(gbaKeyboardButtonForEvent(event("f", "KeyF"))).toBe(1); -}); - -it("maps GBA keyboard system buttons", () => { - expect(gbaKeyboardButtonForEvent(event("5", "Digit5"))).toBe(3); - expect(gbaKeyboardButtonForEvent(event("4", "Digit4"))).toBe(2); -}); - -it("maps GBA keyboard d-pad buttons", () => { - expect(gbaKeyboardButtonForEvent(event("w", "KeyW"))).toBe(4); - expect(gbaKeyboardButtonForEvent(event("s", "KeyS"))).toBe(5); - expect(gbaKeyboardButtonForEvent(event("a", "KeyA"))).toBe(6); - expect(gbaKeyboardButtonForEvent(event("d", "KeyD"))).toBe(7); -}); - -it("maps GBA keyboard shoulder buttons", () => { - expect(gbaKeyboardButtonForEvent(event("v", "KeyV"))).toBe(8); - expect(gbaKeyboardButtonForEvent(event("b", "KeyB"))).toBe(9); -}); - -it("ignores keys outside the GBA keyboard mapping", () => { - expect(gbaKeyboardButtonForEvent(event("q", "KeyQ"))).toBeNull(); - expect(gbaKeyboardButtonForEvent(event(" ", "Space"))).toBeNull(); -}); - -it("maps SNES keyboard face and shoulder buttons", () => { - expect(snesKeyboardButtonForEvent(event("r", "KeyR"))).toBe(0); // B - expect(snesKeyboardButtonForEvent(event("t", "KeyT"))).toBe(8); // A - expect(snesKeyboardButtonForEvent(event("y", "KeyY"))).toBe(9); // X - expect(snesKeyboardButtonForEvent(event("g", "KeyG"))).toBe(1); // Y - expect(snesKeyboardButtonForEvent(event("q", "KeyQ"))).toBe(10); // L - expect(snesKeyboardButtonForEvent(event("e", "KeyE"))).toBe(11); // R -}); - -it("maps SNES keyboard system and d-pad buttons", () => { - expect(snesKeyboardButtonForEvent(event("4", "Digit4"))).toBe(2); // Select - expect(snesKeyboardButtonForEvent(event("5", "Digit5"))).toBe(3); // Start - expect(snesKeyboardButtonForEvent(event("w", "KeyW"))).toBe(4); - expect(snesKeyboardButtonForEvent(event("s", "KeyS"))).toBe(5); - expect(snesKeyboardButtonForEvent(event("a", "KeyA"))).toBe(6); - expect(snesKeyboardButtonForEvent(event("d", "KeyD"))).toBe(7); -}); - -it("ignores unmapped keys for SNES keyboard mapping", () => { - expect(snesKeyboardButtonForEvent(event("f", "KeyF"))).toBeNull(); - expect(snesKeyboardButtonForEvent(event(" ", "Space"))).toBeNull(); -}); - -describe("Vs. System coin key (nr-use)", () => { - function coinTarget() { - const slots: number[] = []; - return { slots, insert_vs_coin: (slot: number) => { slots.push(slot); } }; - } - - function key(k: string, repeat = false): Pick { - return { key: k, repeat }; - } - - it("inserts one coin into slot 1 when 6 is pressed, as on desktop", () => { - const nes = coinTarget(); - expect(applyVsCoinKey(nes, key("6"), true)).toBe(true); - expect(nes.slots).toEqual([0]); - }); - - it("inserts no further coin while 6 is held and repeats, nor on release", () => { - const nes = coinTarget(); - applyVsCoinKey(nes, key("6"), true); - expect(applyVsCoinKey(nes, key("6", true), true)).toBe(true); - expect(applyVsCoinKey(nes, key("6"), false)).toBe(true); - expect(nes.slots).toEqual([0]); - }); - - it("leaves every other key alone", () => { - const nes = coinTarget(); - expect(applyVsCoinKey(nes, key("5"), true)).toBe(false); - expect(nes.slots).toEqual([]); - }); -}); diff --git a/web/src/input/keyboard_mapping.ts b/web/src/input/keyboard_mapping.ts deleted file mode 100644 index a64083041..000000000 --- a/web/src/input/keyboard_mapping.ts +++ /dev/null @@ -1,79 +0,0 @@ -export function gbaKeyboardButtonForEvent(event: Pick): number | null { - const key = event.key.toLowerCase(); - switch (key) { - case "g": - return 0; - case "f": - return 1; - case "4": - return 2; - case "5": - return 3; - case "w": - return 4; - case "s": - return 5; - case "a": - return 6; - case "d": - return 7; - case "v": - return 8; - case "b": - return 9; - default: - return null; - } -} - -export function snesKeyboardButtonForEvent(event: Pick): number | null { - const key = event.key.toLowerCase(); - switch (key) { - case "r": - return 0; // B - case "g": - return 1; // Y - case "4": - return 2; // Select - case "5": - return 3; // Start - case "w": - return 4; - case "s": - return 5; - case "a": - return 6; - case "d": - return 7; - case "t": - return 8; // A - case "y": - return 9; // X - case "q": - return 10; // L - case "e": - return 11; // R - default: - return null; - } -} - -/** - * Key 6 inserts a Vs. System coin into slot 1, as on desktop. A press inserts one coin and - * the core times the coin pulse, so key repeats and the release insert nothing: a coin line - * held ten frames or more reads as a jammed coin to Vs. Duck Hunt. Returns true when the - * key was the coin key. - */ -export function applyVsCoinKey( - nes: { insert_vs_coin(slot: number): void }, - event: Pick, - pressed: boolean -): boolean { - if (event.key !== "6") { - return false; - } - if (pressed && !event.repeat) { - nes.insert_vs_coin(0); - } - return true; -} diff --git a/web/src/input/super_scope.test.ts b/web/src/input/super_scope.test.ts index 00d637e7a..13430037f 100644 --- a/web/src/input/super_scope.test.ts +++ b/web/src/input/super_scope.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import { SUPER_SCOPE_MOUSE_RELEASED, createSuperScopeSession, - superScopeKeyAction, superScopeTurboMessage, } from "./super_scope"; @@ -92,11 +91,3 @@ describe("createSuperScopeSession", () => { expect(session.captured()).toBe(false); }); }); - -describe("superScopeKeyAction", () => { - it("makes the Select key the Turbo switch and the Start key Pause", () => { - expect(superScopeKeyAction("4")).toBe("turbo"); - expect(superScopeKeyAction("5")).toBe("pause"); - expect(superScopeKeyAction("w")).toBeNull(); - }); -}); diff --git a/web/src/input/super_scope.ts b/web/src/input/super_scope.ts index c0bf3af9b..180d29a22 100644 --- a/web/src/input/super_scope.ts +++ b/web/src/input/super_scope.ts @@ -14,17 +14,6 @@ export function superScopeTurboMessage(on: boolean): string { return on ? "Turbo on" : "Turbo off"; } -/** The Select key (4) is the Turbo switch and the Start key (5) is Pause. */ -export function superScopeKeyAction(key: string): "turbo" | "pause" | null { - if (key === "4") { - return "turbo"; - } - if (key === "5") { - return "pause"; - } - return null; -} - const LEFT_BUTTON = 0; const RIGHT_BUTTON = 2;