Skip to main content

rustynes_ppu/
provenance.rs

1//! v2.3.2 "Lucid" — per-byte **write attribution** for PPU-visible memory.
2//!
3//! # What this is for
4//!
5//! The pixel-provenance debugger answers "why is this pixel this color?" by
6//! walking backwards from an emitted pixel through the PPU fetch pipeline to the
7//! bytes that fed it — the nametable byte, the attribute byte, the pattern-table
8//! bytes, the palette entry, the sprite record. That chain stops dead at the
9//! bytes themselves unless something remembers **who put them there**.
10//!
11//! The existing devtools each hold one piece and none holds this one. The Trace
12//! Logger has the PC but not the effect; the Event Viewer
13//! (`LockstepBus::events`) has the CPU-side `$2000-$3FFF` write with its PPU
14//! position but neither the resolved VRAM address nor the PC; the memory-access
15//! counter has per-address read/write counts and a last-access cycle stamp but,
16//! again, no PC. This module supplies the missing edge: for each byte of the
17//! PPU's own memories, the **program counter and CPU cycle of the write that
18//! last stored it**.
19//!
20//! # Why the storage lives here and not on the bus
21//!
22//! Attribution cannot be recorded at [`crate::PpuBus`]'s CPU-write boundary,
23//! because the *effective* destination is not visible there. A nametable byte is
24//! written by a `STA $2007` whose target address lives in the PPU's internal `v`
25//! register, set earlier by two `$2006` writes; a palette entry is the same
26//! `$2007` store landing in a different memory; an OAM byte arrives either
27//! through `$2004` or as one of 256 bytes of an OAM DMA burst triggered by a
28//! single `$4014` store. Only the PPU knows where each one actually went, so the
29//! record is stamped at the store site — [`Ppu::write_vram`], [`Ppu::write_palette`],
30//! and the two OAM write paths — with a `(pc, cycle)` context the bus latches per
31//! instruction and hands down.
32//!
33//! [`Ppu::write_vram`]: crate::Ppu
34//! [`Ppu::write_palette`]: crate::Ppu
35//!
36//! # Cost
37//!
38//! The whole module is `debug-hooks`-gated, and even under that feature the store
39//! is allocated lazily — [`Ppu::write_attribution`] is `None` until the frontend
40//! arms it, so a `debug-hooks` build with the provenance panel closed pays one
41//! `Option` discriminant test per PPU-memory write and nothing else. Armed, it
42//! costs [`WriteAttribution::HEAP_BYTES`] of heap and one 16-byte store per
43//! write. Nothing here is read by emulation, so the framebuffer, the audio, and
44//! the cycle counts are bit-identical whether it is armed or not.
45//!
46//! [`Ppu::write_attribution`]: crate::Ppu
47//!
48//! # Deliberate scope limit
49//!
50//! CHR writes (`$0000-$1FFF`) are **not** attributed. That window is owned by the
51//! mapper — it may be ROM (undriveable), CHR-RAM, or a board-specific window with
52//! its own banking — so a byte offset here is not a stable identity across a bank
53//! switch the way CIRAM, palette RAM, and OAM offsets are. Pattern-table
54//! provenance is reported as "supplied by mapper bank N", which the mapper
55//! already knows, rather than being faked with an unstable offset.
56
57extern crate alloc;
58
59use alloc::boxed::Box;
60use alloc::vec;
61
62/// Number of attributed CIRAM bytes — the 2 KiB of internal nametable RAM.
63///
64/// Mapper-supplied nametable memory (`MMC5` `ExRAM`, 4-screen boards) is written
65/// through [`crate::PpuBus::write_nametable`] and is not covered; see the module
66/// docs.
67pub const CIRAM_LEN: usize = 0x0800;
68
69/// Number of attributed OAM bytes (64 sprites x 4).
70pub const OAM_LEN: usize = 0x0100;
71
72/// Number of attributed palette-RAM entries.
73///
74/// Indices are post-mirroring (the `$3F10/$14/$18/$1C` aliases fold onto
75/// `$3F00/$04/$08/$0C`), so an attribution looked up through either address
76/// returns the same record — which is correct, because they *are* the same byte.
77pub const PALETTE_LEN: usize = 0x20;
78
79/// One write-attribution record: who wrote a byte, when, and with what.
80///
81/// `cycle` is the CPU cycle counter at the instruction that performed the write,
82/// which is what makes a record comparable against the Trace Logger and the
83/// Event Viewer for the same frame. It is not the PPU dot — the PPU position is
84/// recoverable from the cycle and is already carried by the Event Viewer.
85#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
86pub struct WriteAttrib {
87    /// CPU cycle count at the writing instruction's opcode fetch.
88    pub cycle: u64,
89    /// Program counter of the writing instruction.
90    ///
91    /// For an OAM DMA burst this is the PC of the `STA $4014` that triggered it,
92    /// not a synthetic per-byte PC — 256 bytes genuinely share one cause.
93    pub pc: u16,
94    /// The byte value stored, **as stored**. Palette writes are masked to 6 bits
95    /// before recording, so this matches what a subsequent read returns rather
96    /// than what the CPU put on the bus.
97    pub value: u8,
98}
99
100/// One attribution slot: a [`WriteAttrib`] plus whether anything has been
101/// written yet.
102///
103/// A `written` flag rather than a sentinel cycle, because cycle 0 is a legitimate
104/// value — the reset sequence performs real writes — and a sentinel would
105/// silently misreport the earliest writes in a run as "never written".
106#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash)]
107struct Slot {
108    rec: WriteAttribInner,
109    written: bool,
110}
111
112/// Storage-side mirror of [`WriteAttrib`] with a `Default`, so a slot array can
113/// be built without inventing a meaningless public default PC.
114#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash)]
115struct WriteAttribInner {
116    cycle: u64,
117    pc: u16,
118    value: u8,
119}
120
121impl Slot {
122    const fn get(self) -> Option<WriteAttrib> {
123        if self.written {
124            Some(WriteAttrib {
125                cycle: self.rec.cycle,
126                pc: self.rec.pc,
127                value: self.rec.value,
128            })
129        } else {
130            None
131        }
132    }
133
134    const fn set(&mut self, pc: u16, cycle: u64, value: u8) {
135        self.rec = WriteAttribInner { cycle, pc, value };
136        self.written = true;
137    }
138}
139
140/// Per-byte write attribution for the PPU's own memories.
141///
142/// Construct via [`WriteAttribution::new`]. Every index is masked, so no accessor
143/// can panic on an out-of-range offset — a provenance query is a debugging
144/// convenience and must never be able to take down the emulator it is inspecting.
145#[derive(Clone, Debug)]
146pub struct WriteAttribution {
147    ciram: Box<[Slot]>,
148    oam: Box<[Slot]>,
149    palette: [Slot; PALETTE_LEN],
150}
151
152impl WriteAttribution {
153    /// Heap cost of one armed store, in bytes. Reported by the UI so the memory
154    /// price of arming is visible rather than folded into general process growth.
155    pub const HEAP_BYTES: usize = (CIRAM_LEN + OAM_LEN) * size_of::<Slot>();
156
157    /// Allocate a cleared attribution store.
158    #[must_use]
159    pub fn new() -> Self {
160        Self {
161            ciram: vec![Slot::default(); CIRAM_LEN].into_boxed_slice(),
162            oam: vec![Slot::default(); OAM_LEN].into_boxed_slice(),
163            palette: [Slot::default(); PALETTE_LEN],
164        }
165    }
166
167    /// Forget every recorded write, keeping the allocation.
168    ///
169    /// Called on power-cycle and on save-state restore: attribution describes the
170    /// history of *this* run, and a restored state's bytes were not written by
171    /// any instruction this session executed. Reporting stale PCs after a restore
172    /// would be worse than reporting nothing.
173    pub fn clear(&mut self) {
174        self.ciram.fill(Slot::default());
175        self.oam.fill(Slot::default());
176        self.palette = [Slot::default(); PALETTE_LEN];
177    }
178
179    /// Record a write to internal nametable RAM at physical offset `off`
180    /// (mirroring already resolved by the caller).
181    pub fn record_ciram(&mut self, off: usize, pc: u16, cycle: u64, value: u8) {
182        self.ciram[off & (CIRAM_LEN - 1)].set(pc, cycle, value);
183    }
184
185    /// Record a write to OAM at byte index `idx`.
186    pub fn record_oam(&mut self, idx: u8, pc: u16, cycle: u64, value: u8) {
187        self.oam[idx as usize].set(pc, cycle, value);
188    }
189
190    /// Record a write to palette RAM at post-mirroring index `idx`.
191    pub const fn record_palette(&mut self, idx: usize, pc: u16, cycle: u64, value: u8) {
192        self.palette[idx & (PALETTE_LEN - 1)].set(pc, cycle, value);
193    }
194
195    /// The write that last stored the CIRAM byte at physical offset `off`, or
196    /// `None` if nothing has written it since the store was armed or cleared.
197    #[must_use]
198    pub fn ciram(&self, off: usize) -> Option<WriteAttrib> {
199        self.ciram[off & (CIRAM_LEN - 1)].get()
200    }
201
202    /// The write that last stored OAM byte `idx`.
203    #[must_use]
204    pub fn oam(&self, idx: u8) -> Option<WriteAttrib> {
205        self.oam[idx as usize].get()
206    }
207
208    /// The write that last stored palette entry `idx` (post-mirroring).
209    #[must_use]
210    pub const fn palette(&self, idx: usize) -> Option<WriteAttrib> {
211        self.palette[idx & (PALETTE_LEN - 1)].get()
212    }
213}
214
215impl Default for WriteAttribution {
216    fn default() -> Self {
217        Self::new()
218    }
219}
220
221// ---------------------------------------------------------------------------
222// Per-pixel provenance
223// ---------------------------------------------------------------------------
224
225/// Which layer won the priority decision at a pixel.
226#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash)]
227pub enum PixelLayer {
228    /// Neither layer was opaque: the universal backdrop (`$3F00`), or the
229    /// palette-address override the PPU applies when rendering is disabled with
230    /// `v` pointing into palette space.
231    #[default]
232    Backdrop,
233    /// The background pattern won.
234    Background,
235    /// A sprite won — either because the background pixel was transparent or
236    /// because the sprite had front priority.
237    Sprite,
238}
239
240/// [`PixelProvenance::sprite_slot`] when no sprite won the pixel.
241pub const SPRITE_SLOT_NONE: u8 = 0xFF;
242
243/// [`PixelProvenance::pattern_addr`] when the pixel has no pattern behind it
244/// (backdrop), so a zero would be indistinguishable from a real `$0000` fetch.
245pub const PATTERN_ADDR_NONE: u16 = 0xFFFF;
246
247/// The causal record for one emitted pixel.
248///
249/// Recorded in `Ppu::emit_pixel` from state already computed there plus the
250/// per-tile address cascade described on `Ppu::prov_bg_cur`. Everything here is
251/// an address or a decision the hardware actually made; nothing is reconstructed
252/// after the fact, because a reconstruction would silently disagree with the
253/// emulator in exactly the corner cases a provenance panel exists to explain.
254///
255/// # Reading it
256///
257/// [`Self::palette_index`] indexes straight into
258/// [`WriteAttribution::palette`], and [`Self::nt_addr`] resolves (through the
259/// mapper's mirroring) to the CIRAM offset for [`WriteAttribution::ciram`] — so
260/// the two halves of this module compose into "this pixel is this color because
261/// instruction X wrote this palette entry and instruction Y wrote this nametable
262/// byte".
263#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash)]
264pub struct PixelProvenance {
265    /// PPU scanline the pixel was emitted on.
266    pub scanline: i16,
267    /// PPU dot the pixel was emitted at. Screen X is `dot - 1`.
268    pub dot: u16,
269    /// Which layer won.
270    pub layer: PixelLayer,
271    /// The exact palette-RAM address read for the final color, pre-mirroring —
272    /// so `$3F10` is reported as `$3F10` even though it reads `$3F00`.
273    pub palette_addr: u16,
274    /// Post-mirroring palette-RAM index of the final color. Indexes
275    /// [`WriteAttribution::palette`] directly.
276    pub palette_index: u8,
277    /// The 6-bit NES color the palette entry held, before emphasis.
278    pub color: u8,
279    /// `$2001` grayscale + emphasis bits in effect at this pixel
280    /// (`mask & 0xE1`), which is what turns [`Self::color`] into the RGBA
281    /// actually written.
282    pub color_mask: u8,
283    /// Nametable address of the background tile **being displayed** at this
284    /// pixel — not the address `v` currently holds, which has already advanced
285    /// two tiles ahead. See `Ppu::prov_bg_cur`.
286    pub nt_addr: u16,
287    /// Attribute address of the displayed background tile. Carried rather than
288    /// derived from [`Self::nt_addr`], because an MMC5 vertical split supplies
289    /// its own attribute address that the standard arithmetic cannot produce.
290    pub at_addr: u16,
291    /// CHR address of the pattern row feeding this pixel — the displayed
292    /// background tile's row, or the winning sprite's row.
293    /// [`PATTERN_ADDR_NONE`] for a backdrop pixel.
294    pub pattern_addr: u16,
295    /// Background pattern bits (0..=3) at this pixel. 0 is transparent.
296    pub bg_idx: u8,
297    /// Background attribute / palette group (0..=3).
298    pub bg_pal: u8,
299    /// Sprite pattern bits (0..=3) at this pixel. 0 is transparent.
300    pub spr_idx: u8,
301    /// Sprite palette group (0..=3).
302    pub spr_pal: u8,
303    /// Sprite slot (0..=7) of the winning sprite, or [`SPRITE_SLOT_NONE`].
304    ///
305    /// This is the secondary-OAM slot for the scanline, **not** the primary OAM
306    /// sprite number: sprite evaluation copies bytes from primary to secondary
307    /// OAM without retaining the source index, so the primary index is not
308    /// available at emit time. The panel matches the slot's Y/tile/attribute
309    /// against OAM rather than being handed an index the PPU never kept.
310    pub sprite_slot: u8,
311    /// `true` when the winning sprite had front priority over the background.
312    pub sprite_front: bool,
313    /// `true` when sprite 0 contributed an opaque pixel here — the condition the
314    /// sprite-0 hit flag is derived from.
315    pub sprite_zero: bool,
316    /// Fine-X scroll in effect (0..=7): which texel column of the displayed
317    /// background tile this pixel samples.
318    pub fine_x: u8,
319    /// Fine-Y (0..=7): which texel row of the displayed background tile.
320    pub fine_y: u8,
321}
322
323impl PixelProvenance {
324    /// Whether this record was actually emitted, as opposed to being the cleared
325    /// [`Default`].
326    ///
327    /// `Ppu::emit_pixel` stamps [`Self::dot`] on every pixel it records, and the
328    /// visible dots are `1..=256` — so dot 0 is unreachable for a real record and
329    /// is exactly what `clear` leaves behind. Without this a caller cannot tell a
330    /// cleared record from a genuine backdrop pixel, and reads a confident
331    /// "scanline 0, dot 0, backdrop, palette $0000" as fact. That is precisely how
332    /// the v2.3.2 inspector reported a wiped frame (v2.3.6 workstream 0).
333    #[must_use]
334    pub const fn is_recorded(&self) -> bool {
335        self.dot != 0
336    }
337}
338
339/// Screen width in pixels, and the stride of a [`PixelProvenanceFrame`].
340///
341/// An alias, not a second definition: see [`crate::SCREEN_WIDTH`].
342pub const SCREEN_W: usize = crate::SCREEN_WIDTH;
343/// Screen height in pixels. Alias of [`crate::SCREEN_HEIGHT`].
344pub const SCREEN_H: usize = crate::SCREEN_HEIGHT;
345
346/// One frame of [`PixelProvenance`], indexed by `y * SCREEN_W + x`.
347///
348/// Overwritten in place every frame, exactly like the framebuffer it shadows, so
349/// a query after `run_frame` describes the frame the user is looking at.
350#[derive(Clone, Debug)]
351pub struct PixelProvenanceFrame {
352    pixels: Box<[PixelProvenance]>,
353}
354
355impl PixelProvenanceFrame {
356    /// Heap cost of one armed frame, in bytes.
357    pub const HEAP_BYTES: usize = SCREEN_W * SCREEN_H * size_of::<PixelProvenance>();
358
359    /// Allocate a cleared frame.
360    #[must_use]
361    pub fn new() -> Self {
362        Self {
363            pixels: vec![PixelProvenance::default(); SCREEN_W * SCREEN_H].into_boxed_slice(),
364        }
365    }
366
367    /// The record for screen pixel `(x, y)`, or `None` if either coordinate is
368    /// off-screen. Returning `None` rather than clamping matters: a clamped
369    /// query would answer confidently about a pixel the caller did not ask for.
370    #[must_use]
371    pub fn get(&self, x: usize, y: usize) -> Option<PixelProvenance> {
372        if x >= SCREEN_W || y >= SCREEN_H {
373            return None;
374        }
375        Some(self.pixels[y * SCREEN_W + x])
376    }
377
378    /// Record a pixel. Out-of-range coordinates are dropped rather than
379    /// wrapping into an unrelated pixel.
380    pub fn set(&mut self, x: usize, y: usize, rec: PixelProvenance) {
381        if x < SCREEN_W && y < SCREEN_H {
382            self.pixels[y * SCREEN_W + x] = rec;
383        }
384    }
385
386    /// The whole frame, row-major.
387    #[must_use]
388    pub fn pixels(&self) -> &[PixelProvenance] {
389        &self.pixels
390    }
391
392    /// Forget every recorded pixel, keeping the allocation.
393    ///
394    /// Called on power-cycle and on save-state restore for the same reason
395    /// [`WriteAttribution::clear`] is: the records describe a timeline the
396    /// restore replaced. The framebuffer analogy does NOT excuse keeping them —
397    /// the framebuffer is serialized and comes back consistent with the restored
398    /// state, whereas this frame is not, so a restore landing mid-frame would
399    /// leave pre-restore addresses for every pixel above the current scanline
400    /// with nothing marking them stale.
401    pub fn clear(&mut self) {
402        self.pixels.fill(PixelProvenance::default());
403    }
404}
405
406impl Default for PixelProvenanceFrame {
407    fn default() -> Self {
408        Self::new()
409    }
410}
411
412// ---------------------------------------------------------------------------
413// Stashing both stores across a same-timeline restore
414// ---------------------------------------------------------------------------
415
416/// Both provenance stores, moved out of a [`Ppu`] so a caller can put them back.
417///
418/// # Why this exists
419///
420/// A save-state restore clears both stores, and that is right: the restored
421/// bytes were not written by anything this session ran, so the honest answer is
422/// "no record" rather than a PC from a timeline that no longer exists.
423///
424/// Run-ahead is the one caller for which that is wrong, and it is wrong for a
425/// reason that has nothing to do with the restore itself. Its cycle runs the
426/// persistent frame, snapshots, runs the hidden and then the **visible** frame,
427/// lets the frontend harvest that frame, and only then rolls back. The rollback
428/// is therefore the *last* thing to happen before the emulator lock reaches the
429/// UI — so a clear there does not discard a stale timeline, it discards the
430/// record for the exact frame the user is looking at, before anyone can read it.
431/// That is what made the shipped Pixel Provenance inspector render a complete,
432/// confident, entirely empty report (v2.3.6 workstream 0, defect 1).
433///
434/// Stashing is a **move, not a copy**: both stores are boxed, so this costs two
435/// pointer moves per visible frame rather than the ~37 KiB memcpy a snapshot of
436/// the contents would. The restore in between sees `None` on both, so its clear
437/// is a no-op and its own reasoning is left completely intact — this mechanism
438/// changes nothing for save-state loads or for netplay rollback, both of which
439/// still want the clear.
440///
441/// [`Ppu`]: crate::Ppu
442#[derive(Debug, Default)]
443pub struct ProvenanceStash {
444    pub(crate) write_attrib: Option<Box<WriteAttribution>>,
445    pub(crate) prov_frame: Option<Box<PixelProvenanceFrame>>,
446    pub(crate) prov_armed: bool,
447}
448
449impl ProvenanceStash {
450    /// Whether either store was armed when this stash was taken.
451    ///
452    /// Callers use it to skip the put-back entirely on the overwhelmingly
453    /// common path where nothing is armed at all.
454    #[must_use]
455    pub const fn is_armed(&self) -> bool {
456        self.prov_armed || self.write_attrib.is_some()
457    }
458
459    /// The stashed per-pixel provenance frame, or `None` when unarmed.
460    ///
461    /// v2.3.8 — read-only, so a detached stash can be inspected without being
462    /// put back into an emulator first. The Divergence Lens captures a trial's
463    /// provenance precisely so it never touches a live `Nes` again; routing the
464    /// read through a scratch instance would undo that. Mirrors
465    /// `rustynes_apu::provenance::AudioProvenanceStash::mix_trace`.
466    #[must_use]
467    pub fn pixel_frame(&self) -> Option<&PixelProvenanceFrame> {
468        self.prov_frame.as_deref()
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    #[test]
477    fn unwritten_slots_report_none() {
478        let a = WriteAttribution::new();
479        assert_eq!(a.ciram(0), None);
480        assert_eq!(a.oam(0), None);
481        assert_eq!(a.palette(0), None);
482    }
483
484    #[test]
485    fn cycle_zero_is_a_real_record_not_a_sentinel() {
486        // The reset sequence performs writes at low cycle counts; a store that
487        // used `cycle == 0` as "never written" would erase them.
488        let mut a = WriteAttribution::new();
489        a.record_ciram(0x123, 0xC000, 0, 0x42);
490        assert_eq!(
491            a.ciram(0x123),
492            Some(WriteAttrib {
493                cycle: 0,
494                pc: 0xC000,
495                value: 0x42
496            })
497        );
498    }
499
500    #[test]
501    fn indices_wrap_instead_of_panicking() {
502        let mut a = WriteAttribution::new();
503        a.record_ciram(CIRAM_LEN + 5, 0x8000, 7, 0x11);
504        assert_eq!(a.ciram(5).map(|r| r.value), Some(0x11));
505        a.record_palette(PALETTE_LEN + 3, 0x8001, 8, 0x22);
506        assert_eq!(a.palette(3).map(|r| r.value), Some(0x22));
507    }
508
509    #[test]
510    fn last_write_wins() {
511        let mut a = WriteAttribution::new();
512        a.record_oam(9, 0xA000, 10, 0x01);
513        a.record_oam(9, 0xB000, 20, 0x02);
514        assert_eq!(
515            a.oam(9),
516            Some(WriteAttrib {
517                cycle: 20,
518                pc: 0xB000,
519                value: 0x02
520            })
521        );
522    }
523
524    #[test]
525    fn clear_forgets_everything() {
526        let mut a = WriteAttribution::new();
527        a.record_ciram(1, 0x8000, 1, 1);
528        a.record_oam(1, 0x8000, 1, 1);
529        a.record_palette(1, 0x8000, 1, 1);
530        a.clear();
531        assert_eq!(a.ciram(1), None);
532        assert_eq!(a.oam(1), None);
533        assert_eq!(a.palette(1), None);
534    }
535}