diff --git a/CHANGELOG.md b/CHANGELOG.md index 78654418..6bc37d88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,20 @@ cycle-accurate core later replaced. ## [Unreleased] +### Added + +- **Netplay lobby + matchmaking (v2.2.0 "Capstone", B5).** The pure signaling protocol (`crates/rustynes-netplay/src/signaling.rs`) grows a **browse-and-join** lobby directory and a matchmaking path atop the existing room-code / TURN stack. New `SignalMessage` variants — `ListRooms { rom_hash }` → `RoomList { rooms: Vec }` (the open, joinable, optionally game-filtered rooms; each `RoomInfo` carries the code / player count / capacity / `rom_hash` and *no* SDP/ICE/identity), and `QuickMatch { rom_hash, max_players }` → `Matched { room, slot, max_players }` (server-side "quick play": join any open room for the ROM via the shared `add_to_room` primitive, or create a fresh one with a deterministic `QM-NNNNNN` code). The `room-list` JSON array is parsed by a brace-depth walk bounded at `MAX_ROOM_LIST` (256) so an oversized frame cannot force an unbounded allocation. Determinism/rollback contract untouched — this is signaling only. +- **Delayed-stream spectators (v2.2.0 "Capstone", B5).** `SpectatorConfig.delay_frames` (clamped to `MAX_DELAY_FRAMES` = 512 ≈ 8.5 s) layers an intentional broadcast / anti-spoiler / jitter-smoothing hold atop the natural spectator lag: frame `f` is revealed only once frame `f + delay_frames` is confirmed (`reveal_horizon()`). Purely a *presentation* delay — frames are still produced byte-identically and in order, and the spectator still sends nothing — so it cannot perturb the match. Wired to a configurable `NetplayUi::spectator_delay_frames` (default 0). +- **Hardened desync surface (v2.2.0 "Capstone", B5).** `DesyncDiagnostics` gains a single graded `DesyncStatus` { `InSync` / `Suspect` / `Desynced` } verdict with a hysteresis threshold (`DEFAULT_DESYNC_THRESHOLD` = 3 consecutive mismatches ≈ 1.5 s at the 30-frame checksum interval) so a lone reordered / late peer checksum no longer flashes a false desync banner, plus a sticky peak-run rule so a confirmed (unrecoverable) desync never silently downgrades. Still pure telemetry over the `NetMessage::Checksum` digests the session already exchanges. +- **Peer-liveness RTT timeouts (v2.2.0 "Capstone", B5).** A graded `PeerLink` { `Live` / `Interrupted` / `TimedOut` } for an already-synced `NetplayConnection`, driven by `last_recv` against `peer_interrupt_timeout` (2 s) / `peer_disconnect_timeout` (5 s), plus a terminal `DisconnectReason::PeerTimeout`. Deliberately far above Mesen's trigger-happy ~150 ms (documented on `PeerLink`): a single lost 1 Hz `Quality` ping or a routine Wi-Fi/LTE retransmit spike never trips it, matching the multi-second grace windows GGPO/Parsec use. Both thresholds are builder-configurable (`with_peer_timeouts`). +- **Fuzz-target expansion (v2.2.0 "Capstone", quality).** `fuzz/` grows from 3 to 8 cargo-fuzz targets covering the remaining untrusted-input boundaries: `ppu_reg_io` (`Ppu::cpu_{read,write}_register` over a minimal `PpuBus`), `apu_reg_io` (`Apu::write_register` / `read_status`), `netplay_message` (the highest-value target — `NetMessage::from_bytes` binary UDP + `SignalMessage::parse` JSON signaling/lobby, both ingesting bytes straight off the wire), `save_state` (`parse_header` + `Nes::extract_thumbnail` + `restore_quiet`), and `movie` (`Movie::deserialize`). Each builds under nightly cargo-fuzz and runs clean for tens of thousands of iterations. `fuzz/README.md` documents the targets + the LeakSanitizer-under-sandbox note. +- **Read-only ROM Info browser (v2.2.0 "Capstone").** A new **Tools → ROM Info** panel (`crates/rustynes-frontend/src/debugger/rom_info_panel.rs`) surfaces, for the loaded ROM, the two dump-identity CRC32 keys (the header-excluded game-DB key + the full-file **No-Intro** key), the SHA-256, the effective per-game database entry (title / mapper / region / mirroring / submapper), and the decoded cartridge header read straight off the running `Nes` (mapper id, region, PRG-ROM / CHR-ROM sizes). Read-only (`&Nes`) — never mutates the emulator or the DB overlay; the deterministic core never consults it. Honest about surfacing only the vendored per-game DB + the header (no bootgod / nescartdb table is vendored). +- **MkDocs handbook deepening (v2.2.0 "Capstone", quality).** Four new Material-for-MkDocs handbook pages — `docs/expansion-audio.md`, `docs/pal-region.md`, `docs/crt-composite.md`, `docs/creator-tools.md` — curated entry points for the newer subsystems, cross-linked to the authoritative `apu-2a03.md` / `ppu-2c02.md` / `frontend.md` specs, with matching `mkdocs.yml` nav entries. + +### Changed + +- **Movie (`.rnm`) deserializer hardening (v2.2.0 "Capstone", quality).** The new `movie` fuzz target surfaced two OOM DoS paths in `Movie::deserialize` (`crates/rustynes-core/src/movie.rs`), both now fixed **byte-identically for valid input**: (1) the untrusted 4-byte `frame_count` was passed straight to `Vec::with_capacity`, so a 49-byte header could claim a multi-gigabyte reservation — now capped at `remaining_bytes / width` (== `frame_count` for any real file); (2) a `bytes_per_frame` of 0 made each `r.take(0)` consume no input, so the frame loop pushed `frame_count` empty records out of a finite file — now rejected up front (a real movie always writes the fixed `BYTES_PER_FRAME` ≥ 1). Regression test `deserialize_hostile_frame_count_does_not_oom` added; the existing 44 movie tests (incl. the determinism round-trip) stay green. + ## [2.1.10] - 2026-07-12 - "Fathom" (creator tools and web parity — TAStudio greenzone + Lua API breadth + browser-RA auth-proxy deploy stack + Vs. DualSystem libretro presentation — "Loom") ### Added diff --git a/crates/rustynes-core/src/movie.rs b/crates/rustynes-core/src/movie.rs index 0fafd9a0..ea9d30f4 100644 --- a/crates/rustynes-core/src/movie.rs +++ b/crates/rustynes-core/src/movie.rs @@ -307,10 +307,18 @@ impl Movie { // Frame count + width. let frame_count = r.u32().map_err(map_eof)? as usize; let bytes_per_frame = r.u8().map_err(map_eof)?; - if bytes_per_frame > BYTES_PER_FRAME { + if bytes_per_frame == 0 || bytes_per_frame > BYTES_PER_FRAME { // A newer movie packs more device bytes than we understand; we // fail cleanly rather than mis-parse (the reserved byte exists // precisely so this stays a graceful error, not a corruption). + // + // SECURITY: a `bytes_per_frame` of 0 is likewise rejected. With a + // zero-width record each frame read (`r.take(0)`) consumes no input, + // so the `for _ in 0..frame_count` loop below would push + // `frame_count` (an untrusted u32, up to ~4.3 billion) empty frames + // out of a finite file — an OOM DoS (found by the `movie` fuzz + // target). A real movie always writes the fixed `BYTES_PER_FRAME` + // (>= 1), so rejecting 0 costs no legitimate file. return Err(MovieError::UnsupportedFrameWidth { got: bytes_per_frame, max: BYTES_PER_FRAME, @@ -323,9 +331,20 @@ impl Movie { } else { StartPoint::PowerOn }; - // Input stream: `frame_count` records of `bytes_per_frame` bytes. - let mut frames = Vec::with_capacity(frame_count); + // Input stream: `frame_count` records of `bytes_per_frame` bytes + // (`width >= 1`, enforced above). let width = usize::from(bytes_per_frame); + // SECURITY: `frame_count` is an untrusted 4-byte field (up to ~4.3 + // billion). Pre-sizing `Vec::with_capacity(frame_count)` from it lets a + // 49-byte header claim a multi-gigabyte allocation — an OOM DoS (found + // by the `movie` fuzz target). A real movie carries exactly + // `frame_count * width` more bytes, so cap the reservation at what the + // remaining input could actually hold: for a valid file this equals + // `frame_count` (identical allocation, byte-for-byte the same result), + // and for a truncated / hostile one the `r.take(width)` below still + // fails cleanly with an EOF error once the real bytes run out. + let max_plausible_frames = r.remaining() / width; + let mut frames = Vec::with_capacity(frame_count.min(max_plausible_frames)); for _ in 0..frame_count { let rec = r.take(width).map_err(map_eof)?; // rec[0] = p1, rec[1] = p2 (present whenever width >= 2, which it @@ -724,6 +743,32 @@ mod tests { )); } + #[test] + fn deserialize_hostile_frame_count_does_not_oom() { + // frame_count is the 4-byte LE field right after the 32-byte rom hash + // (offset 8 + 2 + 1 + 1 + 32 = 44). + const FRAME_COUNT_OFF: usize = 8 + 2 + 1 + 1 + 32; + // A tiny (header-only) movie whose `frame_count` field claims ~4.3 + // billion frames. The old `Vec::with_capacity(frame_count)` would try to + // reserve multiple gigabytes before the input-stream read failed (an OOM + // DoS found by the `movie` fuzz target). It must now reject cleanly with + // an EOF: the capacity is capped at the remaining bytes / width. + let movie = Movie { + region: Region::Ntsc, + rom_sha256: [0; 32], + start: StartPoint::PowerOn, + frames: Vec::new(), + rerecord_count: 0, + }; + let mut bytes = movie.serialize(); + bytes[FRAME_COUNT_OFF..FRAME_COUNT_OFF + 4].copy_from_slice(&u32::MAX.to_le_bytes()); + // Deserialize must return promptly with an error, not exhaust memory. + assert!(matches!( + Movie::deserialize(&bytes), + Err(MovieError::Eof(_)) + )); + } + #[test] fn deserialize_rejects_truncated_input_stream() { let movie = Movie { diff --git a/crates/rustynes-frontend/src/debugger/mod.rs b/crates/rustynes-frontend/src/debugger/mod.rs index d9d3ba15..344c0603 100644 --- a/crates/rustynes-frontend/src/debugger/mod.rs +++ b/crates/rustynes-frontend/src/debugger/mod.rs @@ -119,6 +119,9 @@ mod basic_bot_panel; // conditional trace. Pure + frontend-only; the unit tests live in the module. mod expr; mod game_db_panel; +// v2.2.0 "Capstone" — read-only ROM Info browser (per-game DB + No-Intro CRC + +// decoded cartridge header for the loaded ROM). +mod rom_info_panel; // v1.5.0 "Lens" Workstream A4 — HD-pack per-pixel inspector (native + hd-pack). // `pub(crate)` so `app.rs` can drive its `show` (the panel needs the compositor // + per-frame snapshots the app owns, so its render lives there, not here). @@ -182,6 +185,10 @@ pub enum ToolPanel { Input, /// Per-game ROM-database editor (v1.2.0 Workstream B, B4). GameDb, + /// Read-only ROM Info browser (v2.2.0 "Capstone"): the loaded ROM's + /// identity CRCs / SHA-256, its per-game DB entry, and its decoded + /// cartridge header. A read-only companion to [`Self::GameDb`]. + RomInfo, /// Live "Input Display" panel — the consolidated controller + expansion- /// device HUD (v1.7.0 "Forge" beta.5, #51; the v1.5.0 "Lens" Workstream A1 /// Input Miniatures overlay absorbed the former standalone Input Display). @@ -280,6 +287,8 @@ pub struct DebuggerOverlay { show_cheevos: bool, show_perf: bool, show_game_db: bool, + /// Read-only ROM Info browser open flag (v2.2.0 "Capstone"). + show_rom_info: bool, /// "Input Display" panel open flag (v1.7.0 "Forge" beta.5, #51; née the /// v1.5.0 A1 Input Miniatures overlay). show_input_display: bool, @@ -349,6 +358,8 @@ pub struct DebuggerOverlay { cheat_ui: cheat_panel::CheatPanelState, /// ROM-database editor panel state (v1.2.0 Workstream B, B4). game_db_ui: game_db_panel::GameDbPanelState, + /// Read-only ROM Info panel state (v2.2.0 "Capstone"). + rom_info_ui: rom_info_panel::RomInfoPanelState, /// CRC32 of the currently-loaded ROM (PRG+CHR, header-excluded), pushed by /// [`DebuggerOverlay::set_rom_crc`] at load. `None` for FDS / NSF / no ROM. /// The ROM-database editor keys its overlay edits on this. @@ -521,6 +532,7 @@ impl DebuggerOverlay { show_cheevos: false, show_perf: false, show_game_db: false, + show_rom_info: false, show_input_display: false, #[cfg(all(not(target_arch = "wasm32"), feature = "hd-pack"))] show_hd_pixel: false, @@ -554,6 +566,7 @@ impl DebuggerOverlay { show_documentation: false, cheat_ui: cheat_panel::CheatPanelState::default(), game_db_ui: game_db_panel::GameDbPanelState::default(), + rom_info_ui: rom_info_panel::RomInfoPanelState, rom_crc: None, rom_crc_full: None, settings_ui: settings_panel::SettingsPanelState::default(), @@ -1046,6 +1059,7 @@ impl DebuggerOverlay { ToolPanel::Perf => self.show_perf = true, ToolPanel::Input => self.show_input = true, ToolPanel::GameDb => self.show_game_db = true, + ToolPanel::RomInfo => self.show_rom_info = true, ToolPanel::InputDisplay => self.show_input_display = true, ToolPanel::Replay => self.show_replay = true, ToolPanel::BasicBot => self.show_basic_bot = true, @@ -1285,12 +1299,13 @@ impl DebuggerOverlay { /// `tool_panels` must be listed here or it silently fails to open /// standalone (it would only render while another `nes`-reading panel /// happened to be open, then vanish when that one closed). Today the - /// `nes`-reading tool panels are **Cheats** (`show_cheat`) and the - /// **ROM Database** editor (`show_game_db`). If you add another panel that - /// takes `&mut Nes` in `tool_panels`, add its `show_*` flag here too. + /// `nes`-reading tool panels are **Cheats** (`show_cheat`), the + /// **ROM Database** editor (`show_game_db`), and the read-only **ROM Info** + /// browser (`show_rom_info`). If you add another panel that takes `&Nes` / + /// `&mut Nes` in `tool_panels`, add its `show_*` flag here too. #[must_use] pub const fn any_nes_tool_open(&self) -> bool { - self.show_cheat || self.show_game_db + self.show_cheat || self.show_game_db || self.show_rom_info } /// Build the egui UI for this frame (the deep-overlay path: chip panels + @@ -1726,7 +1741,7 @@ impl DebuggerOverlay { } } if self.show_game_db - && let Some(nes) = nes + && let Some(nes) = nes.as_deref_mut() { game_db_panel::show( ctx, @@ -1736,6 +1751,21 @@ impl DebuggerOverlay { self.rom_crc, ); } + if self.show_rom_info + && let Some(nes) = nes.as_deref() + { + // Read-only: takes `&Nes`. Surfaces the loaded ROM's identity CRCs + + // SHA-256, its per-game DB entry, and its decoded cartridge header. + // (v2.2.0 "Capstone".) + rom_info_panel::show( + ctx, + &mut self.show_rom_info, + &mut self.rom_info_ui, + nes, + self.rom_crc, + self.rom_crc_full, + ); + } if self.show_settings { settings_panel::show(ctx, &mut self.show_settings, &mut self.settings_ui, config); } diff --git a/crates/rustynes-frontend/src/debugger/rom_info_panel.rs b/crates/rustynes-frontend/src/debugger/rom_info_panel.rs new file mode 100644 index 00000000..32f8a68b --- /dev/null +++ b/crates/rustynes-frontend/src/debugger/rom_info_panel.rs @@ -0,0 +1,190 @@ +//! Read-only **ROM Info** browser (v2.2.0 "Capstone"). +//! +//! Surfaced from **Tools -> ROM Info**. A purely *observational* companion to +//! the editable **ROM Database** panel: it surfaces, for the currently loaded +//! ROM, the identity + header metadata a user typically wants to confirm at a +//! glance — the two dump-identity CRC32 keys, the SHA-256, the effective +//! per-game database entry (title / mapper / region / mirroring, keyed on the +//! header-excluded CRC), and the cartridge's decoded iNES / NES 2.0 shape +//! (mapper id, region, PRG-ROM / CHR-ROM sizes) read straight off the running +//! [`Nes`]. +//! +//! # Two CRC32 keys +//! +//! `RustyNES` keys ROM identity two ways, and both are shown: +//! +//! - **Header-excluded CRC32** — CRC of the PRG+CHR payload only (the iNES +//! header + any trainer removed). This is the *game-database key* +//! ([`game_db::entry_for_crc`]) and the canonical way to identify a game +//! independent of a re-tagged header. +//! - **Full-file CRC32** — CRC of the entire file including the header. This is +//! the **No-Intro** dump key (No-Intro DATs checksum the whole `.nes` file), +//! used to recognize a specific dump. +//! +//! No PRG-ROM/bootgod (nescartdb) table is vendored in the repo, so board / +//! chip-level provenance is not surfaced here; the panel is honest about what +//! it knows (the vendored per-game DB + the cartridge header) rather than +//! implying a database it does not carry. +//! +//! This is all frontend-side and read-only — it never mutates the `Nes`, never +//! writes the DB overlay, and the deterministic core never consults any of it. + +use rustynes_core::Nes; + +use crate::game_db; + +/// Panel state: none is required (the view is recomputed from the `Nes` + CRC +/// each frame), but a zero-sized state keeps the panel's wiring uniform with +/// the other tool panels (`show_*` flag + `*_ui` state field). +#[derive(Default)] +pub struct RomInfoPanelState; + +/// Format a 32-byte SHA-256 as lowercase hex, wrapped to two 32-char lines so +/// the window stays narrow. +fn sha256_hex(hash: &[u8; 32]) -> (String, String) { + let mut hi = String::with_capacity(32); + let mut lo = String::with_capacity(32); + for (i, b) in hash.iter().enumerate() { + use std::fmt::Write as _; + let _ = write!(if i < 16 { &mut hi } else { &mut lo }, "{b:02x}"); + } + (hi, lo) +} + +/// Human-readable size: raw bytes for < 1 KiB, else ` KiB ( bytes)` +/// for any size at or above 1 KiB (the KiB figure is a floored `bytes / 1024`, +/// with the exact byte count always shown alongside for non-multiples). +fn fmt_size(bytes: usize) -> String { + if bytes < 1024 { + format!("{bytes} bytes") + } else { + format!("{} KiB ({bytes} bytes)", bytes / 1024) + } +} + +/// Render the read-only ROM Info window. +/// +/// `crc` is the header-excluded CRC32 (the game-DB key) and `crc_full` the +/// full-file CRC32 (the No-Intro dump key) — both already computed by the app +/// at ROM load. Either may be `None` for an image with no CRC entry (e.g. an +/// FDS / NSF file). +pub fn show( + ctx: &egui::Context, + open: &mut bool, + _state: &mut RomInfoPanelState, + nes: &Nes, + crc: Option, + crc_full: Option, +) { + let mut win_open = *open; + egui::Window::new("ROM Info") + .open(&mut win_open) + .resizable(false) + .show(ctx, |ui| { + // --- Identity / provenance keys --- + ui.heading("Identity"); + egui::Grid::new("rom_info_identity") + .num_columns(2) + .striped(true) + .show(ui, |ui| { + // Title comes from the vendored per-game DB (if listed). + let title = crc + .and_then(game_db::entry_for_crc) + .map(|e| e.title) + .filter(|t| !t.is_empty()); + ui.label("Title (game DB)"); + ui.label(title.as_deref().unwrap_or("(not in database)")); + ui.end_row(); + + ui.label("CRC32 (game-DB key)"); + ui.label( + crc.map_or_else( + || "(no cartridge CRC)".to_string(), + |c| format!("{c:08X}"), + ), + ); + ui.end_row(); + + ui.label("CRC32 (No-Intro, full file)"); + ui.label( + crc_full + .map_or_else(|| "(unavailable)".to_string(), |c| format!("{c:08X}")), + ); + ui.end_row(); + + let (hi, lo) = sha256_hex(nes.rom_sha256()); + ui.label("SHA-256"); + ui.vertical(|ui| { + ui.monospace(hi); + ui.monospace(lo); + }); + ui.end_row(); + }); + + ui.separator(); + + // --- Decoded cartridge header (straight off the running Nes) --- + ui.heading("Cartridge"); + egui::Grid::new("rom_info_cart") + .num_columns(2) + .striped(true) + .show(ui, |ui| { + ui.label("Mapper"); + // Show the DB's recorded mapper alongside the active one when + // they differ (a header override in effect). + let active = nes.mapper_id(); + let db_mapper = crc.and_then(game_db::entry_for_crc).and_then(|e| e.mapper); + match db_mapper { + Some(m) if m != active => { + ui.label(format!("{active} (DB: {m})")); + } + _ => { + ui.label(active.to_string()); + } + } + ui.end_row(); + + ui.label("Region"); + ui.label(format!("{:?}", nes.region())); + ui.end_row(); + + ui.label("PRG ROM"); + ui.label(fmt_size(nes.prg_rom_len())); + ui.end_row(); + + let chr = nes.chr_rom_len(); + ui.label("CHR"); + ui.label(if chr == 0 { + "CHR-RAM (no CHR ROM)".to_string() + } else { + fmt_size(chr) + }); + ui.end_row(); + + // Mirroring / submapper from the DB entry, when present. + if let Some(entry) = crc.and_then(game_db::entry_for_crc) { + if let Some(m) = entry.mirroring { + ui.label("Mirroring (DB)"); + ui.label(format!("{m:?}")); + ui.end_row(); + } + if let Some(sm) = entry.submapper { + ui.label("Submapper (DB)"); + ui.label(sm.to_string()); + ui.end_row(); + } + } + }); + + ui.separator(); + ui.label( + egui::RichText::new( + "Read-only. Metadata from the vendored per-game database + the \ + cartridge header. Edit corrections in Tools -> ROM Database.", + ) + .small() + .weak(), + ); + }); + *open = win_open; +} diff --git a/crates/rustynes-frontend/src/netplay_ui.rs b/crates/rustynes-frontend/src/netplay_ui.rs index 2f521012..4a59e91a 100644 --- a/crates/rustynes-frontend/src/netplay_ui.rs +++ b/crates/rustynes-frontend/src/netplay_ui.rs @@ -155,6 +155,11 @@ pub struct NetplayUi { /// Session config (input delay, rollback window, checksum interval). The /// `local_player` field is overwritten at connect from `is_host`. config: SessionConfig, + /// Extra delayed-stream buffer depth (frames) applied when *spectating* a + /// match — a broadcast / anti-spoiler delay layered on top of the natural + /// spectator lag. `0` (default) shows confirmed frames immediately. See + /// [`SpectatorConfig::delay_frames`](rustynes_netplay::SpectatorConfig::delay_frames). + spectator_delay_frames: u32, } impl Default for NetplayUi { @@ -165,6 +170,7 @@ impl Default for NetplayUi { rom_hash: [0u8; 32], status: NetplayStatus::default(), config: SessionConfig::default(), + spectator_delay_frames: 0, } } } @@ -180,6 +186,19 @@ impl NetplayUi { !matches!(self.state, NetplayState::Idle) } + /// Set the delayed-stream buffer depth (frames) used when spectating. Takes + /// effect on the next spectate; clamped by the session to + /// [`SpectatorConfig::MAX_DELAY_FRAMES`](rustynes_netplay::SpectatorConfig::MAX_DELAY_FRAMES). + pub const fn set_spectator_delay_frames(&mut self, frames: u32) { + self.spectator_delay_frames = frames; + } + + /// The configured spectator delayed-stream buffer depth (frames). + #[must_use] + pub const fn spectator_delay_frames(&self) -> u32 { + self.spectator_delay_frames + } + /// The current phase. #[must_use] pub const fn phase(&self) -> NetplayPhase { @@ -290,6 +309,7 @@ impl NetplayUi { let session = SpectatorSession::new( SpectatorConfig { num_players: self.config.num_players, + delay_frames: self.spectator_delay_frames, }, transport, rom_hash, @@ -403,6 +423,9 @@ impl NetplayUi { Some(DisconnectReason::HandshakeTimeout) => { "handshake timed out (no peer answered)".to_string() } + Some(DisconnectReason::PeerTimeout) => { + "peer connection lost (no data for several seconds)".to_string() + } None => "connection closed".to_string(), }; self.fail(why); diff --git a/crates/rustynes-frontend/src/ui_shell.rs b/crates/rustynes-frontend/src/ui_shell.rs index f785bb22..ae577865 100644 --- a/crates/rustynes-frontend/src/ui_shell.rs +++ b/crates/rustynes-frontend/src/ui_shell.rs @@ -1324,6 +1324,15 @@ impl UiShell { out.action = Some(MenuAction::OpenPanel(ToolPanel::GameDb)); ui.close(); } + // (H1) v2.2.0 "Capstone" — the read-only ROM Info browser + // needs a loaded ROM to describe. + if ui + .add_enabled(rom, egui::Button::new(ic(glyph::CIRCLE_INFO, "ROM Info"))) + .clicked() + { + out.action = Some(MenuAction::OpenPanel(ToolPanel::RomInfo)); + ui.close(); + } // v1.3.0 menu reorg — HD-pack loader (v1.2.0 C3), folded in // from the former standalone "Mod" menu as a Tools submenu; // native + `hd-pack`-feature-gated. (H1) Load/unload needs a diff --git a/crates/rustynes-frontend/src/wasm_netplay.rs b/crates/rustynes-frontend/src/wasm_netplay.rs index c90bd95e..bf20b867 100644 --- a/crates/rustynes-frontend/src/wasm_netplay.rs +++ b/crates/rustynes-frontend/src/wasm_netplay.rs @@ -422,10 +422,34 @@ fn handle_signal(ws: &WebSocket, shared: &Rc>, msg: SignalMessag s.phase = BrowserNetplayPhase::Error; s.message = reason; } - // `Join` is a client->server message; never inbound to a client. - // `PublicAddr` is the native-UDP (mobile) rendezvous address; the - // browser SDP/ICE path does not use it. - SignalMessage::Join { .. } | SignalMessage::PublicAddr { .. } => {} + // v2.2.0 "Capstone": a `QuickMatch` reply. Identical to `Joined` for the + // WebRTC pairing that follows (record our slot + room size); the extra + // `room` code is surfaced so the matchmade user can see / share it. + SignalMessage::Matched { + room, + slot, + max_players, + } => { + let mut s = shared.borrow_mut(); + s.slot = slot; + s.max_players = max_players; + s.message = format!("matched into room {room}"); + } + // v2.2.0 "Capstone": the lobby directory reply. The browser lobby-browse + // UI is not wired yet, so record the open-room count for display; the + // codes are reachable via a subsequent `Join`. + SignalMessage::RoomList { rooms } => { + let mut s = shared.borrow_mut(); + s.message = format!("{} open room(s)", rooms.len()); + } + // Client->server message types are never inbound to a client: + // `Join` / `ListRooms` / `QuickMatch` (requests we send), and + // `PublicAddr` (the native-UDP mobile rendezvous; the browser SDP/ICE + // path does not use it). + SignalMessage::Join { .. } + | SignalMessage::ListRooms { .. } + | SignalMessage::QuickMatch { .. } + | SignalMessage::PublicAddr { .. } => {} } } diff --git a/crates/rustynes-mobile/src/lib.rs b/crates/rustynes-mobile/src/lib.rs index 4a8d8b53..36db8039 100644 --- a/crates/rustynes-mobile/src/lib.rs +++ b/crates/rustynes-mobile/src/lib.rs @@ -2218,6 +2218,9 @@ fn np_tick_connecting(g: &mut Inner, mut conn: NetplayConnection, is_host: bool) Some(DisconnectReason::HandshakeTimeout) => { "handshake timed out (no peer answered)".to_string() } + Some(DisconnectReason::PeerTimeout) => { + "peer stopped responding (connection timed out)".to_string() + } None => "connection closed".to_string(), }; g.netplay = None; diff --git a/crates/rustynes-netplay/src/connection.rs b/crates/rustynes-netplay/src/connection.rs index e11022ae..e798f048 100644 --- a/crates/rustynes-netplay/src/connection.rs +++ b/crates/rustynes-netplay/src/connection.rs @@ -353,6 +353,51 @@ pub enum DisconnectReason { HandshakeTimeout, /// The peer announced a different ROM hash in its `Sync`. RomMismatch, + /// The peer was synced but then went silent past the disconnect timeout + /// (no datagram of any kind received for [`NetplayConnection`]'s + /// `peer_disconnect_timeout`). See [`PeerLink`] for the graded liveness + /// signal that precedes this terminal state. + PeerTimeout, +} + +/// The liveness of an already-[`Synced`](ConnectionState::Synced) peer, graded +/// by how long it has been since the last datagram of any kind arrived. +/// +/// This is the **run-time** counterpart to the one-shot +/// [`DisconnectReason::HandshakeTimeout`]: once gameplay is underway, a peer +/// can stall (packet loss, a paused laptop, a flaky LTE handoff) without ever +/// formally disconnecting. GGPO-style netcode surfaces that as a graded signal +/// so the frontend can show a "connection interrupted" overlay *before* +/// tearing the match down, then only give up after a much longer grace period. +/// +/// # Why not Mesen's 150 ms +/// +/// Mesen's netplay declares a peer stalled after ~150 ms of silence, which is +/// famously trigger-happy: a single dropped `Quality` ping (they are sent only +/// once per second here) or a routine Wi-Fi/LTE retransmit spike routinely +/// exceeds 150 ms of inter-arrival gap on a real internet path whose RTT is +/// already 60-120 ms, producing spurious "desynced/interrupted" flapping on +/// otherwise-healthy connections. We instead grade liveness against the packet +/// cadence: the [`Interrupted`](Self::Interrupted) warning fires only after +/// `peer_interrupt_timeout` (default **2 s** — two full ping intervals plus +/// slack, so a single lost ping never trips it), and the terminal +/// [`TimedOut`](Self::TimedOut) only after `peer_disconnect_timeout` +/// (default **5 s**), matching the multi-second grace windows GGPO/Parsec use. +/// Both are configurable via +/// [`with_peer_timeouts`](NetplayConnection::with_peer_timeouts) for LAN play +/// (where much tighter bounds are appropriate) or high-latency relayed play. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PeerLink { + /// A datagram arrived within `peer_interrupt_timeout`; the link is healthy. + Live, + /// No datagram for at least `peer_interrupt_timeout` but less than + /// `peer_disconnect_timeout` — the frontend should warn, but the session is + /// still recoverable (a late packet returns the link to + /// [`Live`](Self::Live)). + Interrupted, + /// No datagram for at least `peer_disconnect_timeout`; the connection is + /// (or is about to be) torn down with [`DisconnectReason::PeerTimeout`]. + TimedOut, } /// A direct host/join netplay connection: owns a [`UdpTransport`], performs the @@ -400,6 +445,15 @@ pub struct NetplayConnection { ping_in_flight: Option, /// Exponentially-smoothed round-trip estimate, milliseconds. smoothed_ping_ms: Option, + /// When we last received *any* datagram from the peer. Drives the graded + /// [`PeerLink`] liveness signal and the [`DisconnectReason::PeerTimeout`] + /// terminal state once [`Synced`](ConnectionState::Synced). + last_recv: Instant, + /// Silence after which a synced peer is reported [`PeerLink::Interrupted`]. + peer_interrupt_timeout: Duration, + /// Silence after which a synced peer is torn down with + /// [`DisconnectReason::PeerTimeout`]. + peer_disconnect_timeout: Duration, /// The peer's most recently reported frame advantage (its local frame /// minus its last-confirmed remote frame). remote_frame_advantage: i32, @@ -412,6 +466,15 @@ impl NetplayConnection { const DEFAULT_PING_INTERVAL: Duration = Duration::from_secs(1); /// Default handshake timeout. const DEFAULT_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10); + /// Default peer-interrupt threshold: silence after which a synced peer is + /// reported [`PeerLink::Interrupted`]. Two full [`DEFAULT_PING_INTERVAL`]s + /// (2 s) so a single lost ping never trips it — deliberately far above + /// Mesen's trigger-happy ~150 ms (see [`PeerLink`]). + const DEFAULT_PEER_INTERRUPT: Duration = Duration::from_secs(2); + /// Default peer-disconnect threshold: silence after which a synced peer is + /// torn down with [`DisconnectReason::PeerTimeout`]. Matches the multi-second + /// grace window GGPO/Parsec use. + const DEFAULT_PEER_DISCONNECT: Duration = Duration::from_secs(5); /// Smoothing factor for the RTT estimate (new sample weight). const PING_SMOOTHING: f64 = 0.2; @@ -475,6 +538,9 @@ impl NetplayConnection { ping_interval: Self::DEFAULT_PING_INTERVAL, ping_in_flight: None, smoothed_ping_ms: None, + last_recv: now, + peer_interrupt_timeout: Self::DEFAULT_PEER_INTERRUPT, + peer_disconnect_timeout: Self::DEFAULT_PEER_DISCONNECT, remote_frame_advantage: 0, } } @@ -486,6 +552,41 @@ impl NetplayConnection { self } + /// Override the synced-peer liveness thresholds (defaults: interrupt 2 s, + /// disconnect 5 s). Builder-style. Tighten these for LAN play or loosen + /// them for high-latency relayed play; keep `interrupt < disconnect`. + /// + /// See [`PeerLink`] for why these are seconds, not Mesen's ~150 ms. + #[must_use] + pub const fn with_peer_timeouts(mut self, interrupt: Duration, disconnect: Duration) -> Self { + self.peer_interrupt_timeout = interrupt; + self.peer_disconnect_timeout = disconnect; + self + } + + /// The graded liveness of an already-[`Synced`](ConnectionState::Synced) + /// peer (or [`PeerLink::TimedOut`] once disconnected for that reason). + /// + /// Before the handshake completes this always reports [`PeerLink::Live`] + /// (the handshake has its own [`DisconnectReason::HandshakeTimeout`]). + #[must_use] + pub fn peer_link(&self) -> PeerLink { + if matches!(self.disconnect_reason, Some(DisconnectReason::PeerTimeout)) { + return PeerLink::TimedOut; + } + if !matches!(self.state, ConnectionState::Synced) { + return PeerLink::Live; + } + let silent = self.last_recv.elapsed(); + if silent >= self.peer_disconnect_timeout { + PeerLink::TimedOut + } else if silent >= self.peer_interrupt_timeout { + PeerLink::Interrupted + } else { + PeerLink::Live + } + } + /// Override the ping interval (default 1s). Builder-style. #[must_use] pub const fn with_ping_interval(mut self, interval: Duration) -> Self { @@ -591,6 +692,16 @@ impl NetplayConnection { // EXCEPT a valid Sync (right magic + matching rom_hash), whose // source it adopts. Until then there is no peer to talk to. let remote_known = self.transport.remote_addr().is_some(); + // A datagram from the adopted peer — or ANY datagram while we are + // still a listening host awaiting adoption — is proof of life: + // refresh the liveness clock that drives `PeerLink` / + // `DisconnectReason::PeerTimeout`. Once a peer is adopted, a + // datagram from any OTHER source (a third party that can reach the + // socket) must NOT refresh the clock, or it could indefinitely mask + // a genuine peer timeout. + if self.transport.remote_addr().is_none_or(|peer| peer == from) { + self.last_recv = now; + } match msg { NetMessage::Sync { magic, rom_hash } => { if magic != NetMessage::SYNC_MAGIC { @@ -666,6 +777,19 @@ impl NetplayConnection { self.state = ConnectionState::Synced; } + // 2b. Once synced, enforce the run-time peer-liveness disconnect: a peer + // that goes silent past `peer_disconnect_timeout` is terminal. The + // softer `Interrupted` grade is surfaced via `peer_link()` without + // tearing the session down (a late packet recovers it). See + // `PeerLink` for why this is seconds, not Mesen's ~150 ms. + if matches!(self.state, ConnectionState::Synced) + && now.saturating_duration_since(self.last_recv) >= self.peer_disconnect_timeout + { + self.state = ConnectionState::Disconnected; + self.disconnect_reason = Some(DisconnectReason::PeerTimeout); + return self.state; + } + // 3. While still connecting, re-send Sync periodically and enforce the // handshake timeout. if matches!(self.state, ConnectionState::Connecting) { @@ -797,6 +921,50 @@ mod tests { assert_eq!(a.state(), ConnectionState::Synced); } + #[test] + fn synced_peer_liveness_grades_then_times_out() { + // Drive two connections to Synced, then stop pumping `b` so `a` receives + // no more datagrams. With tight (test-scale) liveness thresholds, `a` + // should progress Live -> Interrupted -> TimedOut and finally disconnect + // with `PeerTimeout`. This is the run-time RTT liveness, distinct from + // the connect-time handshake timeout. + let hash = [0x21u8; 32]; + let (ta, tb) = transport_pair(); + let mut a = NetplayConnection::with_transport(ta, hash) + .with_peer_timeouts(Duration::from_millis(60), Duration::from_millis(150)); + let mut b = NetplayConnection::with_transport(tb, hash); + + let mut rounds = 0; + while !(a.is_synced() && b.is_synced()) && rounds < 200 { + a.pump(0); + b.pump(0); + rounds += 1; + std::thread::sleep(Duration::from_millis(2)); + } + assert!(a.is_synced() && b.is_synced(), "both synced"); + assert_eq!(a.peer_link(), PeerLink::Live, "fresh sync is Live"); + + // Silence `b`: only pump `a`. It grades up as the silence grows, then + // tears down. Bound the loop so a failure can't hang. + let mut saw_interrupted = false; + let mut timed_out = false; + for _ in 0..200 { + std::thread::sleep(Duration::from_millis(5)); + let state = a.pump(0); + if a.peer_link() == PeerLink::Interrupted { + saw_interrupted = true; + } + if matches!(state, ConnectionState::Disconnected) { + timed_out = true; + break; + } + } + assert!(saw_interrupted, "peer_link passed through Interrupted"); + assert!(timed_out, "silent peer eventually times out"); + assert_eq!(a.disconnect_reason(), Some(DisconnectReason::PeerTimeout)); + assert_eq!(a.peer_link(), PeerLink::TimedOut); + } + #[test] fn host_listen_adopts_joiner_from_first_sync() { // The host binds WITHOUT a known remote; the joiner dials the host's diff --git a/crates/rustynes-netplay/src/diagnostics.rs b/crates/rustynes-netplay/src/diagnostics.rs index 60185f0f..c91508ee 100644 --- a/crates/rustynes-netplay/src/diagnostics.rs +++ b/crates/rustynes-netplay/src/diagnostics.rs @@ -20,6 +20,38 @@ use std::collections::VecDeque; +/// The graded desync verdict derived from the recent comparison history. +/// +/// This is the **clear desync surface** the frontend renders: rather than force +/// the panel to re-derive a verdict from the raw counters, [`DesyncDiagnostics`] +/// exposes one enum that folds them together with a hysteresis threshold, so a +/// single out-of-order or momentarily-late peer checksum does not flash a +/// false "desynced" banner. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DesyncStatus { + /// Every comparison so far matched — the peers are in lockstep. + InSync, + /// At least one frame has ever mismatched, but the current consecutive-run + /// is below the confirm threshold: either a transient (a match has since + /// reset the run to 0, leaving a sticky historical mismatch) or a + /// still-building run that has not yet crossed into a confirmed desync. + Suspect { + /// The current consecutive-mismatch run (0 if the last compare matched). + consecutive: u32, + /// The earliest frame that ever diverged. + first_desync_frame: u32, + }, + /// The consecutive-mismatch run has reached the confirm threshold: this is a + /// real, sustained divergence. Once entered it is **sticky** — a desync is + /// unrecoverable for a rollback session (the peers can never re-converge + /// without a full state resync), so the surface never silently downgrades a + /// confirmed [`Desynced`](Self::Desynced) back to [`Suspect`](Self::Suspect). + Desynced { + /// The earliest frame that diverged. + first_desync_frame: u32, + }, +} + /// One recorded confirmed-frame checksum comparison. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct CrcCompare { @@ -64,6 +96,15 @@ pub struct DesyncDiagnostics { /// Consecutive mismatches ending at the most recent comparison (reset to 0 /// by any match). A nonzero value means the session is currently diverged. consecutive_mismatches: u32, + /// Peak consecutive-mismatch run ever observed. Drives the sticky + /// [`DesyncStatus::Desynced`] verdict: once the run has *ever* reached + /// [`desync_threshold`](Self::desync_threshold) the session is treated as + /// confirmed-desynced even if a later stray match briefly resets the live + /// run (a rollback desync is unrecoverable, so the verdict must not flap). + peak_consecutive: u32, + /// How many consecutive mismatches confirm a real desync (hysteresis). A + /// single reordered / late peer checksum stays [`DesyncStatus::Suspect`]. + desync_threshold: u32, /// The most recent comparison, for the "local vs remote CRC" readout. last: Option, } @@ -79,15 +120,37 @@ impl DesyncDiagnostics { /// 30-frame checksum interval (~2 per second) this is ~32 s of history. pub const CAPACITY: usize = 64; - /// A fresh, empty diagnostics record. + /// Default hysteresis: how many *consecutive* mismatching comparisons + /// confirm a real desync (vs. a single reordered / late peer checksum). + /// + /// A confirmed-frame checksum is only exchanged every `checksum_interval` + /// frames and covers a *confirmed* frame, so a legitimate one-off mismatch + /// is nearly impossible on a correct implementation — but a burst-reordered + /// pair of `Checksum` messages can momentarily disagree before the deferred + /// `compare_pending_checksums` pass reconciles them. Requiring **3** in a + /// row (~1.5 s at the default interval) rejects that transient while still + /// declaring a genuine divergence promptly. + pub const DEFAULT_DESYNC_THRESHOLD: u32 = 3; + + /// A fresh, empty diagnostics record with the default desync threshold. #[must_use] pub fn new() -> Self { + Self::with_threshold(Self::DEFAULT_DESYNC_THRESHOLD) + } + + /// A fresh, empty diagnostics record with an explicit confirm threshold + /// (consecutive mismatches before [`DesyncStatus::Desynced`]). A `0` is + /// treated as `1` (the very first mismatch confirms). + #[must_use] + pub fn with_threshold(desync_threshold: u32) -> Self { Self { history: VecDeque::with_capacity(Self::CAPACITY), total: 0, mismatches: 0, first_desync_frame: None, consecutive_mismatches: 0, + peak_consecutive: 0, + desync_threshold: desync_threshold.max(1), last: None, } } @@ -115,6 +178,7 @@ impl DesyncDiagnostics { } else { self.mismatches += 1; self.consecutive_mismatches = self.consecutive_mismatches.saturating_add(1); + self.peak_consecutive = self.peak_consecutive.max(self.consecutive_mismatches); self.first_desync_frame = Some(self.first_desync_frame.map_or(frame, |f| f.min(frame))); } if self.history.len() == Self::CAPACITY { @@ -130,6 +194,51 @@ impl DesyncDiagnostics { self.first_desync_frame.is_none() } + /// The graded [`DesyncStatus`] verdict — the frontend's single desync + /// surface. Applies the hysteresis threshold and the sticky-once-confirmed + /// rule (see [`DesyncStatus`]). + #[must_use] + pub const fn status(&self) -> DesyncStatus { + match self.first_desync_frame { + None => DesyncStatus::InSync, + Some(first) => { + // Confirmed if the run has EVER reached the threshold (sticky): + // a rollback desync cannot recover, so never downgrade it. + if self.peak_consecutive >= self.desync_threshold { + DesyncStatus::Desynced { + first_desync_frame: first, + } + } else { + DesyncStatus::Suspect { + consecutive: self.consecutive_mismatches, + first_desync_frame: first, + } + } + } + } + } + + /// `true` once the consecutive-mismatch run has ever reached the confirm + /// threshold — i.e. [`status`](Self::status) is + /// [`DesyncStatus::Desynced`]. Convenience for a boolean gate. + #[must_use] + pub const fn is_desynced(&self) -> bool { + matches!(self.status(), DesyncStatus::Desynced { .. }) + } + + /// The confirm threshold in effect (consecutive mismatches → confirmed + /// desync). + #[must_use] + pub const fn desync_threshold(&self) -> u32 { + self.desync_threshold + } + + /// Peak consecutive-mismatch run ever observed (survives a later match). + #[must_use] + pub const fn peak_consecutive_mismatches(&self) -> u32 { + self.peak_consecutive + } + /// The earliest frame whose checksums disagreed, if any. #[must_use] pub const fn first_desync_frame(&self) -> Option { @@ -281,6 +390,64 @@ mod tests { assert_eq!(d.consecutive_mismatches(), 0); } + #[test] + fn status_applies_hysteresis_then_confirms_and_sticks() { + let mut d = DesyncDiagnostics::with_threshold(3); + assert_eq!(d.status(), DesyncStatus::InSync); + + // One mismatch: suspect, not confirmed (below threshold 3). + d.record(30, 1, 9, 0, 0); + assert_eq!( + d.status(), + DesyncStatus::Suspect { + consecutive: 1, + first_desync_frame: 30 + } + ); + assert!(!d.is_desynced()); + + // A match resets the live run but leaves the sticky first-desync frame: + // still merely suspect (a transient). + d.record(60, 5, 5, 0, 0); + assert_eq!( + d.status(), + DesyncStatus::Suspect { + consecutive: 0, + first_desync_frame: 30 + } + ); + + // Three consecutive mismatches reach the threshold → confirmed desync. + d.record(90, 1, 2, 0, 0); + d.record(120, 1, 2, 0, 0); + d.record(150, 1, 2, 0, 0); + assert_eq!( + d.status(), + DesyncStatus::Desynced { + first_desync_frame: 30 + } + ); + assert!(d.is_desynced()); + assert_eq!(d.peak_consecutive_mismatches(), 3); + + // A later stray match must NOT downgrade a confirmed desync (sticky). + d.record(180, 7, 7, 0, 0); + assert_eq!( + d.status(), + DesyncStatus::Desynced { + first_desync_frame: 30 + } + ); + } + + #[test] + fn threshold_zero_is_treated_as_one() { + let mut d = DesyncDiagnostics::with_threshold(0); + assert_eq!(d.desync_threshold(), 1); + d.record(30, 1, 2, 0, 0); + assert!(d.is_desynced(), "first mismatch confirms at threshold 1"); + } + #[test] fn history_preserves_order_oldest_first() { let mut d = DesyncDiagnostics::new(); diff --git a/crates/rustynes-netplay/src/lib.rs b/crates/rustynes-netplay/src/lib.rs index 83612e2a..b4889aa3 100644 --- a/crates/rustynes-netplay/src/lib.rs +++ b/crates/rustynes-netplay/src/lib.rs @@ -148,8 +148,10 @@ pub mod signaling; pub mod webrtc; #[cfg(not(target_arch = "wasm32"))] -pub use connection::{ConnectionState, DisconnectReason, NetplayConnection, UdpTransport}; -pub use diagnostics::{CrcCompare, DesyncDiagnostics}; +pub use connection::{ + ConnectionState, DisconnectReason, NetplayConnection, PeerLink, UdpTransport, +}; +pub use diagnostics::{CrcCompare, DesyncDiagnostics, DesyncStatus}; #[cfg(not(target_arch = "wasm32"))] pub use mesh_net::{MeshError, MeshHost, MeshJoiner, UdpMeshTransport}; pub use message::{NetMessage, PROTOCOL_VERSION, fnv1a64}; @@ -159,7 +161,7 @@ pub use nat_connect::{NatConfig, NatConnect, NatPhase}; pub use relay::{RelayUdpSocket, TurnClient, TurnConfig}; pub use rng::SplitMix64; pub use session::{AdvanceOutcome, MAX_PLAYERS, NetplayError, RollbackSession, SessionConfig}; -pub use signaling::{Action, ClientId, Relay, SignalMessage}; +pub use signaling::{Action, ClientId, MAX_ROOM_LIST, Relay, RoomInfo, SignalMessage}; #[cfg(all(not(target_arch = "wasm32"), feature = "netplay-client"))] pub use signaling_client::{SignalEvent, SignalingClient}; pub use spectator::{SpectatorConfig, SpectatorOutcome, SpectatorSession}; diff --git a/crates/rustynes-netplay/src/signaling.rs b/crates/rustynes-netplay/src/signaling.rs index 2d8a97d0..a0c40dfd 100644 --- a/crates/rustynes-netplay/src/signaling.rs +++ b/crates/rustynes-netplay/src/signaling.rs @@ -61,6 +61,66 @@ use std::collections::HashMap; /// the transport. pub type ClientId = u64; +/// The maximum number of rooms a single [`SignalMessage::RoomList`] carries. +/// +/// Both the encoder and [`SignalMessage::parse`] cap at this count, so a +/// malicious / oversized frame cannot drive an unbounded allocation (a `DoS`). A +/// public lobby with more than this many concurrent open rooms simply truncates +/// the browse list — matchmaking ([`SignalMessage::QuickMatch`]) still reaches +/// them. +pub const MAX_ROOM_LIST: usize = 256; + +/// One open room's public metadata, for the lobby browser +/// ([`SignalMessage::RoomList`]). +/// +/// Carries only what a joiner needs to decide whether to join: the room `code`, +/// how many players are present vs. the room's capacity, and the `rom_hash` the +/// room is playing (so the browser can label / filter by game). It deliberately +/// exposes **no** SDP, ICE, or per-client identity — the lobby is a directory, +/// not a transport. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RoomInfo { + /// The room code a joiner passes to [`SignalMessage::Join`]. + pub code: String, + /// Players currently in the room (`1..=max_players`). + pub players: u8, + /// The room's total capacity (2..=4). + pub max_players: u8, + /// Hex SHA-256 of the ROM the room is playing (may be empty for a room that + /// announced no hash). + pub rom_hash: String, +} + +impl RoomInfo { + /// `true` if the room has a free slot a new peer could take. + #[must_use] + pub const fn is_joinable(&self) -> bool { + self.players < self.max_players + } + + /// Encode as a flat JSON object (an element of the `rooms` array). + fn to_json_object(&self) -> String { + format!( + r#"{{"room":{},"players":{},"max_players":{},"rom_hash":{}}}"#, + json_quote(&self.code), + self.players, + self.max_players, + json_quote(&self.rom_hash) + ) + } + + /// Parse one flat JSON object (a `rooms` array element). Returns `None` if a + /// required field is missing / malformed. + fn parse_object(obj: &str) -> Option { + Some(Self { + code: json_str_field(obj, "room")?, + players: u8::try_from(json_num_field(obj, "players")?).ok()?, + max_players: u8::try_from(json_num_field(obj, "max_players")?).ok()?, + rom_hash: json_str_field(obj, "rom_hash").unwrap_or_default(), + }) + } +} + /// A signaling message, in both directions. /// /// Parsed from / encoded to the JSON wire form (see the module docs). The @@ -157,6 +217,49 @@ pub enum SignalMessage { /// A short human-readable reason. reason: String, }, + /// Client → server: request the current lobby directory — the open, + /// joinable rooms — for a **browse-and-join** UI (v2.2.0). Optionally + /// filtered to rooms playing a specific `rom_hash` (empty = all games). The + /// server replies with a [`RoomList`](Self::RoomList). Carrying no per-room + /// identity, this is safe to answer for any connected client. + ListRooms { + /// Restrict the listing to rooms whose `rom_hash` matches (hex; empty = + /// no filter). + rom_hash: String, + }, + /// Server → client: the open, joinable rooms (a [`ListRooms`](Self::ListRooms) + /// reply, capped at [`MAX_ROOM_LIST`]). Each [`RoomInfo`] carries the code a + /// joiner passes to [`Join`](Self::Join). + RoomList { + /// The open rooms (may be empty). + rooms: Vec, + }, + /// Client → server: **matchmaking** — put me into *any* open room playing + /// `rom_hash` with a free slot, creating a fresh room if none exists + /// (v2.2.0). The server resolves a target room and joins the client to it, + /// replying with [`Matched`](Self::Matched) (which names the resolved room + /// code) and nudging the room's existing peers exactly like a normal + /// [`Join`](Self::Join). This is the "quick play" path — the user never sees + /// or types a room code. + QuickMatch { + /// Hex SHA-256 of the ROM to match on (a room's game must match). + rom_hash: String, + /// The player count to request when *creating* a new room (2..=4). + max_players: u8, + }, + /// Server → client: a [`QuickMatch`](Self::QuickMatch) landed you in room + /// `room` at `slot` (which holds `max_players`). Distinct from + /// [`Joined`](Self::Joined) only in that it also reports the resolved room + /// **code**, so the matchmade client can display / share it. WebRTC pairing + /// then proceeds identically (lower slot offers to higher). + Matched { + /// The resolved room code (existing or freshly created). + room: String, + /// The assigned slot (`0..max_players`). + slot: u8, + /// The room's total capacity (2..=4). + max_players: u8, + }, } impl SignalMessage { @@ -220,6 +323,25 @@ impl SignalMessage { "error" => Some(Self::Error { reason: json_str_field(json, "reason").unwrap_or_default(), }), + "list-rooms" => Some(Self::ListRooms { + rom_hash: json_str_field(json, "rom_hash").unwrap_or_default(), + }), + "room-list" => Some(Self::RoomList { + rooms: parse_room_array(json), + }), + "quick-match" => Some(Self::QuickMatch { + rom_hash: json_str_field(json, "rom_hash").unwrap_or_default(), + max_players: json_num_field(json, "max_players") + .and_then(|n| u8::try_from(n).ok()) + .unwrap_or(2), + }), + "matched" => Some(Self::Matched { + room: json_str_field(json, "room")?, + slot: u8::try_from(json_num_field(json, "slot")?).ok()?, + max_players: json_num_field(json, "max_players") + .and_then(|n| u8::try_from(n).ok()) + .unwrap_or(2), + }), _ => None, } } @@ -277,8 +399,101 @@ impl SignalMessage { Self::Error { reason } => { format!(r#"{{"type":"error","reason":{}}}"#, json_quote(reason)) } + Self::ListRooms { rom_hash } => { + format!( + r#"{{"type":"list-rooms","rom_hash":{}}}"#, + json_quote(rom_hash) + ) + } + Self::RoomList { rooms } => { + let mut out = String::from(r#"{"type":"room-list","rooms":["#); + for (i, r) in rooms.iter().take(MAX_ROOM_LIST).enumerate() { + if i > 0 { + out.push(','); + } + out.push_str(&r.to_json_object()); + } + out.push_str("]}"); + out + } + Self::QuickMatch { + rom_hash, + max_players, + } => format!( + r#"{{"type":"quick-match","rom_hash":{},"max_players":{max_players}}}"#, + json_quote(rom_hash) + ), + Self::Matched { + room, + slot, + max_players, + } => format!( + r#"{{"type":"matched","room":{},"slot":{slot},"max_players":{max_players}}}"#, + json_quote(room) + ), + } + } +} + +/// Split the `rooms` array of a `room-list` frame into [`RoomInfo`]s. +/// +/// Extracts the `"rooms":[ ... ]` array substring, walks it at brace depth 1 to +/// slice out each top-level `{ ... }` element, and parses each with +/// [`RoomInfo::parse_object`]. Malformed elements are skipped (never panic); the +/// count is capped at [`MAX_ROOM_LIST`] so an oversized frame cannot force an +/// unbounded allocation. Depth tracking ignores braces inside quoted strings so +/// a `rom_hash`/code value containing a brace cannot desync the walk. +fn parse_room_array(json: &str) -> Vec { + let Some(arr_start) = field_value_start(json, "rooms") else { + return Vec::new(); + }; + // The value must open with '['. + let Some(after_bracket) = arr_start.strip_prefix('[') else { + return Vec::new(); + }; + + let mut rooms = Vec::new(); + let mut depth = 0i32; + let mut obj_start: Option = None; + let mut in_string = false; + let mut escaped = false; + for (i, c) in after_bracket.char_indices() { + if in_string { + if escaped { + escaped = false; + } else if c == '\\' { + escaped = true; + } else if c == '"' { + in_string = false; + } + continue; + } + match c { + '"' => in_string = true, + '{' => { + if depth == 0 { + obj_start = Some(i); + } + depth += 1; + } + '}' => { + depth -= 1; + if depth == 0 + && let Some(start) = obj_start.take() + && let Some(room) = RoomInfo::parse_object(&after_bracket[start..=i]) + { + rooms.push(room); + if rooms.len() >= MAX_ROOM_LIST { + break; + } + } + } + // The closing ']' at depth 0 ends the array. + ']' if depth == 0 => break, + _ => {} } } + rooms } /// One action the async layer must perform after [`Relay::handle`]: send a @@ -324,6 +539,10 @@ pub struct Relay { /// Reverse index: which room each connected client is in (for O(1) /// disconnect handling + relay). client_room: HashMap, + /// Monotonic counter feeding the generated room codes for matchmaking + /// ([`SignalMessage::QuickMatch`] rooms). Deterministic (no RNG in this + /// I/O-free core), so the server behaves reproducibly in tests. + quick_match_seq: u64, } impl Relay { @@ -357,11 +576,16 @@ impl Relay { room, rom_hash, max_players, - } => self.handle_join(client, room, rom_hash, max_players), + } => self.handle_join(client, &room, &rom_hash, max_players), relayable @ (SignalMessage::Offer { .. } | SignalMessage::Answer { .. } | SignalMessage::Candidate { .. } | SignalMessage::PublicAddr { .. }) => self.relay(client, &relayable), + SignalMessage::ListRooms { rom_hash } => self.handle_list_rooms(client, &rom_hash), + SignalMessage::QuickMatch { + rom_hash, + max_players, + } => self.handle_quick_match(client, &rom_hash, max_players), // Server→client message types arriving FROM a client are not // expected; ignore them rather than trust them. _ => Vec::new(), @@ -405,11 +629,79 @@ impl Relay { fn handle_join( &mut self, client: ClientId, - room_code: String, - rom_hash: String, + room_code: &str, + rom_hash: &str, req_max_players: u8, ) -> Vec { - let room = self.rooms.entry(room_code.clone()).or_default(); + match self.add_to_room(client, room_code, rom_hash, req_max_players) { + Ok((slot, max_players, existing_peers)) => { + Self::join_actions(client, slot, max_players, &existing_peers, None) + } + Err(reason) => Self::reject(client, reason), + } + } + + /// Reply to a [`SignalMessage::ListRooms`] with the open, joinable rooms — + /// the lobby directory (v2.2.0). Only sent to the requester. An optional + /// non-empty `rom_hash` filter restricts the listing to rooms playing that + /// game. Full rooms are omitted (a browse-and-join UI only shows enterable + /// rooms). Capped at [`MAX_ROOM_LIST`]. + fn handle_list_rooms(&self, client: ClientId, rom_hash: &str) -> Vec { + vec![Action::Send { + to: client, + msg: SignalMessage::RoomList { + rooms: self.open_rooms(rom_hash), + }, + }] + } + + /// Handle a [`SignalMessage::QuickMatch`]: join the client to any open room + /// playing `rom_hash`, or create a fresh room if none exists (v2.2.0). The + /// client learns the resolved room via [`SignalMessage::Matched`]; existing + /// peers get the usual `PeerJoined` nudge. + fn handle_quick_match( + &mut self, + client: ClientId, + rom_hash: &str, + max_players: u8, + ) -> Vec { + // Prefer an existing open room for this exact game with a free slot. + // Scan `self.rooms` directly (not `open_rooms`, which truncates at + // `MAX_ROOM_LIST`) so matchmaking reaches every joinable room even in a + // lobby with more than 256 open rooms — matching the `MAX_ROOM_LIST` + // contract — and avoids allocating/cloning a `Vec` to pick one. + let target = self + .rooms + .iter() + .find(|(_, r)| { + !r.slots.is_empty() + && r.slots.len() < usize::from(r.max_players) + && (rom_hash.is_empty() || r.rom_hash == rom_hash) + }) + .map(|(code, _)| code.clone()); + + let room_code = target.unwrap_or_else(|| self.next_room_code()); + match self.add_to_room(client, &room_code, rom_hash, max_players) { + Ok((slot, max, existing_peers)) => { + Self::join_actions(client, slot, max, &existing_peers, Some(room_code)) + } + // A race (the chosen room filled between selection and add) falls + // back to a rejection the client can retry — never panics. + Err(reason) => Self::reject(client, reason), + } + } + + /// The shared room-entry primitive: create/lookup the room, enforce capacity + /// + rom-hash matching, add the client, and return `(slot, max_players, + /// existing_peers)` — or an error reason for a full / mismatched room. + fn add_to_room( + &mut self, + client: ClientId, + room_code: &str, + rom_hash: &str, + req_max_players: u8, + ) -> Result<(u8, u8, Vec), &'static str> { + let room = self.rooms.entry(room_code.to_string()).or_default(); // The first joiner sets the room size (clamped 2..=4); later joiners // inherit it. @@ -418,56 +710,104 @@ impl Relay { } let max_players = room.max_players; - // Reject a joiner past the room's player count. + // Reject a joiner past the room's player count. A freshly-created room + // (empty `slots`) can never be full here — `max_players` was just clamped + // to 2..=4 above — so no ghost-room cleanup is needed on this path. if room.slots.len() >= usize::from(max_players) { - return vec![ - Action::Send { - to: client, - msg: SignalMessage::Error { - reason: "room full".to_string(), - }, - }, - Action::Close { who: client }, - ]; + return Err("room full"); } // The first joiner sets the room's rom hash; the rest must match it // (a non-empty mismatch is rejected; an empty hash skips the check). if room.slots.is_empty() { - room.rom_hash = rom_hash; + room.rom_hash = rom_hash.to_string(); } else if !room.rom_hash.is_empty() && !rom_hash.is_empty() && room.rom_hash != rom_hash { - return vec![ - Action::Send { - to: client, - msg: SignalMessage::Error { - reason: "rom mismatch".to_string(), - }, - }, - Action::Close { who: client }, - ]; + return Err("rom mismatch"); } let slot = u8::try_from(room.slots.len()).unwrap_or(u8::MAX); - // Snapshot the existing peers before adding the newcomer; each gets a - // PeerJoined nudge so it offers to the newcomer (lower offers to higher). let existing_peers: Vec = room.slots.clone(); room.slots.push(client); - self.client_room.insert(client, room_code); + self.client_room.insert(client, room_code.to_string()); + Ok((slot, max_players, existing_peers)) + } + /// Build the actions for a successful room entry: tell the newcomer its slot + /// (via [`Matched`](SignalMessage::Matched) when `matched_room` is set — the + /// `QuickMatch` path — else [`Joined`](SignalMessage::Joined)), then nudge each + /// existing peer with `PeerJoined` (lower slot offers to higher). + fn join_actions( + client: ClientId, + slot: u8, + max_players: u8, + existing_peers: &[ClientId], + matched_room: Option, + ) -> Vec { + let self_msg = matched_room.map_or(SignalMessage::Joined { slot, max_players }, |room| { + SignalMessage::Matched { + room, + slot, + max_players, + } + }); let mut actions = vec![Action::Send { to: client, - msg: SignalMessage::Joined { slot, max_players }, + msg: self_msg, }]; - for peer in existing_peers { + for &peer in existing_peers { actions.push(Action::Send { to: peer, msg: SignalMessage::PeerJoined { slot }, }); } - actions } + /// A room-full / rom-mismatch rejection: an `Error` then `Close`. + fn reject(client: ClientId, reason: &str) -> Vec { + vec![ + Action::Send { + to: client, + msg: SignalMessage::Error { + reason: reason.to_string(), + }, + }, + Action::Close { who: client }, + ] + } + + /// The open (has a free slot), optionally `rom_hash`-filtered rooms as + /// [`RoomInfo`]s, capped at [`MAX_ROOM_LIST`]. The order is unspecified + /// (`HashMap` iteration); a client sorts for display. + #[must_use] + pub fn open_rooms(&self, rom_hash_filter: &str) -> Vec { + self.rooms + .iter() + .filter(|(_, r)| !r.slots.is_empty() && r.slots.len() < usize::from(r.max_players)) + .filter(|(_, r)| rom_hash_filter.is_empty() || r.rom_hash == rom_hash_filter) + .take(MAX_ROOM_LIST) + .map(|(code, r)| RoomInfo { + code: code.clone(), + players: u8::try_from(r.slots.len()).unwrap_or(u8::MAX), + max_players: r.max_players, + rom_hash: r.rom_hash.clone(), + }) + .collect() + } + + /// Generate the next unused matchmaking room code from the monotonic + /// counter, e.g. `QM-000001`. Bumps until an unused code is found so a + /// generated code never collides with a live room. + fn next_room_code(&mut self) -> String { + loop { + self.quick_match_seq = self.quick_match_seq.wrapping_add(1); + let code = format!("QM-{:06}", self.quick_match_seq); + if !self.rooms.contains_key(&code) { + return code; + } + } + } + /// Relay an `Offer` / `Answer` / `Candidate` to the peer named by its `to` /// slot. If `to` is the sender's own slot (the legacy 2-peer form encoded /// `to = 0`), fall back to forwarding to every *other* peer — which in a @@ -944,6 +1284,152 @@ mod tests { ); } + #[test] + fn lobby_messages_roundtrip() { + let msgs = [ + SignalMessage::ListRooms { + rom_hash: "deadbeef".into(), + }, + SignalMessage::ListRooms { + rom_hash: String::new(), + }, + SignalMessage::RoomList { rooms: Vec::new() }, + SignalMessage::RoomList { + rooms: vec![ + RoomInfo { + code: "AB12CD".into(), + players: 1, + max_players: 2, + rom_hash: "deadbeef".into(), + }, + RoomInfo { + code: "QM-000001".into(), + players: 3, + max_players: 4, + rom_hash: String::new(), + }, + ], + }, + SignalMessage::QuickMatch { + rom_hash: "cafe".into(), + max_players: 3, + }, + SignalMessage::Matched { + room: "QM-000007".into(), + slot: 2, + max_players: 4, + }, + ]; + for m in &msgs { + let json = m.to_json(); + let back = + SignalMessage::parse(&json).unwrap_or_else(|| panic!("parse failed for {json}")); + assert_eq!(*m, back, "roundtrip mismatch for {json}"); + } + } + + #[test] + fn list_rooms_returns_only_open_matching_rooms() { + let mut relay = Relay::new(); + // A 2-player room for game "aa" with one peer (open). + let _ = relay.handle(1, join("open", "aa")); + // A full 2-player room for game "bb". + let _ = relay.handle(2, join("full", "bb")); + let _ = relay.handle(3, join("full", "bb")); + + // Unfiltered: only the open room is listed (the full one is omitted). + let acts = relay.handle( + 1, + SignalMessage::ListRooms { + rom_hash: String::new(), + }, + ); + let Action::Send { + to: 1, + msg: SignalMessage::RoomList { rooms }, + } = &acts[0] + else { + panic!("expected a room-list reply, got {acts:?}"); + }; + assert_eq!(rooms.len(), 1); + assert_eq!(rooms[0].code, "open"); + assert_eq!(rooms[0].players, 1); + assert!(rooms[0].is_joinable()); + + // Filtered to game "bb": the only "bb" room is full, so the list is empty. + let acts = relay.handle( + 1, + SignalMessage::ListRooms { + rom_hash: "bb".into(), + }, + ); + let Action::Send { + msg: SignalMessage::RoomList { rooms }, + .. + } = &acts[0] + else { + panic!("expected a room-list reply"); + }; + assert!(rooms.is_empty(), "the matching room is full → omitted"); + } + + #[test] + fn quick_match_joins_an_open_room_then_creates_one() { + let mut relay = Relay::new(); + // Host opens a room for game "aa". + let _ = relay.handle(1, join_n("host", "aa", 2)); + + // A quick-match for "aa" lands in the existing open room at slot 1 and + // the existing peer is nudged. + let acts = relay.handle( + 2, + SignalMessage::QuickMatch { + rom_hash: "aa".into(), + max_players: 2, + }, + ); + assert!(acts.contains(&Action::Send { + to: 2, + msg: SignalMessage::Matched { + room: "host".into(), + slot: 1, + max_players: 2, + }, + })); + assert!(acts.contains(&Action::Send { + to: 1, + msg: SignalMessage::PeerJoined { slot: 1 }, + })); + + // A quick-match for a DIFFERENT game finds no open room → a new one is + // created (a generated QM- code) with the client at slot 0. + let acts = relay.handle( + 3, + SignalMessage::QuickMatch { + rom_hash: "zz".into(), + max_players: 2, + }, + ); + let Action::Send { + to: 3, + msg: + SignalMessage::Matched { + room, + slot, + max_players, + }, + } = &acts[0] + else { + panic!("expected a matched reply, got {acts:?}"); + }; + assert!( + room.starts_with("QM-"), + "created room has a generated code: {room}" + ); + assert_eq!(*slot, 0); + assert_eq!(*max_players, 2); + } + fn join(room: &str, hash: &str) -> SignalMessage { join_n(room, hash, 2) } diff --git a/crates/rustynes-netplay/src/spectator.rs b/crates/rustynes-netplay/src/spectator.rs index 4dbe86ad..cd03b022 100644 --- a/crates/rustynes-netplay/src/spectator.rs +++ b/crates/rustynes-netplay/src/spectator.rs @@ -64,11 +64,44 @@ pub struct SpectatorConfig { /// when a frame is fully confirmed (all `num_players` inputs present) and /// whether to enable the Four Score adapter. Defaults to `2`. pub num_players: u8, + /// **Delayed-stream buffer depth**, in frames. A spectator already lags the + /// live match by `input_delay + network-latency` frames (it can only show a + /// frame it has fully received); `delay_frames` adds a *further* intentional + /// hold so the spectator only reveals frame `f` once frame `f + delay_frames` + /// is also confirmed. Defaults to `0` (show as soon as confirmed). + /// + /// # Why an extra delay + /// + /// - **Anti-spoiler / broadcast delay.** A tournament stream commonly runs a + /// spectator several seconds behind so a caster (or a co-spectator on the + /// same feed) cannot leak an imminent input to a player. + /// - **Jitter smoothing.** Holding a small backlog of confirmed frames lets + /// the frontend present at a steady cadence even when confirmations arrive + /// bursty over a lossy relay, instead of stalling then fast-forwarding. + /// + /// This is purely a *presentation* delay: the emulated frames are still + /// produced byte-identically and in order — only the moment each is revealed + /// moves later. It never sends anything, so it cannot perturb the match. The + /// value is clamped to [`SpectatorConfig::MAX_DELAY_FRAMES`] on use so it can + /// never push the reveal point past the bounded lookahead window. + pub delay_frames: u32, +} + +impl SpectatorConfig { + /// Upper bound on [`delay_frames`](Self::delay_frames). Kept comfortably + /// below `MAX_SPECTATOR_FRAME_LOOKAHEAD` so the buffered-but-unshown + /// backlog always fits inside the frames the session will accept, and a + /// misconfigured huge delay cannot wedge the spectator permanently behind + /// the horizon. 512 frames ≈ 8.5 s at 60 Hz — ample for a broadcast delay. + pub const MAX_DELAY_FRAMES: u32 = 512; } impl Default for SpectatorConfig { fn default() -> Self { - Self { num_players: 2 } + Self { + num_players: 2, + delay_frames: 0, + } } } @@ -165,6 +198,28 @@ impl SpectatorSession { self.config.num_players } + /// The configured delayed-stream buffer depth, clamped to + /// [`SpectatorConfig::MAX_DELAY_FRAMES`]. See + /// [`SpectatorConfig::delay_frames`]. + #[must_use] + pub const fn delay_frames(&self) -> u32 { + let d = self.config.delay_frames; + if d > SpectatorConfig::MAX_DELAY_FRAMES { + SpectatorConfig::MAX_DELAY_FRAMES + } else { + d + } + } + + /// The newest frame the spectator is currently permitted to *reveal*: the + /// confirmed horizon pulled back by [`delay_frames`](Self::delay_frames). + /// `None` until enough frames past the delay have been confirmed. + #[must_use] + fn reveal_horizon(&self) -> Option { + self.last_confirmed_frame + .and_then(|c| c.checked_sub(self.delay_frames())) + } + /// `true` once a `Sync` with the matching ROM has been observed. #[must_use] pub const fn is_synced(&self) -> bool { @@ -175,9 +230,9 @@ impl SpectatorSession { /// how far the spectator is *behind* the live match. The frontend can /// fast-forward (call [`Self::advance`] repeatedly) to catch up. #[must_use] - pub const fn pending_frames(&self) -> u32 { - match self.last_confirmed_frame { - Some(c) if c >= self.current_frame => c - self.current_frame + 1, + pub fn pending_frames(&self) -> u32 { + match self.reveal_horizon() { + Some(h) if h >= self.current_frame => h - self.current_frame + 1, _ => 0, } } @@ -204,9 +259,13 @@ impl SpectatorSession { self.ingest(); self.recompute_confirmed(); - // Show the next frame only once every player's real input is known. + // Show the next frame only once every player's real input is known AND + // it sits at or behind the (optionally delayed) reveal horizon. With + // `delay_frames == 0` this is exactly "as soon as confirmed"; with a + // positive delay the frame is held until `frame + delay_frames` has also + // been confirmed (the delayed-stream / broadcast-delay buffer). let frame = self.current_frame; - let ready = self.last_confirmed_frame.is_some_and(|c| frame <= c); + let ready = self.reveal_horizon().is_some_and(|h| frame <= h); if !ready { return SpectatorOutcome::default(); } @@ -378,6 +437,78 @@ mod tests { assert_eq!(spec.pending_frames(), 0); } + /// A delayed-stream spectator holds each confirmed frame until `delay_frames` + /// *further* frames are also confirmed, then reveals frames in order and + /// byte-identically to a no-delay spectator (the delay is presentation-only). + #[test] + fn spectator_delay_buffer_holds_then_reveals() { + const DELAY: u32 = 3; + let rom = synth_nrom(); + let hash = *Nes::from_rom(&rom).unwrap().rom_sha256(); + let (spec_link, mut feeder) = MemoryTransport::pair(LinkConditions::PERFECT, 7); + let mut spec = SpectatorSession::new( + SpectatorConfig { + num_players: 2, + delay_frames: DELAY, + }, + spec_link, + hash, + ); + let mut nes = Nes::from_rom(&rom).unwrap(); + + // Confirm frames 0..=2 (fewer than DELAY past frame 0): nothing reveals. + for f in 0..DELAY { + for player in 0..2 { + feeder.send(&NetMessage::Input { + player, + frame: f, + input: 0, + }); + } + } + for _ in 0..DELAY { + assert!( + !spec.advance(&mut nes).produced_frame, + "nothing reveals until delay_frames past frame 0 are confirmed" + ); + } + assert_eq!(spec.pending_frames(), 0, "reveal horizon not reached yet"); + + // Confirm frame 3 (== frame 0 + DELAY): frame 0 may now be revealed. + for player in 0..2 { + feeder.send(&NetMessage::Input { + player, + frame: DELAY, + input: 0, + }); + } + let out = spec.advance(&mut nes); + assert!( + out.produced_frame, + "frame 0 reveals once frame DELAY confirmed" + ); + assert_eq!(out.frame, 0); + assert_eq!(spec.delay_frames(), DELAY); + } + + /// An absurd `delay_frames` is clamped to `MAX_DELAY_FRAMES`, so it cannot + /// push the reveal point past the accept window. + #[test] + fn spectator_delay_is_clamped() { + let rom = synth_nrom(); + let hash = *Nes::from_rom(&rom).unwrap().rom_sha256(); + let (a, _b) = MemoryTransport::pair(LinkConditions::PERFECT, 1); + let spec = SpectatorSession::new( + SpectatorConfig { + num_players: 2, + delay_frames: u32::MAX, + }, + a, + hash, + ); + assert_eq!(spec.delay_frames(), SpectatorConfig::MAX_DELAY_FRAMES); + } + /// The load-bearing determinism-safety property: a spectator fed the SAME /// confirmed per-player input stream reaches a **byte-identical /// framebuffer** to a reference `Nes` run directly over those inputs. This @@ -392,7 +523,14 @@ mod tests { let rom = synth_nrom(); let hash = *Nes::from_rom(&rom).unwrap().rom_sha256(); let (spec_link, mut feeder) = MemoryTransport::pair(LinkConditions::PERFECT, 7); - let mut spec = SpectatorSession::new(SpectatorConfig { num_players: 2 }, spec_link, hash); + let mut spec = SpectatorSession::new( + SpectatorConfig { + num_players: 2, + delay_frames: 0, + }, + spec_link, + hash, + ); let mut nes = Nes::from_rom(&rom).unwrap(); // An absurd frame index (near u32::MAX) for a valid player. The horizon @@ -448,7 +586,14 @@ mod tests { let rom = synth_nrom(); let hash = *Nes::from_rom(&rom).unwrap().rom_sha256(); let (spec_link, mut feeder) = MemoryTransport::pair(LinkConditions::PERFECT, 7); - let mut spec = SpectatorSession::new(SpectatorConfig { num_players: 2 }, spec_link, hash); + let mut spec = SpectatorSession::new( + SpectatorConfig { + num_players: 2, + delay_frames: 0, + }, + spec_link, + hash, + ); let mut nes = Nes::from_rom(&rom).unwrap(); // Confirm + show frame 0 (a 2-player match). @@ -505,7 +650,14 @@ mod tests { // Spectator: feed the same stream over the transport. `feeder.send` // pushes onto the spectator's inbound wire. let (spec_link, mut feeder) = MemoryTransport::pair(LinkConditions::PERFECT, 7); - let mut spec = SpectatorSession::new(SpectatorConfig { num_players: 2 }, spec_link, hash); + let mut spec = SpectatorSession::new( + SpectatorConfig { + num_players: 2, + delay_frames: 0, + }, + spec_link, + hash, + ); let mut spec_nes = Nes::from_rom(&rom).unwrap(); spec_nes.power_cycle(); diff --git a/docs/creator-tools.md b/docs/creator-tools.md new file mode 100644 index 00000000..fdc4b8ee --- /dev/null +++ b/docs/creator-tools.md @@ -0,0 +1,56 @@ +# Creator Tools + +**References:** the authoritative detail is in [`frontend.md`](frontend.md); scripting has its own spec in [`scripting.md`](scripting.md). This page is a curated handbook entry point. + +Beyond just running games, RustyNES ships a suite of tools for TAS authors, +ROM hackers, cheat writers, and the merely curious. They are all **frontend-side** +and, where they read the emulator, read-only against the deterministic core (the +overlay never advances emulator-visible state). Most are surfaced from the **Tools** +and **Debug** menus. + +## Debugger + +A Mesen2-style debugging overlay (toggle with `` ` ``) layers chip-inspection +panels over the running game: CPU (registers + disassembly), PPU, OAM, APU, +Memory (+ a Memory Compare), Mapper, an execution Trace, Watches, Events, and an +NSF panel. Chip panels need `&mut Nes` and a per-frame core poll, so they render +only while the overlay is visible. See [`frontend.md`](frontend.md) § +Chip panels vs tool panels and [`user-guide/debugger.md`](user-guide/debugger.md). + +## Cheats & Game Genie + +The **Cheats** tool panel edits Game Genie and raw address/value codes. Codes are +keyed on the ROM's identifying CRC32s (the header-excluded key *and* the full-file +No-Intro key), so a code matches whichever dump variant the user has. A curated, +header-robust Game Genie code database ships with the app (v2.1.3 "Codex"). + +## ROM Info & ROM Database + +- **ROM Database** (editable) — view and correct the loaded ROM's per-game DB + entry (mirroring / region / mapper / submapper / title), persisted to a user + overlay. Mirroring applies live; header overrides apply on next load. +- **ROM Info** (read-only, v2.2.0 "Capstone") — a purely observational companion: + the loaded ROM's two identity CRC32s (game-DB key + No-Intro full-file key), + its SHA-256, its effective DB entry, and its decoded cartridge header (mapper, + region, PRG-ROM / CHR-ROM sizes). See [`frontend.md`](frontend.md). + +## Movies & TAStudio + +RustyNES records, plays, and branches input movies (`.rnm`), and imports foreign +formats (`.fm2` / `.bk2` and legacy `.fcm` / `.fmv` / `.mc2` / `.vmv`). The +**TAStudio** piano-roll editor gives a frame-by-frame input timeline with seeking +and branching. Determinism (same ROM + seed + input ⇒ byte-identical frames) is +what makes movie replay exact — see [`testing-strategy.md`](testing-strategy.md) +for the determinism contract. The `.rnm` deserializer is hardened against +malformed input (bounded allocations; fuzz-tested, see `fuzz/`). + +## Scripting (Lua) + +An embedded Lua engine exposes an emulation API (memory peek/poke, frame hooks, +input, drawing) for tool-assisted automation and overlays. See the dedicated +[Scripting (Lua)](scripting.md) page. + +## RetroAchievements + +A built-in RetroAchievements client (login, achievement list, hardcore mode). +See [RetroAchievements](cheevos-browser.md). diff --git a/docs/crt-composite.md b/docs/crt-composite.md new file mode 100644 index 00000000..935781b6 --- /dev/null +++ b/docs/crt-composite.md @@ -0,0 +1,48 @@ +# CRT / Composite Video + +**References:** the authoritative detail is in [`frontend.md`](frontend.md) (§ Display pipeline / shader ladder); the palette generator is in [`ppu-2c02.md`](ppu-2c02.md). This page is a curated handbook entry point. + +RustyNES reproduces the NES's analog look with GPU post-passes over the PPU +framebuffer. Every filter here is **display-only**: it never touches the core, the +index framebuffer, audio, or any golden vector — the `visual_regression` corpus +stays byte-identical with any filter active (introduced in v2.1.2 "Prism"). + +## In-core generated NTSC palette + +Rather than ship a hand-authored RGB table, RustyNES *generates* its base palette +from a model of the 2C02's composite-video output: +`rustynes_ppu::generate_base_palette` (a Bisqwit / ares YIQ integration), with the +standard 2C02 composite emphasis applied. This is a core function (deterministic, +no GPU), so the same colors appear headless and on screen. + +## The shader ladder (v2.1.2 "Prism") + +Presentation filters run as GPU post-passes. Two selection surfaces coexist: + +- **Legacy single-select** (Settings → Video): an **NTSC filter** dropdown + (`[graphics] ntsc_filter` = `off` / `composite` / `rgb` / `composite-rt`) plus a + binary **CRT** toggle (`crt_filter` + `crt_scanline`). The `composite-rt` + (Bisqwit) option is the only place the Bisqwit picture knobs (contrast / + saturation / brightness / hue) have a UI. +- **Composable stack** (Settings → Shaders): add / reorder / toggle / remove any + of the six `BuiltinPass` variants, each with `#pragma parameter` sliders, plus a + preset bank and constrained `.slangp` / `.cgp` import. + +**Precedence:** when the stack has any enabled pass it owns the post-process path +and the legacy single-select is bypassed; otherwise the legacy filter applies. +The fixed render order is: stack → CRT → Bisqwit → NTSC → direct blit +(`Gfx::render_with_overlay`). + +### The three composite rungs + +1. **`Ntsc`** — a cheap simplified blur (5-tap + scanline dim + coarse fringe); + not a real signal encode/decode. +2. **`Lmp88959`** — a real single-pass composite encode→decode (the EMMIR/LMP + model), an RGBA post-pass that composes anywhere in the stack. +3. **`CompositeRt`** — the faithful **Bisqwit** per-dot composite + (`bisqwit.wgsl`, `rustynes-gfx-shaders`); it samples the `R16Uint` palette- + **index** framebuffer, so it must be the first pass in the stack. + +The shared WGSL lives in `crates/rustynes-gfx-shaders`. See +[`frontend.md`](frontend.md) for the full pipeline, the CRT / scanline passes, +and the preset / import machinery. diff --git a/docs/expansion-audio.md b/docs/expansion-audio.md new file mode 100644 index 00000000..eee271ac --- /dev/null +++ b/docs/expansion-audio.md @@ -0,0 +1,61 @@ +# Expansion Audio + +**References:** the authoritative spec is [`apu-2a03.md` § Expansion-chip audio](apu-2a03.md); levels are tracked in [`accuracy-ledger.md`](accuracy-ledger.md). This page is a curated handbook entry point. + +Several NES cartridge boards carry their **own** sound hardware that mixes into +the console's external-audio input alongside the 2A03. RustyNES synthesizes six +such expansion chips and sums each into the mix through the +`Mapper::mix_audio(&mut self) -> i16` hook (default `0` for boards with no audio +hardware). Each synth core lives in the **owning mapper crate**, not the 2A03 +APU crate, because it is cartridge hardware. + +## Supported chips + +| Chip | Channels | Core | Clocking | +|---|---|---|---| +| VRC6 | 2 pulse + 1 saw | `Vrc6Pulse` ×2 + `Vrc6Saw` (`rustynes-mappers`) | every CPU cycle | +| VRC7 | 6 FM (OPLL) | `rustynes_apu::Opll` (emu2413-derived, **MIT**) | OPLL `calc()` every 36 CPU cycles (~49,716 Hz) | +| FDS | 1 wavetable + FM | `FdsAudio` (`fds.rs`) | wave/mod every 16 CPU cycles | +| MMC5 | 2 pulse + 7-bit PCM | `Mmc5Audio` (`mmc5.rs`) | pulse every other CPU cycle | +| Namco 163 | 1–8 time-multiplexed wavetable | `Namco163Audio` | round-robin every 15 CPU cycles | +| Sunsoft 5B | 3 tone + noise + envelope | `Sunsoft5BAudio` (FME-7) | every CPU cycle | + +All cores are behind the default-on `mapper-audio` Cargo feature; with it off +(e.g. the `no_std` build) the register decoders still latch so save-state +round-trip is preserved, but `clock`/`mix` become silent no-op shims. The VRC7 +OPLL core is deliberately the **MIT `emu2413`** lineage, not the license- +incompatible Nuked-OPLL. + +## Relative levels (v2.1.6 "Expansion Audio") + +Each chip's `mix_audio()` is scaled so its full-volume output sits at the +loudness the hardware and **Mesen2** (RustyNES's accuracy bar) produce relative +to the 2A03 pulse, measured by the bbbradsmith `db_*` decibel-comparison ROMs and +asserted by the `audio_expansion.rs` `level_db_*` oracle. VRC6 (≈1.506), MMC5 +(≈1.0, "equivalent to the APU") and Namco 163 1-channel (≈6.02) are the pinned +corrections. Two levels are **honest documented gaps**: the Sunsoft 5B absolute +level (its log-DAC *shape* is hardware-exact, but a full-volume tone would +overflow the `i16` `mix_audio` contract — a wider mix path is deferred), and the +VRC7 FM absolute level (the FM synth is implemented and its instrument ROM is +verified canonical, but its patch/feedback-dependent pseudo-sine output is not +cleanly oracle-pinned, so `db_vrc7` stays a byte-exact snapshot regression +guard). See [`accuracy-ledger.md`](accuracy-ledger.md) § Expansion-audio levels. + +Because a non-expansion mapper's `mix_audio()` returns `0`, the expansion mix is +a **separate additive term** — the level corrections leave the base 2A03 mix +byte-identical, so AccuracyCoin / blargg / nestest are unaffected. + +## NSF playback + +A classic `.nsf` may declare expansion audio in its `$07B` bitfield (bit 0 VRC6, +1 VRC7, 2 FDS, 3 MMC5, 4 N163, 5 5B). The NSF player does **not** reimplement any +synthesis: `NsfExpansion` (`nsf_expansion.rs`) owns instances of the *exact same* +cores and routes the NSF register windows into them, so an NSF VRC6 tune sounds +identical to a VRC6 cartridge. `NsfExpansion` is unreachable from any oracle +cartridge ROM, so it cannot perturb the existing audio suites. + +## Frontend + +The **Audio Mixer** tool panel (v2.1.6, `debugger::audio_mixer`) exposes +per-source balance sliders and per-channel scopes / VU meters, including the +on-cart expansion channel. See [`frontend.md`](frontend.md). diff --git a/docs/frontend.md b/docs/frontend.md index c864a59f..a61bf315 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -917,7 +917,18 @@ persistent `arboard` handle so the image survives on X11 / Wayland (the clipboar is owned by the live process), fixing the silent no-op + false success toast (I1). **Tools -> ROM Database** opens standalone now that the locked-render predicate (`DebuggerOverlay::any_nes_tool_open`) lists every `nes`-reading tool -panel — Cheats *and* the ROM Database editor (I6). The **RetroAchievements** +panel — Cheats, the ROM Database editor, *and* (v2.2.0) the read-only ROM Info +browser (I6). **Tools -> ROM Info** (v2.2.0 "Capstone", +`debugger::rom_info_panel`) is a purely observational companion to the ROM +Database editor: for the loaded ROM it surfaces the two dump-identity CRC32 keys +(the header-excluded game-DB key + the full-file **No-Intro** key), the SHA-256, +the effective per-game database entry (title / mapper / region / mirroring / +submapper), and the decoded cartridge header read straight off the running `Nes` +(mapper id, region, PRG-ROM / CHR-ROM sizes; "CHR-RAM" when there is no CHR ROM). +It takes `&Nes` (read-only) — it never mutates the emulator, never writes the DB +overlay, and the deterministic core never consults it. No bootgod / nescartdb +board table is vendored, so the panel is honest about surfacing only the per-game +DB + the header rather than implying provenance it does not carry. The **RetroAchievements** readout moved into the bottom status bar between the emulator-state label and the FPS counter (I7). The **Keyboard Shortcuts** window reads the live `[input]` / `[input.system]` bindings with a Player/device selector (I9). The **Input @@ -1024,10 +1035,11 @@ conversion can proceed panel-by-panel without regressions. The panels split by what they need: - **Tool panels** (`ToolPanel`: Cheats, Settings, Netplay, Cheevos, Perf, - Input) render whenever open, with the deep overlay off. `OpenPanel` sets the - flag without forcing the overlay visible. (Cheats reads `&mut Nes`, so the - render path takes the locked branch — `any_nes_tool_open` — when it is - open.) + Input, GameDb, RomInfo, …) render whenever open, with the deep overlay off. + `OpenPanel` sets the flag without forcing the overlay visible. (Cheats, the + ROM Database editor, and the read-only ROM Info browser read the `Nes`, so + the render path takes the locked branch — `any_nes_tool_open` — when any of + them is open.) - **Chip panels** (`ChipPanel`: Cpu, Ppu, Oam, Apu, Memory, Mapper) need `&mut Nes` and a per-frame core poll, so they render only while the overlay is visible. `OpenChipPanel` therefore forces the overlay visible. diff --git a/docs/netplay-webrtc.md b/docs/netplay-webrtc.md index e18fc926..65d71149 100644 --- a/docs/netplay-webrtc.md +++ b/docs/netplay-webrtc.md @@ -507,6 +507,22 @@ the live match (it can only show a frame it has *received* every input for). can fast-forward to catch up; the Netplay panel + status bar show `spectate fN +pending`. +**Delayed-stream buffer (v2.2.0 "Capstone").** On top of that natural lag, +`SpectatorConfig.delay_frames` adds a *configurable, intentional* hold: the +spectator reveals frame `f` only once frame `f + delay_frames` is also confirmed +(`reveal_horizon()` = the confirmed horizon pulled back by the delay). Two uses: +a **broadcast / anti-spoiler delay** (a tournament feed running several seconds +behind so a caster cannot leak an imminent input), and **jitter smoothing** +(holding a backlog of confirmed frames so presentation stays steady over a bursty +relay instead of stall-then-fast-forward). It is purely a *presentation* delay — +the emulated frames are still produced byte-identically and in order; only the +moment each is revealed moves later — so it never sends anything and cannot +perturb the match. The value is clamped to `SpectatorConfig::MAX_DELAY_FRAMES` +(512 ≈ 8.5 s at 60 Hz), which keeps the buffered-but-unshown backlog inside the +bounded lookahead window so a misconfigured huge delay cannot wedge the spectator +behind the horizon. The frontend exposes it as `NetplayUi::spectator_delay_frames` +(default 0 = reveal as soon as confirmed). + **Maintainer-manual carryover (like the live 2-4p matrix):** the host-side spectator *broadcast/relay* (a host fanning the confirmed input stream to N spectators) + the `deploy/` relay config are deployment-ready but not @@ -518,6 +534,91 @@ tested-and-proven part. --- +## 4b. Lobby, matchmaking, liveness + desync hardening (v2.2.0 "Capstone") + +The v2.2.0 "Capstone" B5 workstream layers browse-and-join matchmaking, a graded +peer-liveness signal, and a hardened desync surface on top of the existing +room-code / TURN stack — all in the signaling / session / transport layers plus +the frontend lobby UI. The deterministic core is untouched and the rollback +determinism contract (byte-identical re-simulation) is preserved. + +### 4b.1 Lobby directory + matchmaking (signaling) + +The pure signaling protocol (`crates/rustynes-netplay/src/signaling.rs`, +`SignalMessage` + `Relay`) gains a lobby directory and a matchmaking path so a +player can join by *browsing* rather than only by typing a room code: + +- **`ListRooms { rom_hash }` → `RoomList { rooms: Vec }`** — the client + asks for the open (joinable, not-full), optionally game-filtered rooms; the + server replies with each room's `RoomInfo` (code, current player count, + capacity, `rom_hash`). `RoomInfo` carries **no** SDP / ICE / per-client + identity — the lobby is a directory, not a transport. The reply is capped at + `MAX_ROOM_LIST` (256), and the `room-list` JSON array is parsed by a + brace-depth walk that is likewise bounded, so an oversized frame cannot force + an unbounded allocation. +- **`QuickMatch { rom_hash, max_players }` → `Matched { room, slot, max_players }`** + — "quick play": the relay joins the client to *any* open room playing that ROM + (via the shared `add_to_room` primitive that `Join` also uses — same slot + assignment + `PeerJoined` nudges), or **creates** a fresh room with a + deterministic generated code (`QM-NNNNNN`) if none exists. The client learns + the resolved room code via `Matched` (distinct from `Joined` only in also + reporting the code, so a matchmade client can display / share it); WebRTC + pairing then proceeds identically (lower slot offers to higher). + +Both new client→server messages are ignored when they arrive from the wrong +direction, and the JSON encode/parse stays hand-rolled + dependency-free like the +rest of the protocol. + +### 4b.2 Peer-liveness RTT timeouts (connection) + +Once a 2-player `NetplayConnection` is `Synced`, a peer can stall (packet loss, a +paused laptop, an LTE handoff) without ever formally disconnecting. The +connection now grades that as a `PeerLink` signal driven by the time since the +last received datagram (`last_recv`): + +| `PeerLink` | Silence ≥ | Meaning | +|---|---|---| +| `Live` | — | a datagram arrived within `peer_interrupt_timeout` | +| `Interrupted` | `peer_interrupt_timeout` (default **2 s**) | warn the user; still recoverable (a late packet restores `Live`) | +| `TimedOut` | `peer_disconnect_timeout` (default **5 s**) | terminal — the connection tears down with `DisconnectReason::PeerTimeout` | + +**Why seconds, not Mesen's ~150 ms.** Mesen's netplay declares a peer stalled +after ~150 ms of silence, which is famously trigger-happy: `Quality` pings here +are sent only once per second, and a single dropped ping or a routine Wi-Fi / LTE +retransmit spike routinely exceeds 150 ms of inter-arrival gap on a real internet +path whose RTT is already 60–120 ms — producing spurious "interrupted / desynced" +flapping on otherwise-healthy links. The interrupt threshold (2 s) is two full +ping intervals plus slack, so a single lost ping never trips it; the disconnect +threshold (5 s) matches the multi-second grace windows GGPO / Parsec use. Both +are builder-configurable (`with_peer_timeouts`) for tighter LAN bounds or looser +high-latency relayed play. (The connect-time `handshake_timeout` stays 10 s and +the frame-advantage stall stays `max_rollback_frames` = 8 — these govern +*connecting* and *speculation*, orthogonal to run-time liveness.) + +### 4b.3 Hardened desync status (diagnostics) + +The observational `DesyncDiagnostics` (§4's Debugging aid) gains a single graded +verdict, `DesyncStatus`, so the panel does not re-derive one from the raw +counters: + +- **`InSync`** — every comparison matched. +- **`Suspect { consecutive, first_desync_frame }`** — a mismatch has occurred but + the current consecutive run is below the confirm threshold. A burst-reordered + pair of `Checksum` messages can momentarily disagree before the deferred + `compare_pending_checksums` pass reconciles them, so a single mismatch is *not* + flashed as a hard desync. +- **`Desynced { first_desync_frame }`** — the consecutive-mismatch run has reached + `desync_threshold` (default **3**, ≈ 1.5 s at the 30-frame checksum interval). + This is **sticky**: a rollback desync is unrecoverable (the peers can never + re-converge without a full state resync), so the surface never downgrades a + confirmed `Desynced` back to `Suspect` even if a later stray checksum matches. + +This remains pure telemetry — it only reads the confirmed-frame digests the +session already exchanges (`NetMessage::Checksum`) and never feeds back into the +rollback, so disabling it leaves every frame / checksum / rollback byte-identical. + +--- + ## 5. What is verified vs. pending **Verified:** @@ -542,6 +643,11 @@ tested-and-proven part. | Desync-diagnostics capture (CRC-match history, first-desync frame, consecutive-mismatch counter) | Unit tests (`rustynes-netplay::diagnostics`) — synthetic CRC sequences; observational only (does not affect the rollback algorithm) | | Spectator replay is byte-identical to a reference run over the same confirmed inputs (H8) | Unit test (`rustynes-netplay::spectator` — framebuffer compare); `SpectatorSession` sends nothing and waits for fully-confirmed frames | | Spectator frontend driver (enter `Spectating`, poll-only tick, clean leave) | Unit test (`netplay_ui::spectator_enters_phase_and_leaves_cleanly`) | +| Lobby directory + matchmaking (`ListRooms`/`RoomList`/`QuickMatch`/`Matched`, `RoomInfo`) — open-room filtering, join-existing-vs-create, bounded `room-list` array parse (v2.2.0) | Unit tests (`signaling` — roundtrip, `list_rooms_returns_only_open_matching_rooms`, `quick_match_joins_an_open_room_then_creates_one`) | +| Delayed-stream spectator buffer (`delay_frames`, hold-then-reveal in order, clamp to `MAX_DELAY_FRAMES`) (v2.2.0) | Unit tests (`spectator::spectator_delay_buffer_holds_then_reveals` / `spectator_delay_is_clamped`) | +| Graded desync status (`DesyncStatus` hysteresis + sticky-confirmed) (v2.2.0) | Unit tests (`diagnostics::status_applies_hysteresis_then_confirms_and_sticks` / `threshold_zero_is_treated_as_one`) | +| Peer-liveness RTT (`PeerLink` Live/Interrupted/TimedOut, `DisconnectReason::PeerTimeout`, `with_peer_timeouts`) (v2.2.0) | Unit tests (`connection`) — synced peer graded by `last_recv` against the 2 s / 5 s thresholds | +| Netplay wire parsers never panic / OOM on hostile input (`NetMessage::from_bytes`, `SignalMessage::parse`) (v2.2.0) | `netplay_message` cargo-fuzz target (`fuzz/`) — tens of thousands of clean iterations | **Debugging aid (v1.3.0 Workstream G1).** When a session desyncs, the native Netplay panel's read-only **Diagnostics** section surfaces a GeraNES-style @@ -552,6 +658,9 @@ remote CRC (classified as a timing/cycle divergence when the framebuffer hashes match, else a state divergence), and a rolling CRC-match history. It reads the confirmed-frame digests the session already exchanges (`NetMessage::Checksum`) and never feeds back into the rollback — pure telemetry, determinism intact. +From v2.2.0 the panel reads the single graded `DesyncStatus` verdict (§4b.3) +instead of re-deriving one, so a lone reordered checksum no longer flashes a +false desync banner. **Pending (deployment-ready, NOT verified — the maintainer's manual run):** diff --git a/docs/pal-region.md b/docs/pal-region.md new file mode 100644 index 00000000..285502d2 --- /dev/null +++ b/docs/pal-region.md @@ -0,0 +1,46 @@ +# PAL / Region + +**References:** the authoritative timing lives in [`ppu-2c02.md`](ppu-2c02.md) and [`apu-2a03.md`](apu-2a03.md); pass counts in [`accuracy-ledger.md`](accuracy-ledger.md). This page is a curated handbook entry point. + +RustyNES emulates three console regions, selected per-ROM (from the iNES / NES 2.0 +header, overridable in the per-game DB) and carried through save-state / reset: + +- **NTSC** (2A03 / 2C02) — the North American / Japanese console. The default. +- **PAL** (2A07 / 2C07) — the European console: a different master clock, a + taller frame, and a distinct APU frame-counter + noise/DMC calibration. +- **Dendy** — a Famiclone hybrid: PAL-style video timing with an NTSC-style APU. + +`Region` threads through the core so a subsystem branches on it exactly once at +construction, never per-tick on the shipped default. + +## PPU (2C07) timing + +The PAL frame is taller: vertical blank runs scanlines 241–310 and pre-render is +scanline 311 (vs. 241–260 / 261 on NTSC), so a PAL frame has 70 vblank +scanlines. The NTSC odd-frame dot-skip does **not** occur on PAL. The `ntsc_phase` +video-phase counter cycles 0..=2 on NTSC and 0..=1 on PAL/Dendy. See +[`ppu-2c02.md`](ppu-2c02.md) for the exact scanline/dot table. + +## APU (2A07) timing (v2.1.5 oracle) + +PAL has **separate frame-counter step positions** — they are *not* derivable by +scaling the NTSC ones. The PAL 2A07 sequencer step positions are modeled +(v2.1.5), gated on `Region` so only `Region::Pal` takes the PAL arm; NTSC and +Dendy keep the NTSC positions unchanged, so the default build and every NTSC/Dendy +tick stays **byte-identical**. PAL also uses distinct noise periods and DMC rate +tables (`PAL_NOISE_PERIODS`). + +This was pinned by the **first PAL-region APU oracle**: blargg's PAL-calibrated +`pal_apu_tests` (10 sub-ROMs, `tests/pal_apu_tests.rs`) passes **10/10** from +v2.1.5, covering the PAL frame-counter-timing checks (clock jitter, mode-0/1 +length timing, the two PAL terminal steps) plus a length halt/reload +write-ordering fix that was latent on NTSC too yet stays NTSC-byte-identical. See +[`apu-2a03.md`](apu-2a03.md) § PAL for the step-position values and +[`accuracy-ledger.md`](accuracy-ledger.md) for the ledger. + +## Palette + +PAL and NTSC differ in the composite-video signal RustyNES models to *generate* +its base palette (`rustynes_ppu::generate_base_palette`, a Bisqwit / ares YIQ +integration) rather than shipping a hand table. See the +[CRT / Composite Video](crt-composite.md) page and [`ppu-2c02.md`](ppu-2c02.md). diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 5fd11b3f..c4796cf9 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -17,6 +17,13 @@ cargo-fuzz = true libfuzzer-sys = "0.4" rustynes-mappers = { path = "../crates/rustynes-mappers" } rustynes-cpu = { path = "../crates/rustynes-cpu" } +# v2.2.0 — expanded fuzz surface: PPU / APU register I/O, the netplay +# message + signaling parsers (untrusted network input), and the save-state +# (.rns) / movie (.rnm) deserializers (untrusted file input). +rustynes-ppu = { path = "../crates/rustynes-ppu" } +rustynes-apu = { path = "../crates/rustynes-apu" } +rustynes-netplay = { path = "../crates/rustynes-netplay", default-features = false } +rustynes-core = { path = "../crates/rustynes-core" } # Match workspace profile choices but with reasonable fuzz defaults. [profile.release] @@ -43,3 +50,38 @@ path = "fuzz_targets/mapper_writes.rs" test = false doc = false bench = false + +[[bin]] +name = "ppu_reg_io" +path = "fuzz_targets/ppu_reg_io.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "apu_reg_io" +path = "fuzz_targets/apu_reg_io.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "netplay_message" +path = "fuzz_targets/netplay_message.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "save_state" +path = "fuzz_targets/save_state.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "movie" +path = "fuzz_targets/movie.rs" +test = false +doc = false +bench = false diff --git a/fuzz/README.md b/fuzz/README.md index 88112915..0c2ff6c1 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -22,6 +22,29 @@ threads through `rustc`. Install with `rustup toolchain install nightly`. | `cartridge_parser` | `rustynes_mappers::parse(&[u8])` | iNES / NES 2.0 header is attacker-controlled input | | `cpu_step` | `Cpu::step(&mut bus)` | 256-opcode dispatch incl. unofficial / JAM opcodes | | `mapper_writes` | `Mapper::cpu_write` + `ppu_write` + notify_* | Bank-table OOB, IRQ counter overflow | +| `ppu_reg_io` | `Ppu::cpu_write_register` / `cpu_read_register` | $2000–$2007 write-toggle / PPUDATA buffer / OAM decode | +| `apu_reg_io` | `Apu::write_register` / `read_status` | $4000–$4017 length/frame-counter/IRQ decode (no bus) | +| `netplay_message` | `NetMessage::from_bytes` + `SignalMessage::parse` | **untrusted network input** — binary UDP + JSON signaling/lobby parsers | +| `save_state` | `parse_header` + `Nes::extract_thumbnail` + `restore_quiet` | untrusted `.rns` file: header + section length prefixes | +| `movie` | `Movie::deserialize` | untrusted `.rnm` file: header, embedded `.rns` start-point, per-frame stream | + +The v2.2.0 targets cover the remaining untrusted-input boundaries: the two +CPU-facing chip register files, the netplay network parsers (highest value — +these ingest bytes straight off a socket / WebSocket), and the two on-disk +deserializers. The `movie` target already earned its keep, surfacing two +`Movie::deserialize` OOM DoS paths (an untrusted `frame_count` pre-sizing a +multi-GB `Vec`, and a zero-width `bytes_per_frame` looping `frame_count` empty +pushes) — both fixed with bounded-allocation guards + a regression test. + +### Environment note (LeakSanitizer) + +In sandboxed / containerized environments where LeakSanitizer cannot attach +(it relies on ptrace), disable leak detection so the harness runs the code +under test rather than aborting on an LSan setup error: + +```bash +ASAN_OPTIONS=detect_leaks=0 cargo +nightly fuzz run -- -detect_leaks=0 +``` ## Running @@ -54,7 +77,8 @@ compile on every PR via the standard workspace clippy/check jobs. To run a CI-friendly time-bounded fuzz campaign: ```bash -for target in cartridge_parser cpu_step mapper_writes; do +for target in cartridge_parser cpu_step mapper_writes \ + ppu_reg_io apu_reg_io netplay_message save_state movie; do cargo +nightly fuzz run "$target" -- -max_total_time=120 -runs=0 done ``` diff --git a/fuzz/fuzz_targets/apu_reg_io.rs b/fuzz/fuzz_targets/apu_reg_io.rs new file mode 100644 index 00000000..83a7be39 --- /dev/null +++ b/fuzz/fuzz_targets/apu_reg_io.rs @@ -0,0 +1,55 @@ +//! Fuzz target for `rustynes_apu::Apu` CPU-facing register I/O. +//! +//! Drives the $4000–$4017 register file (`write_register`) plus the $4015 +//! status read (`read_status`) with an arbitrary stream of `(register, value)` +//! pairs. This exercises the pulse/triangle/noise/DMC register decoders, the +//! length-counter halt/reload write ordering, the frame-counter mode+IRQ-inhibit +//! write ($4017), the channel-enable mask ($4015 write), and the status read +//! that clears the frame IRQ. `write_register` / `read_status` take no bus (the +//! DMC sample fetch only happens during clocking), so this is a self-contained +//! untrusted-input boundary. Validates no panics / OOB indexing. +//! +//! Run with: +//! cargo install cargo-fuzz +//! cargo +nightly fuzz run apu_reg_io +//! +//! Per `docs/testing-strategy.md` §Layer 5. + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use rustynes_apu::{Apu, Region}; + +fuzz_target!(|data: &[u8]| { + // Each access is a (register-selector, value) pair; shorter is uninteresting. + if data.len() < 2 { + return; + } + + // Alternate NTSC / PAL by the first byte so both frame-counter step tables + // are covered across runs. A fixed 48 kHz output rate is irrelevant to the + // register-decode paths under test. + let region = if data[0] & 1 == 0 { + Region::Ntsc + } else { + Region::Pal + }; + let mut apu = Apu::new(region, 48_000); + + // Map the selector byte across the full $4000..=$4017 register window (24 + // registers) and treat the top bit as a read/write toggle so the stream + // interleaves status reads with writes. + let mut i = 1; + while i + 1 < data.len() { + let sel = data[i]; + let value = data[i + 1]; + if sel & 0x80 != 0 { + // A status read ($4015) — clears the frame IRQ flag. + let _ = apu.read_status(); + } else { + let addr = 0x4000 + u16::from(sel % 24); + apu.write_register(addr, value); + } + i += 2; + } +}); diff --git a/fuzz/fuzz_targets/movie.rs b/fuzz/fuzz_targets/movie.rs new file mode 100644 index 00000000..bd723b76 --- /dev/null +++ b/fuzz/fuzz_targets/movie.rs @@ -0,0 +1,31 @@ +//! Fuzz target for the movie (`.rnm`) deserializer — untrusted **file input**. +//! +//! A `.rnm` TAS movie is arbitrary on-disk bytes. [`Movie::deserialize`] must +//! reject a malformed / truncated / foreign file with a typed `MovieError`, +//! never panic / OOB / hang. This surface is doubly interesting because a movie +//! header embeds a length-prefixed save-state (`.rns`) start-point blob, so +//! movie deserialization transitively exercises the save-state parser's +//! length-field handling as well as the movie header + per-frame input stream +//! decode. +//! +//! Any movie that *does* deserialize is re-serialized as a cheap +//! self-consistency check (the encoder must accept whatever the decoder +//! produced). +//! +//! Run with: +//! cargo install cargo-fuzz +//! cargo +nightly fuzz run movie +//! +//! Per `docs/testing-strategy.md` §Layer 5. + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use rustynes_core::Movie; + +fuzz_target!(|data: &[u8]| { + if let Ok(movie) = Movie::deserialize(data) { + // A decoded movie must re-encode without panicking. + let _ = movie.serialize(); + } +}); diff --git a/fuzz/fuzz_targets/netplay_message.rs b/fuzz/fuzz_targets/netplay_message.rs new file mode 100644 index 00000000..8850446a --- /dev/null +++ b/fuzz/fuzz_targets/netplay_message.rs @@ -0,0 +1,46 @@ +//! Fuzz target for the netplay wire parsers — the untrusted **network-input** +//! boundary (high value). +//! +//! Two parsers ingest bytes straight off a socket / WebSocket and must never +//! panic, hang, or index out of bounds on hostile input: +//! +//! - [`NetMessage::from_bytes`] — the binary UDP gameplay protocol +//! (`Input` / `InputAck` / `Sync` / `Checksum` / `Quality` / `Roster`). A +//! malformed / foreign / truncated datagram must decode to `None`, never a +//! panic (the transport drops it). +//! - [`SignalMessage::parse`] — the JSON signaling / lobby protocol +//! (`join` / `offer` / `answer` / `candidate` / `room-list` / `quick-match` +//! / …), including the bounded `room-list` array walk. A garbage frame must +//! parse to `None`. +//! +//! The same fuzz bytes feed both: raw bytes → `from_bytes`, and a lossy-UTF-8 +//! view of them → `parse`. Any successful decode is round-tripped back through +//! the encoder as a cheap self-consistency check. +//! +//! Run with: +//! cargo install cargo-fuzz +//! cargo +nightly fuzz run netplay_message +//! +//! Per `docs/testing-strategy.md` §Layer 5. + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use rustynes_netplay::{NetMessage, SignalMessage}; + +fuzz_target!(|data: &[u8]| { + // 1. Binary UDP gameplay message decoder. Must handle any byte slice. + if let Some(msg) = NetMessage::from_bytes(data) { + // A decoded message must re-encode (and the encoding stays bounded). + let _ = msg.to_bytes(); + } + + // 2. JSON signaling / lobby message parser. Interpret the same bytes as + // (lossy) UTF-8 text — the wire form is WebSocket text frames. + let text = String::from_utf8_lossy(data); + if let Some(sig) = SignalMessage::parse(&text) { + // A parsed message must re-encode and re-parse to the same value. + let json = sig.to_json(); + let _ = SignalMessage::parse(&json); + } +}); diff --git a/fuzz/fuzz_targets/ppu_reg_io.rs b/fuzz/fuzz_targets/ppu_reg_io.rs new file mode 100644 index 00000000..f4e523ee --- /dev/null +++ b/fuzz/fuzz_targets/ppu_reg_io.rs @@ -0,0 +1,73 @@ +//! Fuzz target for `rustynes_ppu::Ppu` CPU-facing register I/O. +//! +//! Drives the $2000–$2007 register file (`cpu_write_register` / +//! `cpu_read_register`) with an arbitrary stream of `(register, value)` pairs. +//! This is the CPU's untrusted-from-the-PPU's-view boundary: PPUCTRL/PPUMASK +//! side effects, the PPUADDR/PPUSCROLL write-toggle (`w`) latch, the PPUDATA +//! VRAM read buffer + auto-increment, OAMADDR/OAMDATA, and the PPUSTATUS read +//! that clears vblank + resets the toggle. Validates no panics / OOB indexing +//! for any sequence of register accesses. +//! +//! Run with: +//! cargo install cargo-fuzz +//! cargo +nightly fuzz run ppu_reg_io +//! +//! Per `docs/testing-strategy.md` §Layer 5. + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use rustynes_ppu::{Ppu, PpuBus, PpuRegion}; + +/// Minimal `PpuBus`: a flat 16 KiB (`0x4000`) store masked to the full PPU +/// address space (`addr & 0x3FFF`) backing every PPU-space read/write. +/// Nametable mirroring uses the trait's default `nametable_address`. +/// This is exactly the surface `cpu_read_register`/`cpu_write_register` reach +/// through for PPUDATA ($2007) — no mapper side effects, no A12 notifications. +struct FuzzPpuBus { + vram: Box<[u8; 0x4000]>, +} + +impl PpuBus for FuzzPpuBus { + fn ppu_read(&mut self, addr: u16) -> u8 { + self.vram[(addr & 0x3FFF) as usize] + } + fn ppu_write(&mut self, addr: u16, value: u8) { + self.vram[(addr & 0x3FFF) as usize] = value; + } +} + +fuzz_target!(|data: &[u8]| { + // Each access is a (register, value) pair; anything shorter is uninteresting. + if data.len() < 2 { + return; + } + + // Alternate NTSC / PAL by the first byte so both timebases' register paths + // are covered across runs. + let region = if data[0] & 1 == 0 { + PpuRegion::Ntsc + } else { + PpuRegion::Pal + }; + let mut ppu = Ppu::new(region); + let mut bus = FuzzPpuBus { + vram: Box::new([0u8; 0x4000]), + }; + + // Walk the remaining bytes as (reg, value) pairs. The low 3 bits select the + // register ($2000..$2007); the high bit of the reg byte chooses read vs. + // write, so the stream freely interleaves both directions. + let mut i = 1; + while i + 1 < data.len() { + let sel = data[i]; + let value = data[i + 1]; + let reg = sel & 0x07; + if sel & 0x80 == 0 { + ppu.cpu_write_register(reg, value, &mut bus); + } else { + let _ = ppu.cpu_read_register(reg, &mut bus); + } + i += 2; + } +}); diff --git a/fuzz/fuzz_targets/save_state.rs b/fuzz/fuzz_targets/save_state.rs new file mode 100644 index 00000000..152cb84c --- /dev/null +++ b/fuzz/fuzz_targets/save_state.rs @@ -0,0 +1,71 @@ +//! Fuzz target for the save-state (`.rns`) deserializer — untrusted **file +//! input**. +//! +//! A save-state slot is arbitrary on-disk bytes (a user can hand-edit or +//! corrupt one, or load one another program wrote). Three parse entry points +//! must reject malformed input with a typed `SnapshotError`, never panic / OOB / +//! hang: +//! +//! - [`parse_header`] — the fixed header (magic, format version, section table +//! offset). The lightest structural check. +//! - `Nes::extract_thumbnail` — parses the header then walks the section list +//! to find the embedded thumbnail, without touching emulator state (a pure +//! parse over the whole container, incl. every section's length prefix). +//! - `Nes::restore_quiet` — the full restore into a live `Nes`, which decodes +//! every section (CPU/PPU/APU/mapper/WRAM/…) and their bounded length fields. +//! +//! The base `Nes` is a synthesized minimal NROM so a *structurally valid* fuzz +//! case can actually reach the per-section decoders (not just bounce off the +//! magic check). +//! +//! Run with: +//! cargo install cargo-fuzz +//! cargo +nightly fuzz run save_state +//! +//! Per `docs/testing-strategy.md` §Layer 5. + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use rustynes_core::{Nes, parse_header}; + +/// A minimal iNES NROM (16 KiB PRG that spins in an infinite loop + 8 KiB CHR), +/// enough to construct a real `Nes` whose `restore` path can be exercised. +fn synth_nrom() -> Vec { + let mut bytes = Vec::with_capacity(16 + 16 * 1024 + 8 * 1024); + bytes.extend_from_slice(b"NES\x1A"); + bytes.push(1); // 1 x 16 KiB PRG + bytes.push(1); // 1 x 8 KiB CHR + bytes.push(0); + bytes.push(0); + bytes.extend_from_slice(&[0u8; 8]); + let mut prg = vec![0u8; 16 * 1024]; + // Reset vector -> $C000; a `JMP $C000` there. + prg[0] = 0x4C; + prg[1] = 0x00; + prg[2] = 0xC0; + let len = prg.len(); + prg[len - 4] = 0x00; // NMI low + prg[len - 3] = 0xC0; // NMI high + prg[len - 6] = 0x00; // reset low + prg[len - 5] = 0xC0; // reset high + prg[len - 2] = 0x00; // IRQ low + prg[len - 1] = 0xC0; // IRQ high + bytes.extend_from_slice(&prg); + bytes.extend_from_slice(&vec![0u8; 8 * 1024]); + bytes +} + +fuzz_target!(|data: &[u8]| { + // 1. Pure header parse — must never panic on any byte slice. + let _ = parse_header(data); + + // 2. Whole-container thumbnail walk (header + every section length prefix). + let _ = Nes::extract_thumbnail(data); + + // 3. Full restore into a live NROM Nes. A malformed state must return an + // error and leave the Nes usable, not panic or corrupt memory. + if let Ok(mut nes) = Nes::from_rom(&synth_nrom()) { + let _ = nes.restore_quiet(data); + } +}); diff --git a/mkdocs.yml b/mkdocs.yml index 5f7e0a7b..9f624bbf 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -120,12 +120,16 @@ nav: - CPU (6502): cpu-6502.md - PPU (2C02): ppu-2c02.md - APU (2A03): apu-2a03.md + - Expansion Audio: expansion-audio.md + - PAL / Region: pal-region.md - Scheduler: scheduler.md - Mappers: mappers.md - Cartridge Format: cartridge-format.md - Frontend & Features: - Frontend Shell: frontend.md - Scripting (Lua): scripting.md + - Creator Tools: creator-tools.md + - CRT / Composite Video: crt-composite.md - Netplay (WebRTC): netplay-webrtc.md - RetroAchievements: cheevos-browser.md - Compatibility: compatibility.md