Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<RoomInfo> }` (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
Expand Down
51 changes: 48 additions & 3 deletions crates/rustynes-core/src/movie.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
40 changes: 35 additions & 5 deletions crates/rustynes-frontend/src/debugger/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 +
Expand Down Expand Up @@ -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,
Expand All @@ -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);
}
Expand Down
Loading
Loading