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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 37 additions & 0 deletions crates/rustysnes-cart/src/header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`).
Expand Down Expand Up @@ -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.
///
Expand Down Expand Up @@ -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]
Expand Down
5 changes: 5 additions & 0 deletions crates/rustysnes-frontend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"))`-
Expand Down
39 changes: 38 additions & 1 deletion crates/rustysnes-frontend/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,13 @@ struct Active {
rewind: crate::rewind::RewindBuffer,
/// A single quick-save-state slot (`MenuAction::SaveState`/`LoadState`).
quick_save: Option<Vec<u8>>,
/// 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<crate::debugger::RomInfo>,
/// 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<ScriptEngine>,
Expand Down Expand Up @@ -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<crate::debugger::RomInfo> = 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.
Expand Down Expand Up @@ -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")))]
Expand All @@ -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();
Expand Down Expand Up @@ -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")))]
Expand Down Expand Up @@ -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
Expand All @@ -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();
}
Expand Down
21 changes: 18 additions & 3 deletions crates/rustysnes-frontend/src/debugger/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -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/
Expand Down Expand Up @@ -81,6 +86,7 @@ impl ShellState {
watchpoints: &mut Vec<WatchpointEntry>,
breakpoints: &mut Vec<u32>,
actions: &mut Vec<MenuAction>,
rom_info: Option<&RomInfo>,
) {
let mut open = self.debugger_open;
egui::Window::new("Debugger")
Expand All @@ -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 => {
Expand All @@ -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 {
Expand All @@ -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")
}
}
Expand Down
Loading
Loading