diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f27878..dca1957 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 release when unchanged. 3 real unit tests cover native resolution, an HD-pack-scaled resolution, and that the kept bytes are untouched. +- Debug → ROM Info panel: a read-only CRC32/SHA-256/header decode of the loaded cart + (`crates/rustysnes-frontend/src/debugger/rom_info_panel.rs`), captured once per ROM load/close + rather than every frame. `rustysnes_cart::header::Header` gained a decoded `title: String` field + along the way. + ## [1.19.0] "Afterburner" - 2026-07-15 Fifteenth release of the RustyNES-parity roadmap: an optional PGO/BOLT pipeline for the diff --git a/Cargo.lock b/Cargo.lock index ab340f7..effdcc1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3775,6 +3775,7 @@ dependencies = [ "clap_complete", "console_error_panic_hook", "cpal", + "crc32fast", "directories", "egui", "egui-wgpu", diff --git a/crates/rustysnes-cart/src/header.rs b/crates/rustysnes-cart/src/header.rs index 60ae0e2..4ae5e01 100644 --- a/crates/rustysnes-cart/src/header.rs +++ b/crates/rustysnes-cart/src/header.rs @@ -8,6 +8,7 @@ //! See `docs/cartridge-format.md` for the authoritative header-byte layout (`$xFC0`–`$xFDF`) //! and the score heuristic. +use alloc::string::String; use thiserror::Error; /// Errors from header detection. @@ -100,6 +101,10 @@ pub struct Header { pub sram_size: usize, /// Whether the cart is battery-backed (has persistent SRAM / RTC). pub has_battery: bool, + /// The 21-byte internal title (`$xFC0`), non-printable bytes replaced with spaces and + /// trailing padding trimmed. Best-effort: the SNES header has no encoding guarantee, so this + /// is display text, not a validated field (`v1.20.0`, added for the ROM Info debugger panel). + pub title: String, } /// Header field offsets relative to the header base (`$xFC0`). @@ -222,10 +227,27 @@ impl Header { rom_size: image.len(), sram_size, has_battery, + title: decode_title(title_bytes), } } } +/// Best-effort decode of the raw 21-byte title field: non-printable bytes become spaces, then +/// trailing padding is trimmed. See [`Header::title`]. +fn decode_title(title_bytes: &[u8]) -> String { + let decoded: String = title_bytes + .iter() + .map(|&b| { + if (0x20..=0x7E).contains(&b) { + b as char + } else { + ' ' + } + }) + .collect(); + decoded.trim_end().into() +} + /// Derive the on-cart coprocessor from the chipset byte (`$xFD6`) plus (for the ambiguous `$F` /// "custom" nibble only) the cart's title. /// @@ -402,6 +424,21 @@ mod tests { assert_eq!(h.sram_size, 0x2000); assert!(h.has_battery); assert_eq!(h.coprocessor, Coprocessor::None); + assert_eq!(h.title, "RUSTYSNES TEST ROM"); + } + + #[test] + fn title_replaces_non_printable_bytes_and_trims_padding() { + let mut rom = synth_image(0x7FC0, 0x0, 0x00, 0x01); + // "AB" followed by non-printable padding — non-printable bytes become spaces, then the + // trailing run (padding + replaced bytes) trims off, leaving just "AB". + rom[0x7FC0 + field::TITLE] = b'A'; + rom[0x7FC0 + field::TITLE + 1] = b'B'; + for i in 2..field::TITLE_LEN { + rom[0x7FC0 + field::TITLE + i] = 0x00; + } + let h = Header::detect(&rom).expect("detect"); + assert_eq!(h.title, "AB"); } #[test] diff --git a/crates/rustysnes-frontend/Cargo.toml b/crates/rustysnes-frontend/Cargo.toml index 3d09fef..a1fc937 100644 --- a/crates/rustysnes-frontend/Cargo.toml +++ b/crates/rustysnes-frontend/Cargo.toml @@ -166,6 +166,11 @@ web-time = "1.1" # no system zlib dependency. zip = { version = "8.6.0", default-features = false, features = ["deflate"] } +# Debug -> ROM Info panel (`v1.20.0`): CRC32 of the loaded ROM alongside the SHA-256 +# `rustysnes_core::movie::hash_rom` already computes. Pure Rust, wasm32-portable, and already +# resolved in the workspace's dependency tree as a transitive dep, so a direct pin here is free. +crc32fast = "1.5" + # Native-only deps (gilrs/rfd/directories/clap/ratatui/rustysnes-script). A browser tab has no # gamepad-via-gilrs, no rfd dialog, no terminal, and `mlua`'s vendored Lua VM needs a C compiler + # `std` — the wasm builds never see these (the modules are `cfg(not(target_arch = "wasm32"))`- diff --git a/crates/rustysnes-frontend/src/app.rs b/crates/rustysnes-frontend/src/app.rs index f3728a1..be7644f 100644 --- a/crates/rustysnes-frontend/src/app.rs +++ b/crates/rustysnes-frontend/src/app.rs @@ -209,6 +209,13 @@ struct Active { rewind: crate::rewind::RewindBuffer, /// A single quick-save-state slot (`MenuAction::SaveState`/`LoadState`). quick_save: Option>, + /// The loaded ROM's identity + decoded header, for the Debug → ROM Info panel (`v1.20.0`). + /// Set via a `RomInfo::capture` call at each successful-load/close site (`on_gfx_ready`'s CLI + /// boot, `MenuAction::OpenRom`/`CloseRom`, `load_rom_bytes_wasm`) rather than every present, + /// since a loaded ROM's header/hashes never change while it stays loaded — and only when + /// `debug-hooks` is on, since that's the only build where the panel is reachable at all (see + /// each call site's own comment for why a failed load skips the recapture). + rom_info: Option, /// The loaded Lua script, if any (`v0.8.0`, T-81-002). `None` until `MenuAction::LoadScript`. #[cfg(all(feature = "scripting", not(target_arch = "wasm32")))] script: Option, @@ -643,6 +650,14 @@ impl App { } #[cfg(target_arch = "wasm32")] let initial_status = String::new(); + // `None` on `wasm32` boot (no ROM loaded yet); reflects whatever the CLI path above + // loaded on native (`v1.20.0`, the ROM Info panel). Gated on `debug-hooks` — hashing the + // full ROM (CRC32 + SHA-256) is real, avoidable work in a build where the Debug menu + // entry that would ever show this panel doesn't exist (found in review, PR #116). + #[cfg(feature = "debug-hooks")] + let initial_rom_info = crate::debugger::RomInfo::capture(&mut emu); + #[cfg(not(feature = "debug-hooks"))] + let initial_rom_info: Option = None; // Open the audio device (best-effort: a missing device leaves the emulator silent, not // dead). The producer-side resampler converts the S-DSP 32 kHz stream to the device rate. @@ -737,6 +752,7 @@ impl App { self.config.rewind.interval_frames, ), quick_save: None, + rom_info: initial_rom_info, #[cfg(all(feature = "scripting", not(target_arch = "wasm32")))] script: None, #[cfg(all(feature = "scripting", not(target_arch = "wasm32")))] @@ -761,10 +777,19 @@ impl App { return; }; let mut emu = active.core.lock().unwrap_or_else(PoisonError::into_inner); - active.shell.status = match emu.load_rom(bytes) { + let loaded = emu.load_rom(bytes); + active.shell.status = match &loaded { Ok(()) => "ROM loaded".to_string(), Err(e) => format!("ROM load failed: {e}"), }; + // Only recapture on an actual load (never on failure — `load_rom` leaves the previous + // ROM running, so re-hashing it would be pointless work), and only when `debug-hooks` is + // on, since that's the only build where the ROM Info panel is reachable at all (found in + // review, PR #116). + #[cfg(feature = "debug-hooks")] + if loaded.is_ok() { + active.rom_info = crate::debugger::RomInfo::capture(&mut emu); + } drop(emu); // A new cart invalidates every prior snapshot, same as the native `MenuAction::OpenRom`. active.rewind.clear(); @@ -1355,6 +1380,7 @@ impl App { &mut active.watchpoints, &mut active.breakpoints, save_slots.as_deref(), + active.rom_info.as_ref(), #[cfg(feature = "cheats")] &mut active.cheats, #[cfg(all(feature = "netplay", not(target_arch = "wasm32")))] @@ -1476,6 +1502,16 @@ impl App { active.cheevos.unload_game(); active.cheevos.load_game(emu.rom()); } + // Same success gate as the RetroAchievements block above, and same + // reasoning: a failure leaves the previous ROM running, so + // re-hashing it would be pointless work. Also gated on `debug-hooks` + // — hashing the full ROM is avoidable work in a build where the + // panel that would show it doesn't exist (found in review, PR #116). + #[cfg(feature = "debug-hooks")] + if active.shell.status.starts_with("Loaded ") { + active.rom_info = crate::debugger::RomInfo::capture(&mut emu); + } + drop(emu); } // A new cart invalidates every prior snapshot (rewind ring + // quick-save) — restoring one now would apply a foreign ROM's state to @@ -1493,6 +1529,7 @@ impl App { active.shell.status = "ROM closed".into(); active.rewind.clear(); active.quick_save = None; + active.rom_info = None; #[cfg(all(feature = "retroachievements", not(target_arch = "wasm32")))] active.cheevos.unload_game(); } diff --git a/crates/rustysnes-frontend/src/debugger/mod.rs b/crates/rustysnes-frontend/src/debugger/mod.rs index ab2e655..f2d38b8 100644 --- a/crates/rustysnes-frontend/src/debugger/mod.rs +++ b/crates/rustysnes-frontend/src/debugger/mod.rs @@ -20,8 +20,11 @@ mod cpu_panel; mod doc_panel; mod memory_compare_panel; mod ppu_panel; +mod rom_info_panel; mod watch_panel; +pub use rom_info_panel::RomInfo; + use crate::debug_snapshot::{DebugSnapshot, WatchpointEntry}; use crate::ui_shell::{MenuAction, ShellState}; @@ -43,6 +46,8 @@ pub enum DebugPanel { MemCompare, /// An in-app SNES-terminology glossary (`v1.8.0 "Tracepoint"`) + a link to the full docs site. Doc, + /// CRC32/SHA-256/header decode of the loaded cart (`v1.20.0`). + RomInfo, } /// Format a row of 16-bit words as space-separated 4-hex-digit groups — shared by the PPU (VRAM/ @@ -81,6 +86,7 @@ impl ShellState { watchpoints: &mut Vec, breakpoints: &mut Vec, actions: &mut Vec, + rom_info: Option<&RomInfo>, ) { let mut open = self.debugger_open; egui::Window::new("Debugger") @@ -95,10 +101,12 @@ impl ShellState { ui.selectable_value(&mut self.panel, DebugPanel::Watch, "Memory/Watch"); ui.selectable_value(&mut self.panel, DebugPanel::MemCompare, "Compare"); ui.selectable_value(&mut self.panel, DebugPanel::Doc, "Docs"); + ui.selectable_value(&mut self.panel, DebugPanel::RomInfo, "ROM Info"); }); ui.separator(); - // These three panels handle a `None` snapshot themselves (Watch/MemCompare show - // "no data yet"; Doc needs no snapshot at all), so they're dispatched before the + // These four panels handle a `None` snapshot themselves (Watch/MemCompare show + // "no data yet"; Doc needs no snapshot at all; RomInfo needs its own separately- + // threaded `rom_info`, not a `DebugSnapshot`), so they're dispatched before the // early-return below rather than after it. match self.panel { DebugPanel::Watch => { @@ -113,6 +121,10 @@ impl ShellState { doc_panel::render(ui); return; } + DebugPanel::RomInfo => { + rom_info_panel::render(ui, rom_info); + return; + } DebugPanel::Cpu | DebugPanel::Ppu | DebugPanel::Apu | DebugPanel::Cart => {} } let Some(debug) = debug else { @@ -133,7 +145,10 @@ impl ShellState { DebugPanel::Ppu => ppu_panel::render(ui, debug), DebugPanel::Apu => apu_panel::render(ui, debug), DebugPanel::Cart => cart_panel::render(ui, debug), - DebugPanel::Watch | DebugPanel::MemCompare | DebugPanel::Doc => { + DebugPanel::Watch + | DebugPanel::MemCompare + | DebugPanel::Doc + | DebugPanel::RomInfo => { unreachable!("handled above") } } diff --git a/crates/rustysnes-frontend/src/debugger/rom_info_panel.rs b/crates/rustysnes-frontend/src/debugger/rom_info_panel.rs new file mode 100644 index 0000000..61474bf --- /dev/null +++ b/crates/rustysnes-frontend/src/debugger/rom_info_panel.rs @@ -0,0 +1,173 @@ +//! The ROM Info debugger panel: a read-only CRC32/SHA-256/header decode of the loaded cart. +//! +//! `v1.20.0`: closes a named gap against RustyNES's own `rom_info_panel.rs` — that panel and +//! this one share the same *purpose* (identify the loaded image + show its header fields at a +//! glance) but not code: RustySNES's `rustysnes_cart::Header` is a different shape from +//! RustyNES's iNES header, so this is a fresh implementation, not a port. +//! +//! [`RomInfo`] is captured once per ROM load (`app.rs`'s `MenuAction::OpenRom`/`CloseRom` and the +//! `wasm32` file-picker path), not recomputed every frame like [`crate::debug_snapshot::DebugSnapshot`] +//! — the ROM's identity and header never change while it stays loaded, so hashing it again on +//! every present would be pure waste. + +// `rustysnes-frontend` doesn't depend on `rustysnes-cart` directly — it goes through +// `rustysnes-core`'s `pub use rustysnes_cart as cart;` re-export, matching how the rest of this +// crate reaches cart types (see `emu.rs`'s `system_mut().bus.cart` access pattern). +use rustysnes_core::cart::{Coprocessor, MapMode, Region}; + +/// A loaded ROM's identity (CRC32/SHA-256) + decoded internal header, for the ROM Info panel. +#[derive(Debug, Clone)] +pub struct RomInfo { + /// CRC32 of the raw ROM image (copier-prefix included, matching what most ROM databases key + /// on). + pub crc32: u32, + /// SHA-256 of the raw ROM image — the same hash [`rustysnes_core::movie::hash_rom`] uses to + /// key save-states/movies to a specific ROM, reused here rather than recomputed. + pub sha256: [u8; 32], + /// The active board's name (e.g. `"LoROM"`, `"HiROM+SA-1"`). + pub board_name: &'static str, + /// The decoded internal title — see `rustysnes_cart::header::Header::title`. + pub title: String, + /// The base cartridge map mode. + pub map_mode: MapMode, + /// Whether the cart runs the FastROM access window. + pub fast_rom: bool, + /// The console region (from the destination-code header byte). + pub region: Region, + /// The on-cart coprocessor, if any. + pub coprocessor: Coprocessor, + /// ROM size in bytes (post copier-prefix-strip). + pub rom_size: usize, + /// SRAM size in bytes (0 if none). + pub sram_size: usize, + /// Whether the cart is battery-backed. + pub has_battery: bool, + /// The byte offset the header was found at (relative to the prefix-stripped image). + pub header_offset: usize, + /// The number of leading copier-prefix bytes stripped (0 or 512). + pub copier_prefix: usize, +} + +impl RomInfo { + /// Capture a [`RomInfo`] snapshot from the currently-loaded ROM, or `None` if none is loaded. + #[must_use] + pub fn capture(emu: &mut crate::emu::EmuCore) -> Option { + let crc32 = crc32fast::hash(emu.rom()); + let sha256 = rustysnes_core::movie::hash_rom(emu.rom()); + let board_name = emu.cart_name()?; + let header = emu.system_mut().bus.cart.as_ref()?.header.clone(); + Some(Self { + crc32, + sha256, + board_name, + title: header.title, + map_mode: header.map_mode, + fast_rom: header.fast_rom, + region: header.region, + coprocessor: header.coprocessor, + rom_size: header.rom_size, + sram_size: header.sram_size, + has_battery: header.has_battery, + header_offset: header.offset, + copier_prefix: header.copier_prefix, + }) + } +} + +/// Render the ROM Info panel — a plain field grid, "(no ROM loaded)" when `info` is `None`. +pub(super) fn render(ui: &mut egui::Ui, info: Option<&RomInfo>) { + let Some(info) = info else { + ui.label("(no ROM loaded)"); + return; + }; + egui::Grid::new("rom_info").num_columns(2).show(ui, |ui| { + ui.label("Title"); + ui.label(if info.title.is_empty() { + "(blank)" + } else { + &info.title + }); + ui.end_row(); + + ui.label("Board"); + ui.label(info.board_name); + ui.end_row(); + + ui.label("Map mode"); + ui.label(format!("{:?}", info.map_mode)); + ui.end_row(); + + ui.label("Speed"); + ui.label(if info.fast_rom { "FastROM" } else { "SlowROM" }); + ui.end_row(); + + ui.label("Region"); + ui.label(format!("{:?}", info.region)); + ui.end_row(); + + ui.label("Coprocessor"); + ui.label(format!("{:?}", info.coprocessor)); + ui.end_row(); + + ui.label("ROM size"); + ui.label(format!( + "{} bytes ({} KiB)", + info.rom_size, + info.rom_size / 1024 + )); + ui.end_row(); + + ui.label("SRAM size"); + ui.label(format!("{} bytes", info.sram_size)); + ui.end_row(); + + ui.label("Battery-backed"); + ui.label(if info.has_battery { "yes" } else { "no" }); + ui.end_row(); + + ui.label("Header offset"); + ui.label(format!("${:06X}", info.header_offset)); + ui.end_row(); + + ui.label("Copier prefix"); + ui.label(format!("{} bytes", info.copier_prefix)); + ui.end_row(); + + ui.label("CRC32"); + ui.monospace(format!("{:08X}", info.crc32)); + ui.end_row(); + + ui.label("SHA-256"); + ui.monospace(hex_sha256(&info.sha256)); + ui.end_row(); + }); +} + +/// Format a SHA-256 digest as a contiguous lowercase hex string (unlike [`super::hex_row_bytes`], +/// which space-separates for a hex-dump view — a digest reads better unbroken). +fn hex_sha256(bytes: &[u8; 32]) -> String { + use core::fmt::Write as _; + let mut out = String::with_capacity(64); + for b in bytes { + let _ = write!(out, "{b:02x}"); + } + out +} + +#[cfg(test)] +mod tests { + use super::hex_sha256; + + #[test] + fn hex_sha256_formats_lowercase_contiguous() { + let mut bytes = [0u8; 32]; + bytes[0] = 0xAB; + bytes[1] = 0x01; + bytes[31] = 0xFF; + let s = hex_sha256(&bytes); + assert_eq!(s.len(), 64); + assert!(s.starts_with("ab01")); + assert!(s.ends_with("ff")); + assert_eq!(s, s.to_lowercase()); + } +} diff --git a/crates/rustysnes-frontend/src/ui_shell.rs b/crates/rustysnes-frontend/src/ui_shell.rs index 40d1b0b..3f8a05e 100644 --- a/crates/rustysnes-frontend/src/ui_shell.rs +++ b/crates/rustysnes-frontend/src/ui_shell.rs @@ -19,6 +19,7 @@ use crate::cheats::CheatEntry; use crate::config::{Config, Region}; use crate::debug_snapshot::{DebugSnapshot, MEMORY_WINDOW_LEN, WatchpointEntry, WatchpointKind}; +use crate::debugger::RomInfo; use crate::input::Button; use crate::save_states::SlotMeta; @@ -375,6 +376,7 @@ impl ShellState { watchpoints: &mut Vec, breakpoints: &mut Vec, save_slots: Option<&[SlotMeta]>, + rom_info: Option<&RomInfo>, #[cfg(feature = "cheats")] cheats: &mut Vec, #[cfg(all(feature = "netplay", not(target_arch = "wasm32")))] netplay_connected: bool, #[cfg(all(feature = "retroachievements", not(target_arch = "wasm32")))] @@ -628,7 +630,14 @@ impl ShellState { self.render_settings(&ctx, cfg, info, &mut actions); } if self.debugger_open { - self.render_debugger(&ctx, debug, watchpoints, breakpoints, &mut actions); + self.render_debugger( + &ctx, + debug, + watchpoints, + breakpoints, + &mut actions, + rom_info, + ); } if self.save_states_open { self.render_save_states(&ctx, save_slots, &mut actions); diff --git a/docs/frontend.md b/docs/frontend.md index 04327f7..675ceef 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -741,6 +741,17 @@ kind picker + Add button, the armed list with per-row Remove buttons, and a scro directly — `DebugSnapshot` itself stays unconditionally compiled (see that struct's own doc), so its fields can't depend on a type that only exists when core's `debug-hooks` is on. +**ROM Info panel (`v1.20.0`):** a read-only CRC32/SHA-256/header decode of the loaded cart — +`crates/rustysnes-frontend/src/debugger/rom_info_panel.rs`. `RomInfo::capture` is called once per +ROM load/close (native `MenuAction::OpenRom`/`CloseRom` and the `wasm32` file-picker path), not +recomputed every frame like `DebugSnapshot` — a loaded ROM's identity and header never change while +it stays loaded. CRC32 comes from `crc32fast` (already resolved in the workspace's dependency tree +as a transitive dep, so a direct pin was free); SHA-256 reuses `rustysnes_core::movie::hash_rom` +rather than recomputing it. The header decode also picked up a genuinely new field along the way: +`rustysnes_cart::header::Header` gained a `title: String` (the raw 21-byte internal title, +non-printable bytes replaced with spaces, trailing padding trimmed) — `Header::parse` already had +the raw bytes in hand for its own coprocessor-disambiguation title match, this just surfaces them. + ## Scripting + TAS movies (`v0.8.0 "Instrumentation"`, T-81-002) A Tools menu (native only, `#[cfg(all(feature = "scripting", not(target_arch = "wasm32")))]`)