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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Debugger overlay: live CPU/PPU/APU/Cart state viewers — `v0.8.0 "Instrumentation"`,
T-81-001 (PR A of 2).** `ui_shell.rs`'s debugger window (menu entry, panel selector) has
existed since the frontend's first cut but every panel was a literal `"TODO(impl-phase)"`
label. This lands the state-viewer half: a new `DebugSnapshot` (mirroring `ShellInfo`'s
own copy-out-under-the-brief-lock pattern — the shell's non-negotiable rule that egui never
touches the emu lock directly) shows real 65C816 registers/flags, key PPU registers + the
dot/scanline timeline + a scrollable VRAM window + full CGRAM, SPC700 PC/halt state + all 8
S-DSP voices' key registers, and the active board name plus (when loaded) SA-1's second-CPU
registers or the Super FX/GSU register file — resolving `docs/frontend.md`'s open question in
the breadth-inclusive direction this whole ladder takes. New small read-only accessors added
to `rustysnes-ppu` (`bg_mode`/`display_brightness`), `rustysnes-core` (`System::sa1_regs`),
and a new `Board::debug_gsu_state` default-no-op trait hook (overridden by `SuperFxBoard`) —
all read-only, no new mutation paths, zero risk to the 0-diff CPU/SPC700 oracles (verified:
the full `--features test-roms` suite passes unchanged). The Debug menu entry that opens the
overlay is gated behind the `debug-hooks` feature (default off) — without it, the debugger
can never open, so the app never builds a snapshot and the default build's emulation output is
untouched. **Deferred to T-81-006, not this pass:** the 65C816 disassembler + breakpoints/
step controls (needs `System::step_instruction()`-driven stepping, not core changes) and
read/write watchpoints (needs a new `debug-hooks` feature on `rustysnes-core` itself + a
`Bus`-level hook — scoped as its own separate, focused change, T-81-001b, since it touches the
hottest path in the engine).

### Changed

- **Folded the real wasm frontend build into `v0.8.0 "Instrumentation"`'s scope, per explicit
Expand Down
8 changes: 8 additions & 0 deletions crates/rustysnes-cart/src/board.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,14 @@ pub trait Board {
false
}

/// The GSU register file (R0-R15 + SFR + PBR), for a Super FX board's debugger Cart panel.
///
/// Default `None` — only [`crate::coproc::superfx::SuperFxBoard`] overrides this. A
/// read-only debug accessor, not a control surface (`docs/frontend.md` §Debugger overlay).
fn debug_gsu_state(&self) -> Option<([u16; 16], u16, u8)> {
None
}

/// Supply a coprocessor firmware dump (e.g. the DSP-1 `dsp1.rom`). Default `false` — a base
/// board has no firmware to load. A chip-ROM-dump coprocessor returns `true` once the dump is
/// accepted; without it the board is non-functional, never silently degraded (`docs/adr/0003`).
Expand Down
22 changes: 22 additions & 0 deletions crates/rustysnes-cart/src/coproc/gsu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,28 @@ impl Gsu {
self.dreg = 0;
}

// --- Debug-only read accessors (no side effects, unlike `read_register`'s memory-mapped
// window which can have read-clear/latch behavior on some addresses). For the debugger
// overlay's Cart panel (`docs/frontend.md` §Debugger overlay). ------------------------------

/// The R0-R15 general-purpose register file (R15 is also the program counter).
#[must_use]
pub const fn registers(&self) -> [u16; 16] {
self.r
}

/// The status flag register (SFR).
#[must_use]
pub const fn sfr(&self) -> u16 {
self.sfr
}

/// The program bank register.
#[must_use]
pub const fn pbr(&self) -> u8 {
self.pbr
}

// --- The host-sync driver. -------------------------------------------------------------

/// Drain one bus-access clock checkpoint (ares `SuperFX::step`/`Thread::synchronize`
Expand Down
4 changes: 4 additions & 0 deletions crates/rustysnes-cart/src/coproc/superfx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,10 @@ impl Board for SuperFxBoard {
self.gsu.irq_pending()
}

fn debug_gsu_state(&self) -> Option<([u16; 16], u16, u8)> {
Some((self.gsu.registers(), self.gsu.sfr(), self.gsu.pbr()))
}

fn coprocessor_host_accesses(&self) -> u64 {
// Surface the GSU instruction count when the chip has run, else the register-access count.
// Either is a non-zero liveness signal only if the bus window is mapped right and the GSU
Expand Down
9 changes: 8 additions & 1 deletion crates/rustysnes-core/src/scheduler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

use alloc::vec::Vec;

use rustysnes_cpu::Cpu;
use rustysnes_cpu::{Cpu, Regs};
use rustysnes_savestate::{SaveReader, SaveStateError, SaveWriter};

use crate::bus::Bus;
Expand Down Expand Up @@ -204,6 +204,13 @@ impl System {
self.sa1_cpu.as_ref().map(|c| c.cycles)
}

/// The SA-1 second CPU's architectural register file, or `None` when no SA-1 cart is
/// installed. For the debugger overlay's Cart panel (`docs/frontend.md` §Debugger overlay).
#[must_use]
pub fn sa1_regs(&self) -> Option<Regs> {
self.sa1_cpu.as_ref().map(|c| c.regs)
}

/// Step a single CPU instruction (drives the whole machine in lockstep via the Bus).
pub fn step_instruction(&mut self) {
if !self.booted {
Expand Down
9 changes: 6 additions & 3 deletions crates/rustysnes-frontend/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,7 @@ impl App {

let paused = active.shell.paused;
// --- (1) Copy framebuffer + audio + read-only info under a BRIEF lock, then drop it. ---
let (fb, fb_dims, info, audio_samples) = {
let (fb, fb_dims, info, audio_samples, debug) = {
// `mut` is only needed on the synchronous drive path (run_frame/set_pad); the threaded
// build only reads through the guard here.
#[cfg_attr(feature = "emu-thread", allow(unused_mut))]
Expand Down Expand Up @@ -438,8 +438,11 @@ impl App {
fps: active.pacer.fps,
rom_loaded: emu.rom_loaded(),
};
// Only build the debugger snapshot when the window is actually open — a real,
// avoidable per-frame cost otherwise (`docs/frontend.md` §Debugger overlay).
let debug = active.shell.debugger_open.then(|| emu.debug_snapshot());
drop(emu); // release the brief lock BEFORE the wgpu upload + egui pass
(fb, dims, info, audio_samples)
(fb, dims, info, audio_samples, debug)
};

// --- Push the frame's audio through the resampler into the ring (outside the lock). ---
Expand Down Expand Up @@ -478,7 +481,7 @@ impl App {
let raw_input = active.egui_state.take_egui_input(&active.window);
let mut actions = Vec::new();
let full_output = active.egui_ctx.run_ui(raw_input, |ui| {
actions = active.shell.render(ui, &info, config);
actions = active.shell.render(ui, &info, config, debug.as_ref());
});
active
.egui_state
Expand Down
113 changes: 113 additions & 0 deletions crates/rustysnes-frontend/src/debug_snapshot.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
//! The debugger overlay's per-frame read-only state copy.
//!
//! Mirrors [`crate::ui_shell::ShellInfo`]'s own pattern exactly: [`crate::emu::EmuCore::debug_snapshot`]
//! copies plain data out of the [`rustysnes_core::System`] under the SAME brief lock `ShellInfo`
//! already uses, before the lock is dropped and the egui pass runs — the shell's non-negotiable
//! rule (`ui_shell.rs`'s module doc) is that egui NEVER touches the emu lock directly.

use rustysnes_core::cpu::Regs;

/// One frame's worth of read-only chip state for the debugger overlay's 4 panels.
///
/// Built by [`crate::emu::EmuCore::debug_snapshot`] under the brief emu lock, then handed to
/// [`crate::ui_shell::ShellState::render`] after the lock is released.
#[derive(Debug, Clone)]
pub struct DebugSnapshot {
/// The main 65C816's architectural register file.
pub cpu: Regs,
/// PPU1/PPU2 state.
pub ppu: PpuSnapshot,
/// SPC700 + S-DSP state.
pub apu: ApuSnapshot,
/// The loaded cart's board + any coprocessor state.
pub cart: CartSnapshot,
}

/// PPU state for the debugger's PPU panel.
#[derive(Debug, Clone)]
pub struct PpuSnapshot {
/// `BGMODE` ($2105), 0..=7.
pub bg_mode: u8,
/// `INIDISP` ($2100) master brightness, 0..=15.
pub display_brightness: u8,
/// Whether the current frame is hi-res (512-wide, `v0.7.0`).
pub is_hires: bool,
/// The current scanline (`Ppu::scanline`).
pub scanline: u16,
/// The current dot within the scanline (`Ppu::dot`).
pub dot: u16,
/// Whether the PPU is in vertical blank.
pub in_vblank: bool,
/// Whether the PPU is in horizontal blank.
pub in_hblank: bool,
/// The full 256-entry CGRAM palette (512 bytes — cheap to copy wholesale every frame, unlike
/// VRAM's 64 KiB).
pub cgram: [u16; 256],
/// A [`VRAM_WINDOW_LEN`]-word window of VRAM starting at `vram_window_start` (word address) —
/// copying all 64 KiB every frame would be real, avoidable per-frame cost for a window the
/// user can only look at part of at once. `EmuCore::set_debug_vram_scroll` moves the window;
/// no UI control calls it yet (fixed at the window's start address today) — a follow-up.
pub vram_window: [u16; VRAM_WINDOW_LEN],
/// The word address `vram_window` starts at.
pub vram_window_start: u16,
/// The full 544-byte OAM (small enough to copy wholesale every frame).
pub oam: [u8; 544],
}

/// Words per VRAM viewer window (2 KiB) — big enough for a meaningful hex-dump page, small
/// enough that copying it every frame is not a real cost next to a whole PPU dot-tick pass.
pub const VRAM_WINDOW_LEN: usize = 1024;

/// APU (SPC700 + S-DSP) state for the debugger's APU panel.
#[derive(Debug, Clone, Copy)]
pub struct ApuSnapshot {
/// The SMP's program counter.
pub smp_pc: u16,
/// Whether the SMP is halted (`STOP`/`SLEEP`).
pub smp_stopped: bool,
/// Per-voice `(vol_left, vol_right, pitch, srcn, adsr_lo, adsr_hi, gain, envx, outx)` —
/// the DSP registers a debugger cares about, read via `Apu::dsp_read` (no side effects).
pub voices: [VoiceSnapshot; 8],
}

/// One S-DSP voice's key registers (`docs/apu.md`'s DSP register map, per-voice base `v*0x10`).
#[derive(Debug, Clone, Copy, Default)]
pub struct VoiceSnapshot {
/// `VOLL`/`VOLR`.
pub vol: (i8, i8),
/// `PITCHL`/`PITCHH` (14-bit).
pub pitch: u16,
/// `SRCN` (the sample source-directory entry).
pub srcn: u8,
/// `ADSR1`/`ADSR2`.
pub adsr: (u8, u8),
/// `GAIN`.
pub gain: u8,
/// `ENVX` (the current envelope level).
pub envx: u8,
/// `OUTX` (the current sample output).
pub outx: u8,
}

/// Cart/coprocessor state for the debugger's Cart panel.
#[derive(Debug, Clone)]
pub struct CartSnapshot {
/// The active board's name (`Board::name()`), e.g. `"HiROM+SuperFX"`.
pub board_name: Option<&'static str>,
/// The SA-1 second CPU's register file, when the loaded cart is an SA-1 board.
pub sa1: Option<Regs>,
/// The Super FX/GSU register file (R0-R15, SFR, PBR), when the loaded cart is a Super FX
/// board.
pub gsu: Option<GsuSnapshot>,
}

/// The GSU register file, as exposed by `Board::debug_gsu_state`.
#[derive(Debug, Clone, Copy)]
pub struct GsuSnapshot {
/// R0-R15 (R15 doubles as the GSU program counter).
pub r: [u16; 16],
/// The status flag register.
pub sfr: u16,
/// The program bank register.
pub pbr: u8,
}
Loading