diff --git a/crates/rustynes-core/src/nes.rs b/crates/rustynes-core/src/nes.rs index e57c36ab..2483e4a8 100644 --- a/crates/rustynes-core/src/nes.rs +++ b/crates/rustynes-core/src/nes.rs @@ -645,6 +645,21 @@ impl Nes { self.rewind_capture_enabled = enabled; } + /// Whether the per-frame rewind capture is currently armed. + /// + /// Added in v2.3.6 so a caller that needs to suppress capture temporarily can + /// save and restore the *caller's* setting rather than assume the default. + /// `rustynes-probe` does exactly that around a trial: its replayed frames + /// never happened on the user's timeline, so they must not enter the ring — + /// but nor may re-enabling capture afterwards turn it on for someone who had + /// deliberately turned it off. Run-ahead predates this and still restores an + /// unconditional `true`, which is correct only because nothing else disables + /// capture today. + #[must_use] + pub const fn rewind_capture_enabled(&self) -> bool { + self.rewind_capture_enabled + } + /// Step exactly one CPU instruction. For debuggers / step-through tools. pub fn step_instruction(&mut self) -> u8 { #[cfg(feature = "cpu-boot-trace")] diff --git a/crates/rustynes-frontend/src/app.rs b/crates/rustynes-frontend/src/app.rs index 257b4e3f..d4ae3617 100644 --- a/crates/rustynes-frontend/src/app.rs +++ b/crates/rustynes-frontend/src/app.rs @@ -1393,10 +1393,11 @@ impl App { // v1.6.0 "Studio" A2 — a TAStudio session anchors on the closed ROM; end it. if let Some(d) = self.debugger.as_mut() { d.clear_tas_editor(); - // v2.3.6 — a Latency Oracle report is bound to the ROM it was - // measured on. Left standing it describes a cartridge that is no - // longer loaded, with its Apply button still live. (PR #385 review.) - d.clear_latency_report(); + // v2.3.6 — analysis results are bound to the ROM they were derived + // from. Left standing they describe a cartridge that is no longer + // loaded: a Latency Oracle report with its Apply button still live + // (PR #385 review), or a 2,048-entry RAM Atlas that looks like a map. + d.clear_rom_bound_analysis(); } // Stop the dedicated emulation thread from producing frames. #[cfg(all(not(target_arch = "wasm32"), feature = "emu-thread"))] @@ -1704,10 +1705,11 @@ impl App { // replay inputs/branches against a different `Nes`. if let Some(d) = self.debugger.as_mut() { d.clear_tas_editor(); - // v2.3.6 — a Latency Oracle report is bound to the ROM it was - // measured on. Left standing it describes a cartridge that is no - // longer loaded, with its Apply button still live. (PR #385 review.) - d.clear_latency_report(); + // v2.3.6 — analysis results are bound to the ROM they were derived + // from. Left standing they describe a cartridge that is no longer + // loaded: a Latency Oracle report with its Apply button still live + // (PR #385 review), or a 2,048-entry RAM Atlas that looks like a map. + d.clear_rom_bound_analysis(); } // v2.8.0 Phase 5 increment 3 — a reload keeps the pacing regime but // may change the region (NTSC<->PAL frame duration); refresh the @@ -7450,9 +7452,27 @@ impl App { let netplay_locked = self.netplay_is_active() || self.ra_hardcore_blocks(); let mut guard = self.emu.lock(); let movie_locked = guard.movie.is_playing() || guard.movie.is_recording(); - guard.writes_locked = netplay_locked || movie_locked; + let locked = netplay_locked || movie_locked; + guard.writes_locked = locked; guard.raw_cheats = raw_cheats; guard.debug_pokes.extend(debug_pokes); + drop(guard); + self.publish_debugger_write_gate(locked); + } + } + + /// v2.3.6 — mirror the combined write gate onto the debugger overlay. + /// + /// The RAM Atlas needs the same predicate `emu.write` uses: it advances the + /// live `Nes` and pokes work RAM, so under netplay or a TAS record/replay it + /// would diverge a timeline other peers are lockstepped to, and under + /// RA-hardcore it is precisely the write the mode exists to forbid. Passed the + /// already-computed value rather than re-deriving it, so the two consumers + /// cannot drift apart. (PR #392 review.) + #[cfg(not(target_arch = "wasm32"))] + const fn publish_debugger_write_gate(&mut self, locked: bool) { + if let Some(d) = self.debugger.as_mut() { + d.set_writes_locked(locked); } } @@ -8279,9 +8299,18 @@ impl App { // replay/record; wasm has no native netplay/RA-hardcore). let debug_pokes = debugger.take_debug_pokes(); let mut guard = self.emu.lock(); - guard.writes_locked = guard.movie.is_playing() || guard.movie.is_recording(); + let locked = guard.movie.is_playing() || guard.movie.is_recording(); + guard.writes_locked = locked; guard.raw_cheats = raw_cheats; guard.debug_pokes.extend(debug_pokes); + // v2.3.6 — the RAM Atlas needs the same gate: it advances the + // live `Nes` and pokes work RAM, which would diverge a TAS + // timeline being recorded or replayed. Mirrored here as well as + // on the native path, because `post_produce_housekeeping` — the + // native republish site — is `cfg(not(wasm32))`, so relying on + // it alone would leave the wasm build ungated while every + // native gate looked correct. (PR #392 review.) + debugger.set_writes_locked(locked); } } } @@ -8587,10 +8616,11 @@ impl App { // session (it anchored on the previous `Nes`). if let Some(d) = self.debugger.as_mut() { d.clear_tas_editor(); - // v2.3.6 — a Latency Oracle report is bound to the ROM it was - // measured on. Left standing it describes a cartridge that is no - // longer loaded, with its Apply button still live. (PR #385 review.) - d.clear_latency_report(); + // v2.3.6 — analysis results are bound to the ROM they were derived + // from. Left standing they describe a cartridge that is no longer + // loaded: a Latency Oracle report with its Apply button still live + // (PR #385 review), or a 2,048-entry RAM Atlas that looks like a map. + d.clear_rom_bound_analysis(); } // v2.8.0 Phase 5 increment 3 — let the (idle) emulation thread start // producing now that the core holds a ROM. Set AFTER `nes` is in diff --git a/crates/rustynes-frontend/src/debugger/atlas_panel.rs b/crates/rustynes-frontend/src/debugger/atlas_panel.rs new file mode 100644 index 00000000..de7e3031 --- /dev/null +++ b/crates/rustynes-frontend/src/debugger/atlas_panel.rs @@ -0,0 +1,820 @@ +//! RAM Atlas panel (v2.3.6) — **what is each byte of work RAM for?** +//! +//! The UI over [`rustynes_probe::atlas`]. The classifier is headless and tested +//! independently; this file is presentation plus the two budgeted actions that +//! drive the emulator, and deliberately contains no classification logic of its +//! own — a threshold decided here would be a threshold no test could reach. +//! +//! # Why this is not the memory-compare panel +//! +//! Tools → Analysis already offers RAM Search and RAM Watch, and Debug → Memory +//! offers per-address access counts. Those narrow a set the user already has a +//! hypothesis about, or say an address was touched. Neither says what it is. This +//! panel classifies every address, then lets the user *verify* a candidate by +//! perturbing it — the step that separates cause from coincidence, and the step +//! no RAM search can perform. +//! +//! # The two actions are separate because they cost differently +//! +//! **Observe** runs a window of frames once and classifies all 2048 addresses. It +//! is bounded by [`OBSERVE_FRAMES`] and costs about three seconds of emulation. +//! +//! **Verify** costs two trials per address — a baseline and a perturbed run — so +//! a full 2048-address sweep would be over four thousand trials and tens of +//! minutes. It is therefore offered per-address on demand and as a bounded batch +//! of [`VERIFY_BATCH`], never as "verify everything". A button that quietly takes +//! twenty minutes is a worse affordance than one that admits its limit. +//! +//! # Honesty +//! +//! The panel's job is to not over-claim on the classifier's behalf. Three rules: +//! +//! - `Untested` renders as its own state, never blank and never as `Inert`. +//! - Every verification names the **lens** it used, because liveness is relative +//! to the observable (a byte is `Live` through work RAM and may be `Inert` +//! through the framebuffer). +//! - The evidence — change count, direction, range, the threshold that decided it +//! — is shown beside the label, so a reader can disagree with the label. + +use rustynes_core::{Buttons, Nes}; +use rustynes_probe::atlas::{ + self, Behaviour, FRAME_TICK_RATIO, Label, Liveness, Observation, SPARSE_MAX_CHANGES, WRAM_LEN, +}; +use rustynes_probe::{Budget, Observable, Probe}; + +use crate::icons::{glyph, label as ic}; + +/// Frames captured by one Observe. +/// +/// About three seconds of NTSC. Long enough for a score to tick, a timer to +/// count and an animation to cycle; short enough that the synchronous pause is +/// comparable to the Latency Oracle's, which users already accept. +const OBSERVE_FRAMES: u32 = 180; + +/// Frames each verification trial runs. +/// +/// Short on purpose: a poke that matters usually matters immediately, and the +/// cost is paid twice per address. A byte whose effect takes longer than this +/// reads `Inert`, which the panel says is "not observable inside the budget" +/// rather than "dead". +const VERIFY_FRAMES: u32 = 8; + +/// Trials one address costs to verify: a baseline plus a perturbed run. +/// +/// Named because it appears in three places that must agree — the hover text +/// quoting the cost, the `Budget` sizing in `do_verify`, and +/// `atlas::verify_liveness`'s own up-front affordability check. A bare `2` in the +/// budget with a stale number in the tooltip is the drift this prevents. +/// (PR #392 review.) +const TRIALS_PER_ADDRESS: usize = 2; + +/// Addresses one batch verification will attempt. +/// +/// Sixteen, so a batch is 32 trials — a fraction of a second of emulation, and +/// small enough that the button's cost is honestly predictable. The alternative, +/// verifying every changed address, is unbounded in the only case that matters: +/// a busy game where hundreds of addresses moved. +const VERIFY_BATCH: usize = 16; + +/// Persistent panel state. +pub struct AtlasPanel { + /// Classified labels; empty until an observation has been made. + labels: Vec