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}