Skip to content
5 changes: 3 additions & 2 deletions architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<stem>_<N>.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. |
Expand Down Expand Up @@ -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. |
Expand Down Expand Up @@ -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 `<dialog>` 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`. |
Expand Down
24 changes: 24 additions & 0 deletions docs/retrospectives/nr-tlf.md
Original file line number Diff line number Diff line change
@@ -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.
117 changes: 89 additions & 28 deletions src/frontends/native/keyboard/console_keyboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`].
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -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()
Expand All @@ -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,
}
}

Expand All @@ -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<u8>,
) -> KeyOutcome {
// Generic hotkeys that work for any system.
if app_state.modifiers.control_key() {
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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 = [
Expand Down
Loading
Loading