From ebdfd00f8c1c08da124e12b23c1651c2bf5c122d Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 15 Jul 2026 00:29:47 -0400 Subject: [PATCH 1/2] feat(frontend): live host-input capture for Mouse/Super Scope New crate::peripherals module: egui::Context's pointer state now drives EmuCore::set_mouse (delta + left/right buttons) and set_superscope (absolute aim mapped through Gfx::letterbox_scale into SNES pixel space, trigger/cursor/turbo on left/right/middle mouse buttons) once per frame, on both the synchronous and emu-thread render paths. Mirrors rustysnes-libretro's own already-verified poll_port_input translation. Portable to wasm on purpose - no target_arch gate, since both the pointer API and the EmuCore calls are already platform-agnostic. 5 real unit tests cover the pure coordinate-mapping math directly. Also found and corrected a real, separate doc inaccuracy while scoping this: docs/frontend.md claimed controller port 1 has "keyboard + gilrs gamepad" input, but gilrs::Gilrs is never actually instantiated anywhere in this crate - port 1 is keyboard-only today. This is also why Super Multitap sub-pad host input stays unwired here: it needs that same, separate, larger prerequisite (a live Gilrs instance + event polling), not a small addition on top of this fix. Phase A.2 of the new UI/UX-parity ladder. --- CHANGELOG.md | 26 +++ crates/rustysnes-frontend/src/app.rs | 25 ++- crates/rustysnes-frontend/src/gfx.rs | 7 +- crates/rustysnes-frontend/src/lib.rs | 3 + crates/rustysnes-frontend/src/peripherals.rs | 217 +++++++++++++++++++ docs/frontend.md | 47 ++-- 6 files changed, 303 insertions(+), 22 deletions(-) create mode 100644 crates/rustysnes-frontend/src/peripherals.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index cc069acd..9268d0b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 page" section for the full disposition and `to-dos/ROADMAP.md`/the approved UI/UX-parity plan for what fixing those three would actually require. +### Added + +- **Live host-input capture for Mouse/Super Scope** (Phase A.2 of the UI/UX-parity ladder, new + `crate::peripherals`). `config.port2_peripheral`'s Settings selector already wired the emulated + hardware correctly since `v0.9.0`; now `egui::Context`'s pointer state actually drives it once + per frame — `EmuCore::set_mouse` from pointer delta + left/right buttons, `EmuCore::set_superscope` + from an absolute aim position mapped through the present path's own letterbox transform + (`Gfx::letterbox_scale`, exposed `pub(crate)` for this reuse rather than re-derived) into SNES + pixel space, with trigger/cursor/turbo on left/right/middle mouse buttons. Mirrors + `rustysnes-libretro`'s own already-verified `poll_port_input` translation. Portable to wasm on + purpose (no `target_arch` gate) — both the pointer API and the `EmuCore` calls are already + platform-agnostic, so the hosted demo gets this too. 5 real unit tests cover the pure + coordinate-mapping math directly (centered/corner/pillarboxed/off-window cases), not just + "compiles." + +### Fixed + +- **A real, separate finding surfaced while scoping the above**: `docs/frontend.md`'s own "Status" + line claimed controller port 1 had "keyboard + gilrs gamepad" input, but `gilrs::Gilrs` is never + actually instantiated anywhere in `rustysnes-frontend` — confirmed via `input::gamepad_button` + (the gilrs-button-name mapping function) having zero callers. Port 1 is keyboard-only today. + Corrected the doc; wiring real gamepad support is a genuinely separate, larger prerequisite + (a live `Gilrs` instance + per-frame event polling), not something silently expanded into this + fix — it's also what blocks Super Multitap sub-pad 1-3 host input specifically, tracked + separately in the UI/UX-parity plan's backlog. + ## [1.19.0] "Afterburner" - 2026-07-15 Fifteenth release of the RustyNES-parity roadmap: an optional PGO/BOLT pipeline for the diff --git a/crates/rustysnes-frontend/src/app.rs b/crates/rustysnes-frontend/src/app.rs index bcafccb5..953ea5b3 100644 --- a/crates/rustysnes-frontend/src/app.rs +++ b/crates/rustysnes-frontend/src/app.rs @@ -1013,12 +1013,17 @@ impl App { // Controller port 2 peripheral selection (`v0.9.0`, Phase 7 niche // peripherals) — same "just re-sync unconditionally, once per real frame" // pattern as cheats/watchpoints above; cheap (one enum-tag write) when - // unchanged. Host-input capture for the non-Gamepad devices (a real mouse - // pointer driving Super Scope aim / Mouse deltas, extra gamepads for - // Multitap sub-pads) is a follow-up frontend task — this wires the CORE's - // protocol correctly but doesn't yet feed it live host input - // (`docs/frontend.md` §Peripherals). + // unchanged. emu.set_port_device(1, config.port2_peripheral.to_core()); + // Host-input capture for Mouse/Super Scope (`v1.20.0`, closing the gap the + // comment above used to describe) — Multitap sub-pads still have no host + // input source; see `crate::peripherals`'s own module doc for why. + crate::peripherals::sync( + &active.egui_ctx, + &active.gfx, + &mut emu, + config.port2_peripheral.into(), + ); // PC breakpoints (`v0.9.0`, T-81-001 PR B) — same re-sync pattern as above; // a no-op branch in `EmuCore::run_frame` when the list is empty. emu.set_breakpoints(&active.breakpoints); @@ -1089,6 +1094,16 @@ impl App { #[cfg(feature = "debug-hooks")] crate::watchpoints::sync(&active.watchpoints, &mut emu.system_mut().bus); emu.set_port_device(1, config.port2_peripheral.to_core()); + // Host-input capture for Mouse/Super Scope (`v1.20.0`) — same "re-sync once per + // present" cadence the port-device selection above already uses in this threaded + // build; live pointer state is read as often as we redraw, matching how + // `active.pad1`'s own keyboard latch feeds the thread. + crate::peripherals::sync( + &active.egui_ctx, + &active.gfx, + &mut emu, + config.port2_peripheral.into(), + ); emu.set_breakpoints(&active.breakpoints); emu.set_voice_mutes(config.audio.voice_mutes); diff --git a/crates/rustysnes-frontend/src/gfx.rs b/crates/rustysnes-frontend/src/gfx.rs index fdbe54a3..73579b04 100644 --- a/crates/rustysnes-frontend/src/gfx.rs +++ b/crates/rustysnes-frontend/src/gfx.rs @@ -867,8 +867,13 @@ impl Gfx { /// clip-space position so the 4:3 SNES display fits inside the current surface regardless of /// window shape — shared by [`Self::blit`] and [`Self::present`]'s filter passes (`v1.2.0`; /// extracted from `blit`'s own inline math, a pure behavior-preserving refactor). + /// + /// `pub(crate)` since `v1.20.0`: `crate::peripherals` reuses this SAME clip-space fraction to + /// map host pointer coordinates into SNES pixel space for Mouse/Super Scope input, rather than + /// re-deriving the letterbox math a second time and risking the two implementations drifting + /// apart. #[allow(clippy::cast_precision_loss)] - fn letterbox_scale(&self) -> (f32, f32) { + pub(crate) fn letterbox_scale(&self) -> (f32, f32) { let win_w = self.config.width.max(1) as f32; let win_h = self.config.height.max(1) as f32; let win_aspect = win_w / win_h; diff --git a/crates/rustysnes-frontend/src/lib.rs b/crates/rustysnes-frontend/src/lib.rs index ce8ca124..15796f9a 100644 --- a/crates/rustysnes-frontend/src/lib.rs +++ b/crates/rustysnes-frontend/src/lib.rs @@ -68,6 +68,9 @@ pub mod input; #[cfg(all(feature = "netplay", not(target_arch = "wasm32")))] pub mod netplay; pub(crate) mod pacing; +// Host-input capture for Mouse/Super Scope (`v1.20.0`) — no target_arch/feature gate, matching +// `set_port_device`'s own unconditional compilation; see the module's own doc for why. +pub mod peripherals; pub mod rewind; pub mod save_states; pub mod ui_shell; diff --git a/crates/rustysnes-frontend/src/peripherals.rs b/crates/rustysnes-frontend/src/peripherals.rs new file mode 100644 index 00000000..185b5f94 --- /dev/null +++ b/crates/rustysnes-frontend/src/peripherals.rs @@ -0,0 +1,217 @@ +//! Host-input capture for controller port 2's non-`Gamepad` peripherals (`v1.20.0`). +//! +//! Closes the gap `docs/frontend.md`'s "Peripherals" section named: `config.port2_peripheral` +//! already selects the emulated hardware correctly (`app.rs`'s `set_port_device` re-sync), but +//! nothing fed a real host mouse pointer to [`EmuCore::set_mouse`]/[`EmuCore::set_superscope`] +//! until now. +//! +//! Reuses `Gfx::letterbox_scale` — the SAME clip-space fraction [`Gfx::blit`]/[`Gfx::present`] +//! use to fit the 4:3 SNES image inside the window — rather than re-deriving the letterbox math a +//! second time and risking the two implementations drifting apart. Mirrors +//! `rustysnes-libretro`'s own `poll_port_input` (`crates/rustysnes-libretro/src/lib.rs`): the +//! button-bit mapping and the `(-20, -20)` off-screen sentinel for Super Scope match that crate's +//! existing, already-verified translation exactly, just fed from `egui::Context`'s pointer state +//! instead of libretro's `get_input_state` callback. +//! +//! Portable to wasm on purpose (no `target_arch` gate) — egui's pointer API and +//! [`EmuCore::set_mouse`]/[`EmuCore::set_superscope`] are both already platform-agnostic, so this +//! closes the same gap in the hosted demo too, not just the native build. +//! +//! Super Multitap sub-pads 1-3 are NOT wired here — real host gamepad polling would be the input +//! source for those, but `gilrs::Gilrs` is never actually instantiated anywhere in this crate +//! today (confirmed: `input::gamepad_button` is a real, correct button-name mapping with zero +//! callers) — even controller port 1's OWN gamepad support is unwired, keyboard-only. Wiring +//! Multitap host input is blocked on that separate, larger prerequisite, not a small addition on +//! top of this module; see `docs/frontend.md`'s "Peripherals" section for the honest disposition. + +use rustysnes_core::controller::scope; + +use crate::emu::EmuCore; +use crate::gfx::Gfx; + +/// Feed one frame's worth of host pointer input to `emu`'s port-2 peripheral. +/// +/// A no-op for Gamepad/Multitap — Multitap's own host-input source is a separate, unimplemented +/// prerequisite, see the module doc. +pub fn sync(egui_ctx: &egui::Context, gfx: &Gfx, emu: &mut EmuCore, device: PeripheralInputKind) { + match device { + PeripheralInputKind::Mouse => sync_mouse(egui_ctx, emu), + PeripheralInputKind::SuperScope => sync_superscope(egui_ctx, gfx, emu), + PeripheralInputKind::Other => {} + } +} + +/// Which port-2 peripherals this module actually captures host input for. +/// +/// Deliberately NOT [`rustysnes_core::controller::PortDevice`] itself, so a future core +/// peripheral addition can't silently fall through this `match` unnoticed (a new `PortDevice` +/// variant requires an explicit decision here, via [`crate::config::PeripheralKind`]'s own +/// exhaustive `match` in `to_core`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PeripheralInputKind { + /// SNES Mouse — relative pointer motion + left/right buttons. + Mouse, + /// Super Scope light gun — absolute pointer position + trigger/cursor/turbo buttons. + SuperScope, + /// Gamepad or Multitap — no host pointer input to capture. + Other, +} + +impl From for PeripheralInputKind { + fn from(kind: crate::config::PeripheralKind) -> Self { + match kind { + crate::config::PeripheralKind::Mouse => Self::Mouse, + crate::config::PeripheralKind::SuperScope => Self::SuperScope, + crate::config::PeripheralKind::Gamepad | crate::config::PeripheralKind::Multitap => { + Self::Other + } + } + } +} + +/// SNES Mouse: relative motion since last frame (real hardware reports deltas, not an absolute +/// position) plus the primary/secondary button state. `egui::PointerState::delta` already +/// accumulates exactly that between egui passes, so no manual last-position tracking is needed. +fn sync_mouse(egui_ctx: &egui::Context, emu: &mut EmuCore) { + let (delta, left, right) = egui_ctx.input(|i| { + ( + i.pointer.delta(), + i.pointer.button_down(egui::PointerButton::Primary), + i.pointer.button_down(egui::PointerButton::Secondary), + ) + }); + #[allow(clippy::cast_possible_truncation)] + emu.set_mouse( + 0, + delta.x.round() as i32, + delta.y.round() as i32, + left, + right, + ); +} + +/// Super Scope: absolute aim position (mapped from window pixels through the letterbox transform +/// into SNES `0..fb_w`/`0..fb_h` pixel space) plus trigger/cursor/turbo. No fourth mouse button +/// exists to drive `scope::PAUSE`, so it stays unset here — matches this module's own "host +/// mouse input only" scope; a future keyboard-bound Pause is a natural, separate follow-up. +fn sync_superscope(egui_ctx: &egui::Context, gfx: &Gfx, emu: &mut EmuCore) { + let (pointer, left, right, middle) = egui_ctx.input(|i| { + ( + i.pointer.latest_pos(), + i.pointer.button_down(egui::PointerButton::Primary), + i.pointer.button_down(egui::PointerButton::Secondary), + i.pointer.button_down(egui::PointerButton::Middle), + ) + }); + let fb_dims = emu.fb_dims(); + // Matches `SuperScopeState::set_input`'s own `-16` fringe convention (one step further out to + // stay unambiguously off-screen after its `-16..=dimension+16` clamp) — same sentinel + // `rustysnes-libretro`'s own `poll_port_input` uses for an off-screen lightgun read. + let (x, y) = pointer + .and_then(|p| pointer_to_snes_pixel(gfx, (p.x, p.y), fb_dims)) + .unwrap_or((-20, -20)); + let mut buttons = 0u8; + if left { + buttons |= scope::TRIGGER; + } + if right { + buttons |= scope::CURSOR; + } + if middle { + buttons |= scope::TURBO; + } + emu.set_superscope(0, x, y, buttons); +} + +/// Map a host pointer position (window/surface pixels) through `Gfx::letterbox_scale` into +/// SNES pixel space `(0..fb_w, 0..fb_h)`, or `None` if the pointer falls outside the letterboxed +/// game image (over the black bars, or off-window). Thin wrapper around +/// [`pointer_to_snes_pixel_pure`] — pulls the live window size + letterbox scale out of `gfx`, +/// which needs a real wgpu device to construct and so can't be exercised directly by a unit test +/// (`gfx.rs`'s own `letterbox_scale_matches_known_cases` test hits the same constraint). +fn pointer_to_snes_pixel( + gfx: &Gfx, + pointer_px: (f32, f32), + fb_dims: (u32, u32), +) -> Option<(i32, i32)> { + pointer_to_snes_pixel_pure( + (gfx.config.width, gfx.config.height), + gfx.letterbox_scale(), + pointer_px, + fb_dims, + ) +} + +/// The actual coordinate-mapping math, `Gfx`-free so it's directly unit-testable: window pixels +/// -> the letterboxed game image's on-screen rect -> normalized `0.0..1.0` -> SNES pixel space. +/// `None` when `pointer_px` falls outside that rect (over the black bars, or off-window). +#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)] +fn pointer_to_snes_pixel_pure( + win_dims: (u32, u32), + letterbox_scale: (f32, f32), + pointer_px: (f32, f32), + fb_dims: (u32, u32), +) -> Option<(i32, i32)> { + let win_w = win_dims.0.max(1) as f32; + let win_h = win_dims.1.max(1) as f32; + let (scale_x, scale_y) = letterbox_scale; + let image_w = win_w * scale_x; + let image_h = win_h * scale_y; + let origin_x = (win_w - image_w) / 2.0; + let origin_y = (win_h - image_h) / 2.0; + let (px, py) = pointer_px; + if px < origin_x || px >= origin_x + image_w || py < origin_y || py >= origin_y + image_h { + return None; + } + let norm_x = (px - origin_x) / image_w; + let norm_y = (py - origin_y) / image_h; + Some(( + (norm_x * fb_dims.0 as f32) as i32, + (norm_y * fb_dims.1 as f32) as i32, + )) +} + +#[cfg(test)] +mod tests { + use super::pointer_to_snes_pixel_pure; + + const SNES_FB: (u32, u32) = (256, 224); + + #[test] + fn center_of_a_4_3_window_maps_to_center_of_the_framebuffer() { + // No letterboxing (scale (1.0, 1.0)) on an exact-4:3 800x600 window. + let got = pointer_to_snes_pixel_pure((800, 600), (1.0, 1.0), (400.0, 300.0), SNES_FB); + assert_eq!(got, Some((128, 112))); + } + + #[test] + fn top_left_corner_maps_to_origin() { + let got = pointer_to_snes_pixel_pure((800, 600), (1.0, 1.0), (0.0, 0.0), SNES_FB); + assert_eq!(got, Some((0, 0))); + } + + #[test] + fn pillarboxed_window_rejects_a_pointer_over_the_black_bars() { + // A 1920x1080 (16:9) window pillarboxed to 4:3 has scale_x < 1.0 -- a pointer pinned to + // the far-left window edge lands in the left black bar, outside the letterboxed image. + let (scale_x, scale_y) = ((4.0 / 3.0) / (1920.0 / 1080.0), 1.0); + let got = + pointer_to_snes_pixel_pure((1920, 1080), (scale_x, scale_y), (0.0, 540.0), SNES_FB); + assert_eq!(got, None); + } + + #[test] + fn pillarboxed_window_accepts_a_pointer_inside_the_letterboxed_image() { + let (scale_x, scale_y) = ((4.0 / 3.0) / (1920.0 / 1080.0), 1.0); + // Dead center of a pillarboxed window is still dead center of the game image. + let got = + pointer_to_snes_pixel_pure((1920, 1080), (scale_x, scale_y), (960.0, 540.0), SNES_FB); + assert_eq!(got, Some((128, 112))); + } + + #[test] + fn a_pointer_past_the_window_edge_is_rejected() { + let got = pointer_to_snes_pixel_pure((800, 600), (1.0, 1.0), (800.0, 300.0), SNES_FB); + assert_eq!(got, None); + } +} diff --git a/docs/frontend.md b/docs/frontend.md index 6d3db9ff..bd1641e6 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -11,8 +11,12 @@ egui runs every frame. **Status (Phase 5): playable native.** A real commercial ROM boots in a window with picture (PPU BGR555 → RGBA8, aspect-correct 4:3 sub-rect letterbox blit), sound (S-DSP 32 kHz FIFO → -producer-side DRC-paced linear resampler → lock-free ring → cpal stereo), and control (keyboard + -gilrs gamepad → `Bus::set_joypad`). ROM load auto-resolves coprocessor firmware + `.srm` SRAM; +producer-side DRC-paced linear resampler → lock-free ring → cpal stereo), and control (keyboard → +`Bus::set_joypad`). **Correction (`v1.20.0`):** this line previously also claimed "gilrs gamepad" — +found false while scoping the Peripherals fix below: `gilrs::Gilrs` is never actually instantiated +anywhere in this crate, so controller port 1 is keyboard-only today; see "Peripherals" further +down for the honest disposition and why it's a genuinely separate, larger fix than it looks. +ROM load auto-resolves coprocessor firmware + `.srm` SRAM; Reset / Power-Cycle / Pause are wired. The dependency stack tracks the latest mutually-compatible tier: egui/egui-wgpu/egui-winit **0.35**, wgpu **29**, winit **0.30** (winit 0.31 is beta-only and egui-winit 0.35 pins to 0.30 — winit is the crate gating us off 0.31). Native + `wasm32` both @@ -456,20 +460,31 @@ occupies a port (default: `Gamepad`, byte-identical to every prior release); `Bu re-synced once per frame" convention `set_joypad` already uses. Save-stated as real hardware state (`FORMAT_VERSION` 2→3, `docs/adr/0006`), not host debug tooling. -**What this frontend wires today:** a Settings → Input control (`ui_shell.rs`) selects controller -port 2's peripheral via `config.port2_peripheral`, re-synced to the Bus every frame -(`app.rs`, alongside the cheats/watchpoints sync). **What it does NOT yet wire: live host-input -capture.** No code path currently feeds `set_mouse`/`set_superscope`/`set_multitap_pad` from a -real OS mouse pointer, a Super Scope crosshair overlay, or extra `gilrs` gamepads for Multitap -sub-pads 2-4 — selecting a non-`Gamepad` device correctly changes what the emulated hardware -reports (verifiable via `rustysnes-script`/the test harness calling the `EmuCore`/`Bus` methods -directly), but the default GUI session won't yet feel it move. This is a real, open follow-up -frontend task, not a silently-incomplete claim: closing it needs (a) a `WindowEvent::CursorMoved`/ -`MouseInput` capture path (or reading `egui::Context`'s own pointer state, already available every -frame since `egui_state.on_window_event` runs unconditionally) mapped from window pixels through -the present path's letterbox/integer-scale transform (`gfx.rs`) to SNES `0..256`/`0..240` pixel -space, and (b) binding `gilrs` device indices 1-3 to Multitap sub-pads 1-3 (index 0 already has a -natural home in the existing P1 gamepad auto-bind). +**What this frontend wires today (`v0.9.0` → `v1.20.0`):** a Settings → Input control +(`ui_shell.rs`) selects controller port 2's peripheral via `config.port2_peripheral`, re-synced to +the Bus every frame (`app.rs`, alongside the cheats/watchpoints sync). **Mouse and Super Scope now +get live host-input capture too** (`v1.20.0`, `crate::peripherals`): `egui::Context`'s own pointer +state (available every frame since `egui_state.on_window_event` runs unconditionally — no new +`WindowEvent` plumbing needed) feeds `EmuCore::set_mouse`/`set_superscope` once per frame, +alongside the existing port-device re-sync. Super Scope's absolute aim position is mapped from +window pixels through the present path's own letterbox transform (`Gfx::letterbox_scale`, exposed +`pub(crate)` for exactly this reuse — never re-derived) into SNES `0..256`/`0..240` pixel space; +trigger/cursor/turbo map to left/right/middle mouse buttons (no fourth button exists for `Pause`, +left unset). Portable to wasm on purpose — both the pointer API and the `EmuCore` calls are +already platform-agnostic, so the hosted demo gets this too, not just the native build. The pure +coordinate-mapping math is unit-tested directly (`peripherals.rs`'s own `#[cfg(test)]` module), +not just "compiles and is presumed correct." + +**What's still NOT wired: Super Multitap sub-pads 1-3.** Real host gamepad polling would be the +input source, but a genuinely separate, larger discovery blocks it: `gilrs::Gilrs` is never +actually instantiated anywhere in this crate today — confirmed while scoping the Mouse/Super Scope +fix above, `input::gamepad_button` (the gilrs-button-name → SNES-button mapping function) has zero +callers. **Controller port 1's own gamepad support is unwired too** — despite this doc's earlier +"Status" line claiming "keyboard + gilrs gamepad", the default GUI session is keyboard-only right +now. This is a real, separate finding, not a silently-incomplete claim: closing Multitap host +input needs a genuinely new prerequisite (a live `Gilrs` instance + per-frame event polling loop), +not a small addition on top of the Mouse/Super Scope wiring above — see +`to-dos/ROADMAP.md`/the UI/UX-parity plan's Phase B/C backlog for where this is tracked. ## Save-states, rewind, run-ahead From 1560e519f5120ff099f0c286037825c15d205ffd Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 15 Jul 2026 01:16:24 -0400 Subject: [PATCH 2/2] fix(frontend): scale mouse/Super Scope pointer input correctly Two real bugs both bots independently flagged: - Mouse deltas were passed through raw (egui points, unscaled), so sensitivity varied with window scale and host display DPI (a 3x window scale felt 3x too fast). Convert to physical pixels via pixels_per_point, then rescale through the same letterbox transform Super Scope already used. - Super Scope mapped into the PPU's raw fb_dims, which can be pixel-doubled (512-wide hi-res, up to 448-tall interlaced) while SuperScopeState::set_input always clamps/offscreen-checks against the SNES's fixed base 256x224/239 screen space. Map into a new logical_snes_dims() instead, and apply the same pixels_per_point conversion the mouse path needed. Co-Authored-By: Claude Sonnet 5 --- crates/rustysnes-frontend/src/peripherals.rs | 152 +++++++++++++++---- 1 file changed, 125 insertions(+), 27 deletions(-) diff --git a/crates/rustysnes-frontend/src/peripherals.rs b/crates/rustysnes-frontend/src/peripherals.rs index 185b5f94..44c4b7c5 100644 --- a/crates/rustysnes-frontend/src/peripherals.rs +++ b/crates/rustysnes-frontend/src/peripherals.rs @@ -35,7 +35,7 @@ use crate::gfx::Gfx; /// prerequisite, see the module doc. pub fn sync(egui_ctx: &egui::Context, gfx: &Gfx, emu: &mut EmuCore, device: PeripheralInputKind) { match device { - PeripheralInputKind::Mouse => sync_mouse(egui_ctx, emu), + PeripheralInputKind::Mouse => sync_mouse(egui_ctx, gfx, emu), PeripheralInputKind::SuperScope => sync_superscope(egui_ctx, gfx, emu), PeripheralInputKind::Other => {} } @@ -72,30 +72,39 @@ impl From for PeripheralInputKind { /// SNES Mouse: relative motion since last frame (real hardware reports deltas, not an absolute /// position) plus the primary/secondary button state. `egui::PointerState::delta` already /// accumulates exactly that between egui passes, so no manual last-position tracking is needed. -fn sync_mouse(egui_ctx: &egui::Context, emu: &mut EmuCore) { - let (delta, left, right) = egui_ctx.input(|i| { +/// +/// `egui`'s delta is in logical *points*, not the physical pixels `gfx.config.{width,height}` +/// (`size_in_pixels`) use, and a raw point delta would also scale with the window's own +/// size/present scale (3x window scale = 3x-too-fast mouse) — found in review (PR #114, both +/// bots independently). Converts to physical pixels via `pixels_per_point`, then rescales through +/// the same letterbox transform [`sync_superscope`] uses, into SNES pixel space, so sensitivity +/// stays constant regardless of window size, present scale, or host display DPI. +fn sync_mouse(egui_ctx: &egui::Context, gfx: &Gfx, emu: &mut EmuCore) { + let (delta_points, left, right) = egui_ctx.input(|i| { ( i.pointer.delta(), i.pointer.button_down(egui::PointerButton::Primary), i.pointer.button_down(egui::PointerButton::Secondary), ) }); - #[allow(clippy::cast_possible_truncation)] - emu.set_mouse( - 0, - delta.x.round() as i32, - delta.y.round() as i32, - left, - right, + let ppp = egui_ctx.pixels_per_point(); + let delta_px = (delta_points.x * ppp, delta_points.y * ppp); + let (dx, dy) = scale_delta_to_snes( + (gfx.config.width, gfx.config.height), + gfx.letterbox_scale(), + delta_px, + emu.fb_dims(), ); + #[allow(clippy::cast_possible_truncation)] + emu.set_mouse(0, dx.round() as i32, dy.round() as i32, left, right); } /// Super Scope: absolute aim position (mapped from window pixels through the letterbox transform -/// into SNES `0..fb_w`/`0..fb_h` pixel space) plus trigger/cursor/turbo. No fourth mouse button +/// into SNES `0..256`/`0..239` screen space) plus trigger/cursor/turbo. No fourth mouse button /// exists to drive `scope::PAUSE`, so it stays unset here — matches this module's own "host /// mouse input only" scope; a future keyboard-bound Pause is a natural, separate follow-up. fn sync_superscope(egui_ctx: &egui::Context, gfx: &Gfx, emu: &mut EmuCore) { - let (pointer, left, right, middle) = egui_ctx.input(|i| { + let (pointer_pt, left, right, middle) = egui_ctx.input(|i| { ( i.pointer.latest_pos(), i.pointer.button_down(egui::PointerButton::Primary), @@ -103,12 +112,20 @@ fn sync_superscope(egui_ctx: &egui::Context, gfx: &Gfx, emu: &mut EmuCore) { i.pointer.button_down(egui::PointerButton::Middle), ) }); - let fb_dims = emu.fb_dims(); + let pixels_per_point = egui_ctx.pixels_per_point(); + // `SuperScopeState::set_input` always clamps/offscreen-checks against the SNES's BASE + // 256-wide, non-interlaced-height screen space, unconditionally — never the game's current + // (possibly pixel-doubled) video mode — so this maps into `logical_snes_dims`, not the raw + // (possibly hi-res/interlaced) `emu.fb_dims()` (found in review, PR #114, Copilot). + let target_dims = logical_snes_dims(emu.fb_dims()); // Matches `SuperScopeState::set_input`'s own `-16` fringe convention (one step further out to // stay unambiguously off-screen after its `-16..=dimension+16` clamp) — same sentinel // `rustysnes-libretro`'s own `poll_port_input` uses for an off-screen lightgun read. - let (x, y) = pointer - .and_then(|p| pointer_to_snes_pixel(gfx, (p.x, p.y), fb_dims)) + let (x, y) = pointer_pt + .and_then(|p| { + let px = (p.x * pixels_per_point, p.y * pixels_per_point); + pointer_to_snes_pixel(gfx, px, target_dims) + }) .unwrap_or((-20, -20)); let mut buttons = 0u8; if left { @@ -123,22 +140,66 @@ fn sync_superscope(egui_ctx: &egui::Context, gfx: &Gfx, emu: &mut EmuCore) { emu.set_superscope(0, x, y, buttons); } -/// Map a host pointer position (window/surface pixels) through `Gfx::letterbox_scale` into -/// SNES pixel space `(0..fb_w, 0..fb_h)`, or `None` if the pointer falls outside the letterboxed -/// game image (over the black bars, or off-window). Thin wrapper around -/// [`pointer_to_snes_pixel_pure`] — pulls the live window size + letterbox scale out of `gfx`, -/// which needs a real wgpu device to construct and so can't be exercised directly by a unit test -/// (`gfx.rs`'s own `letterbox_scale_matches_known_cases` test hits the same constraint). +/// Derive the SNES's fixed BASE screen dims (`256` wide, `224`/`239` tall) from the PPU's actual +/// `fb_dims`, halving any axis the current video mode has pixel-doubled: hi-res mode doubles +/// width to `512` (`rustysnes_core::facade::SNES_W_HIRES`), interlace doubles height (up to +/// `rustysnes_core::facade::SNES_H_HIRES`, `448`). Super Scope games are always base-resolution +/// in practice, but this stays correct even if the PPU happens to be in a doubled mode for +/// unrelated reasons — see [`sync_superscope`]'s own doc for why the core needs this space, not +/// the raw framebuffer's. +const fn logical_snes_dims(fb_dims: (u32, u32)) -> (u32, u32) { + let w = if fb_dims.0 > 256 { + fb_dims.0 / 2 + } else { + fb_dims.0 + }; + let h = if fb_dims.1 > 239 { + fb_dims.1 / 2 + } else { + fb_dims.1 + }; + (w, h) +} + +/// Map a host pointer position (window/surface PHYSICAL pixels — already `pixels_per_point`- +/// scaled by the caller) through `Gfx::letterbox_scale` into `target_dims`'s pixel space, or +/// `None` if the pointer falls outside the letterboxed game image (over the black bars, or +/// off-window). Thin wrapper around [`pointer_to_snes_pixel_pure`] — pulls the live window size + +/// letterbox scale out of `gfx`, which needs a real wgpu device to construct and so can't be +/// exercised directly by a unit test (`gfx.rs`'s own `letterbox_scale_matches_known_cases` test +/// hits the same constraint). fn pointer_to_snes_pixel( gfx: &Gfx, pointer_px: (f32, f32), - fb_dims: (u32, u32), + target_dims: (u32, u32), ) -> Option<(i32, i32)> { pointer_to_snes_pixel_pure( (gfx.config.width, gfx.config.height), gfx.letterbox_scale(), pointer_px, - fb_dims, + target_dims, + ) +} + +/// Scale a relative pointer-motion delta (window/surface PHYSICAL pixels) into SNES pixel space, +/// through the same letterbox image-rect `pointer_to_snes_pixel_pure` maps absolute positions +/// through — a delta only needs the rect's SIZE (`image_w`/`image_h`), not its origin, since +/// motion is translation-invariant. +#[allow(clippy::cast_precision_loss)] +fn scale_delta_to_snes( + win_dims: (u32, u32), + letterbox_scale: (f32, f32), + delta_px: (f32, f32), + fb_dims: (u32, u32), +) -> (f32, f32) { + let win_w = win_dims.0.max(1) as f32; + let win_h = win_dims.1.max(1) as f32; + let (scale_x, scale_y) = letterbox_scale; + let image_w = (win_w * scale_x).max(1.0); + let image_h = (win_h * scale_y).max(1.0); + ( + delta_px.0 * (fb_dims.0 as f32 / image_w), + delta_px.1 * (fb_dims.1 as f32 / image_h), ) } @@ -150,7 +211,7 @@ fn pointer_to_snes_pixel_pure( win_dims: (u32, u32), letterbox_scale: (f32, f32), pointer_px: (f32, f32), - fb_dims: (u32, u32), + target_dims: (u32, u32), ) -> Option<(i32, i32)> { let win_w = win_dims.0.max(1) as f32; let win_h = win_dims.1.max(1) as f32; @@ -166,14 +227,14 @@ fn pointer_to_snes_pixel_pure( let norm_x = (px - origin_x) / image_w; let norm_y = (py - origin_y) / image_h; Some(( - (norm_x * fb_dims.0 as f32) as i32, - (norm_y * fb_dims.1 as f32) as i32, + (norm_x * target_dims.0 as f32) as i32, + (norm_y * target_dims.1 as f32) as i32, )) } #[cfg(test)] mod tests { - use super::pointer_to_snes_pixel_pure; + use super::{logical_snes_dims, pointer_to_snes_pixel_pure, scale_delta_to_snes}; const SNES_FB: (u32, u32) = (256, 224); @@ -214,4 +275,41 @@ mod tests { let got = pointer_to_snes_pixel_pure((800, 600), (1.0, 1.0), (800.0, 300.0), SNES_FB); assert_eq!(got, None); } + + #[test] + fn logical_dims_passes_through_base_resolution_unchanged() { + assert_eq!(logical_snes_dims((256, 224)), (256, 224)); + assert_eq!(logical_snes_dims((256, 239)), (256, 239)); + } + + #[test] + fn logical_dims_halves_hires_width() { + assert_eq!(logical_snes_dims((512, 224)), (256, 224)); + } + + #[test] + fn logical_dims_halves_interlaced_height() { + assert_eq!(logical_snes_dims((256, 448)), (256, 224)); + assert_eq!(logical_snes_dims((512, 448)), (256, 224)); + } + + #[test] + fn delta_scale_is_identity_at_1to1_no_letterbox() { + // An 800x600 window with no letterboxing, 4:3 SNES fb: no rescale needed at these dims + // (image_w == 800, fb_dims.0 == 256, so a delta only rescales by 256/800 -- verify the + // ratio, not literal identity, since fb space is smaller than window space here). + let (dx, dy) = scale_delta_to_snes((800, 600), (1.0, 1.0), (10.0, 10.0), SNES_FB); + assert!((dx - 3.2).abs() < 1e-4); // 10 * 256/800 + assert!((dy - 3.7333).abs() < 1e-3); // 10 * 224/600 + } + + #[test] + fn delta_scale_shrinks_with_a_larger_window_at_the_same_letterbox() { + // Same letterbox fraction, a 2x larger window -> half the delta for the same physical + // pointer motion (the core bug this fix addresses: 3x window scale felt 3x too fast). + let small = scale_delta_to_snes((800, 600), (1.0, 1.0), (10.0, 10.0), SNES_FB); + let large = scale_delta_to_snes((1600, 1200), (1.0, 1.0), (10.0, 10.0), SNES_FB); + assert!(large.0.mul_add(-2.0, small.0).abs() < 1e-3); + assert!(large.1.mul_add(-2.0, small.1).abs() < 1e-3); + } }