Skip to main content

rustynes_core/
bus.rs

1//! Lockstep bus for the `Nes` facade.
2//!
3//! Per `docs/scheduler.md` §Bus design: this bus owns CPU RAM, the PPU, the
4//! APU, the cartridge mapper, and the controller stub. Each
5//! `cpu_read`/`cpu_write` ticks the PPU exactly 3 times (NTSC) and dispatches
6//! the access to the right device. PPU register reads have side effects;
7//! OAM DMA and DMC DMA are handled by `cpu_cycles_owed`-style state machines
8//! that drain stolen cycles before completing the access that triggered them.
9
10use alloc::collections::BTreeMap;
11use alloc::format;
12use alloc::{boxed::Box, vec::Vec};
13
14use crate::genie::{GenieCode, GenieError};
15use rustynes_apu::{Apu, ApuSnapshotError, Region as ApuRegion};
16
17/// v2.0 R1c-1 DIAGNOSTIC (gated `cpu-instr-cycle-trace`).
18///
19/// A per-CPU-instruction `(PC, cumulative cpu_cycle)` ring buffer (keeps the
20/// LAST `CAP` instructions). `Cpu::step` calls `trace_instr` at each opcode
21/// fetch; the harness dumps the ring (R1 + default) and diffs the
22/// per-instruction cycle deltas to pin the odd-cycle cumulative divergence (the
23/// Y=3-vs-4 source). Read via `rustynes_core::instr_trace`.
24#[cfg(feature = "cpu-instr-cycle-trace")]
25pub mod instr_trace {
26    use core::sync::atomic::{AtomicU32, AtomicU64, Ordering::Relaxed};
27    /// Ring capacity (last CAP instructions kept).
28    pub const CAP: usize = 1 << 18; // 262144
29    /// Per-entry instruction PC.
30    pub static PC: [AtomicU32; CAP] = [const { AtomicU32::new(0) }; CAP];
31    /// Per-entry cumulative CPU cycle.
32    pub static CYC: [AtomicU64; CAP] = [const { AtomicU64::new(0) }; CAP];
33    /// Monotonic write index (total instructions; ring slot = `IDX % CAP`).
34    pub static IDX: AtomicU64 = AtomicU64::new(0);
35
36    /// Record one instruction `(pc, cpu_cycle)` into the ring.
37    #[allow(clippy::cast_possible_truncation)]
38    pub fn record(pc: u16, cpu_cycle: u64) {
39        let slot = (IDX.fetch_add(1, Relaxed) % CAP as u64) as usize;
40        PC[slot].store(u32::from(pc), Relaxed);
41        CYC[slot].store(cpu_cycle, Relaxed);
42    }
43}
44use rustynes_cpu::Bus;
45use rustynes_mappers::{Cartridge, Mapper, MapperError, MapperFrameEvents, RomError};
46use rustynes_ppu::{
47    BgSplitState as PpuBgSplitState, ExAttribute as PpuExAttribute, PaletteInit, Ppu, PpuBus,
48    PpuPalette, PpuRegion, PpuRevision, PpuSnapshotError,
49};
50
51use crate::Cpu2A03Revision;
52use crate::controller::{Buttons, Controller};
53#[cfg(feature = "irq-timing-trace")]
54use crate::irq_trace::{A12Event, BusAccess, CycleRecord, IrqTrace};
55use crate::save_state::{self, SnapshotError};
56use crate::scheduler::M2Phase;
57
58/// CPU RAM (2 KiB).
59const RAM_SIZE: usize = 0x0800;
60
61/// OAM DMA source-page write target (`$4014`). Triggers a 256-byte DMA on
62/// the next CPU read cycle.
63const REG_OAM_DMA: u16 = 0x4014;
64
65/// Default audio sample rate. The frontend may rebuild the bus with a
66/// different rate when CPAL picks something else.
67pub const DEFAULT_SAMPLE_RATE: u32 = 44_100;
68
69/// Map the cartridge-layer [`rustynes_mappers::VsPpuPalette`] to the PPU's
70/// [`PpuPalette`]. `rustynes-core` is the one crate that depends on both `rustynes-ppu`
71/// and `rustynes-mappers`, so the bridge lives here rather than creating a
72/// cross-crate dependency edge.
73const fn vs_palette_to_ppu(p: rustynes_mappers::VsPpuPalette) -> PpuPalette {
74    match p {
75        rustynes_mappers::VsPpuPalette::Composite2C02 => PpuPalette::Composite2C02,
76        rustynes_mappers::VsPpuPalette::Rgb2C03 => PpuPalette::Rgb2C03,
77        rustynes_mappers::VsPpuPalette::Rgb2C04_0001 => PpuPalette::Rgb2C04_0001,
78        rustynes_mappers::VsPpuPalette::Rgb2C04_0002 => PpuPalette::Rgb2C04_0002,
79        rustynes_mappers::VsPpuPalette::Rgb2C04_0003 => PpuPalette::Rgb2C04_0003,
80        rustynes_mappers::VsPpuPalette::Rgb2C04_0004 => PpuPalette::Rgb2C04_0004,
81        rustynes_mappers::VsPpuPalette::Rgb2C05 => PpuPalette::Rgb2C05,
82    }
83}
84
85/// Initial reset state for the bus.
86fn fresh_ram() -> Box<[u8; RAM_SIZE]> {
87    // Deterministic seeded fill — for now zero, matching most emulators'
88    // "post-power-on" approximation.
89    Box::new([0u8; RAM_SIZE])
90}
91
92/// v1.1.0 beta.2 (Workstream C, T-110-C3) — the class of a captured CPU write.
93///
94/// One per event-viewer timeline entry: PPU `$2000-$3FFF`, APU `$4000-$4017`,
95/// or mapper `$4020-$FFFF`, tagged (in [`EventRec`]) with the PPU position at
96/// the moment of the write.
97#[cfg(feature = "debug-hooks")]
98#[derive(Clone, Copy, Debug, Eq, PartialEq)]
99pub enum EventKind {
100    /// A `$2000-$3FFF` PPU-register write.
101    PpuWrite,
102    /// A `$4000-$4017` APU / I/O-register write.
103    ApuWrite,
104    /// A `$4020-$FFFF` mapper-register write.
105    MapperWrite,
106    /// A `$2000-$3FFF` PPU-register read (v1.5.0 Workstream A2 — the graphical
107    /// PPU Event Viewer draws reads as well as writes, so the read/write heatmap
108    /// + the register-access table can show both directions).
109    PpuRead,
110}
111
112#[cfg(feature = "debug-hooks")]
113impl EventKind {
114    /// Whether this event is a CPU read (vs a write). Used by the v1.5.0 PPU
115    /// Event Viewer heatmap to colour reads (blue) vs writes (red).
116    #[must_use]
117    pub const fn is_read(self) -> bool {
118        matches!(self, Self::PpuRead)
119    }
120}
121
122/// One event-viewer record: kind + the PPU `(scanline, dot)` + the address +
123/// (v1.5.0 A2) the byte read or written.
124#[cfg(feature = "debug-hooks")]
125#[derive(Clone, Copy, Debug)]
126pub struct EventRec {
127    /// What happened.
128    pub kind: EventKind,
129    /// PPU scanline at the event (`-1` = pre-render, `0..=239` visible, ...).
130    pub scanline: i16,
131    /// PPU dot (`0..=340`).
132    pub dot: u16,
133    /// The accessed address.
134    pub addr: u16,
135    /// The byte written, or the byte the read returned (v1.5.0 Workstream A2).
136    pub value: u8,
137}
138
139/// Max events captured per frame (bounded so a write-heavy frame can't grow the
140/// log without limit; a frame has at most a few thousand CPU writes).
141#[cfg(feature = "debug-hooks")]
142const EVENT_CAP: usize = 20_000;
143
144/// v1.1.0 beta.3 (Workstream E, T-110-E2) — one CPU bus-access record for the
145/// Lua `onRead` / `onWrite` callbacks: direction + full address + the byte.
146///
147/// Distinct from [`EventRec`] (which is the scanline/dot-oriented event-viewer
148/// record): this captures *every* CPU read and write across the whole address
149/// space, with the value, so a script can react to a specific access. Output-
150/// only and gated behind `access_logging`; the host (Lua engine) enables it
151/// only while `onRead`/`onWrite` callbacks are registered.
152#[cfg(feature = "debug-hooks")]
153#[derive(Clone, Copy, Debug)]
154pub struct AccessRec {
155    /// `true` for a CPU write, `false` for a CPU read.
156    pub write: bool,
157    /// The accessed CPU address (`$0000-$FFFF`).
158    pub addr: u16,
159    /// The byte written, or the byte the read returned.
160    pub value: u8,
161}
162
163/// Max bus accesses captured per frame. A frame issues on the order of 30k CPU
164/// cycles; this caps the worst case so a tight loop can't grow the log
165/// unbounded. A frame that overflows the cap is truncated (the tail is dropped).
166#[cfg(feature = "debug-hooks")]
167const ACCESS_CAP: usize = 60_000;
168
169/// v1.2.0 (Workstream E, T-110-E1) — one interrupt-service record for the Lua
170/// `onNmi` / `onIrq` callbacks: the service direction + the vector the CPU
171/// fetched its new PC from.
172///
173/// Captured at the commit point — [`Bus::notify_irq_service`], called once per
174/// real interrupt entry right before the CPU reads the service vector. This is
175/// the *committed* service (the same point the IRQ trace records), NOT the
176/// speculative `poll_nmi` / `poll_irq` sampler that ADR 0010 flagged as
177/// unreliable — so a script that watches `onNmi`/`onIrq` sees exactly the
178/// interrupts the CPU actually serviced this frame, in order. Output-only and
179/// gated behind `interrupt_logging`; the host (Lua engine) enables it only
180/// while `onNmi`/`onIrq` callbacks are registered.
181#[cfg(feature = "debug-hooks")]
182#[derive(Clone, Copy, Debug, Eq, PartialEq)]
183pub struct InterruptRec {
184    /// `true` for an NMI service entry (`$FFFA`), `false` for an IRQ/BRK
185    /// service entry (`$FFFE`).
186    pub is_nmi: bool,
187    /// The service vector the CPU fetched its new PC from (`$FFFA` for an NMI,
188    /// `$FFFE` for IRQ/BRK).
189    pub vector: u16,
190}
191
192/// Max interrupt-service records captured per frame. A frame services at most a
193/// few hundred interrupts (NMI once + mapper/APU IRQs); this caps a pathological
194/// case so the log can't grow unbounded. A frame that overflows is truncated.
195#[cfg(feature = "debug-hooks")]
196const INTERRUPT_CAP: usize = 4_096;
197
198/// v1.4.0 Workstream D (D2) — the class of hardware event an event-driven
199/// breakpoint can trigger on.
200///
201/// These are tapped at the SAME observational commit points the event-viewer /
202/// interrupt-service / bus-access logs already use (`Bus::cpu_read`,
203/// `Bus::cpu_write`, `Bus::notify_irq_service`, the DMC-DMA GET, the `$4014`
204/// write). A hit only RECORDS the event (kind + PPU position); it never mutates
205/// emulator-visible state, so the determinism contract holds and the
206/// feature-off build is byte-identical.
207///
208/// The 16 categories are packed into a `u16` arm mask (see
209/// [`LockstepBus::set_event_breakpoints`]); the bit index is the discriminant.
210#[cfg(feature = "debug-hooks")]
211#[derive(Clone, Copy, Debug, Eq, PartialEq)]
212#[repr(u8)]
213pub enum EventBpKind {
214    /// An NMI service entry (`$FFFA`), observed at the interrupt-service commit.
215    Nmi = 0,
216    /// An IRQ / BRK service entry (`$FFFE`), observed at the same commit.
217    Irq = 1,
218    /// A sprite-0 hit, observed when the CPU reads `$2002` with bit 6 set (the
219    /// point games actually detect the hit; purely observational).
220    Sprite0Hit = 2,
221    /// An OAM DMA, observed at the `$4014` write that starts it.
222    OamDma = 3,
223    /// A DMC DMA sample fetch (the GET cycle).
224    DmcDma = 4,
225    /// A PPU-register read (`$2000-$3FFF`).
226    PpuRead = 5,
227    /// A PPU-register write (`$2000-$3FFF`).
228    PpuWrite = 6,
229    /// An APU / I/O-register read (`$4000-$4017`).
230    ApuRead = 7,
231    /// An APU / I/O-register write (`$4000-$4017`).
232    ApuWrite = 8,
233    /// A mapper-register read (`$4020-$FFFF`).
234    MapperRead = 9,
235    /// A mapper-register write (`$4020-$FFFF`).
236    MapperWrite = 10,
237}
238
239#[cfg(feature = "debug-hooks")]
240impl EventBpKind {
241    /// The arm-mask bit for this kind.
242    #[must_use]
243    pub const fn bit(self) -> u16 {
244        1u16 << (self as u8)
245    }
246
247    /// A human-readable label (used by the debugger UI + tests).
248    #[must_use]
249    pub const fn label(self) -> &'static str {
250        match self {
251            Self::Nmi => "NMI entry",
252            Self::Irq => "IRQ entry",
253            Self::Sprite0Hit => "Sprite-0 hit",
254            Self::OamDma => "OAM DMA",
255            Self::DmcDma => "DMC DMA",
256            Self::PpuRead => "PPU read",
257            Self::PpuWrite => "PPU write",
258            Self::ApuRead => "APU read",
259            Self::ApuWrite => "APU write",
260            Self::MapperRead => "Mapper read",
261            Self::MapperWrite => "Mapper write",
262        }
263    }
264
265    /// All categories, in discriminant order (for the UI checkbox list).
266    #[must_use]
267    pub const fn all() -> [Self; 11] {
268        [
269            Self::Nmi,
270            Self::Irq,
271            Self::Sprite0Hit,
272            Self::OamDma,
273            Self::DmcDma,
274            Self::PpuRead,
275            Self::PpuWrite,
276            Self::ApuRead,
277            Self::ApuWrite,
278            Self::MapperRead,
279            Self::MapperWrite,
280        ]
281    }
282}
283
284/// v1.4.0 Workstream D (D2) — one event-driven breakpoint hit.
285///
286/// Carries the kind, the associated address (`0` for the interrupt entries that
287/// carry none), and the full timing context (frame / CPU cycle / PPU
288/// scanline+dot) at the moment of the event. Recorded by the first armed-event
289/// tap of a frame; the frontend takes it via
290/// [`crate::Nes::take_event_break_hit`] to pause + report.
291#[cfg(feature = "debug-hooks")]
292#[derive(Clone, Copy, Debug, Eq, PartialEq)]
293pub struct EventBreakHit {
294    /// Which event fired.
295    pub kind: EventBpKind,
296    /// The associated CPU address (the read/write address, the OAM-DMA `$4014`,
297    /// the DMC sample address, or the service vector for NMI/IRQ).
298    pub addr: u16,
299    /// PPU frame counter at the event.
300    pub frame: u64,
301    /// Cumulative CPU cycle at the event.
302    pub cycle: u64,
303    /// PPU scanline (`-1` pre-render .. `260`).
304    pub scanline: i16,
305    /// PPU dot (`0..=340`).
306    pub dot: u16,
307}
308
309/// Lockstep bus.
310///
311/// Owns the entire emulator's mutable state. The CPU borrows `&mut LockstepBus`
312/// during `Cpu::step`. The PPU and APU are ticked from the bus's
313/// `cpu_read`/`cpu_write` implementations (3 dots per CPU cycle, NTSC; APU
314/// every CPU cycle).
315// The per-phase IRQ snapshots (Phase B2 of the C1 IRQ-timing rework) add
316// 4 bools beyond the original 3 (last_nmi_level / nmi_edge_latch /
317// in_dmc_dma), plus another `trace_last_a12` when the trace feature is
318// on. They're independent state words, not a single enum-modelled
319// machine — silencing the lint is the right call.
320#[allow(clippy::struct_excessive_bools)]
321pub struct LockstepBus {
322    /// CPU RAM (2 KiB), mirrored every 0x800 bytes from `$0000-$1FFF`.
323    pub(crate) ram: Box<[u8; RAM_SIZE]>,
324    /// PPU instance.
325    pub(crate) ppu: Ppu,
326    /// APU instance.
327    pub(crate) apu: Apu,
328    /// Cartridge metadata (kept for save-state and debugger).
329    pub(crate) cart: Cartridge,
330    /// Boxed mapper.
331    pub(crate) mapper: Box<dyn Mapper>,
332    /// v2.8.0 Phase 4 — the mapper's capability flags, cached at
333    /// construction (and refreshed when [`Self::power_cycle`] rebuilds the
334    /// mapper) so the per-CPU-cycle hot loop can skip the up-to-four
335    /// virtual dispatches (`notify_cpu_cycle` / `mix_audio` /
336    /// `notify_frame_event` / `irq_pending`) on boards that don't use
337    /// them. Constant per mapper type; NOT part of the save-state.
338    mapper_caps: rustynes_mappers::MapperCaps,
339    /// The original iNES/NES-2.0 ROM bytes, kept so [`Self::power_cycle`] can
340    /// rebuild the mapper to a true power-on state (fresh bank registers,
341    /// cleared CHR-RAM + volatile PRG-RAM). `None` on the FDS path (which has
342    /// no iNES image; FDS netplay is unsupported). NOT part of the save-state
343    /// (constant; the encoder skips it).
344    rom_bytes: Option<Box<[u8]>>,
345    /// Standard NES controllers (player 1 on `$4016`, player 2 on `$4017`).
346    pub(crate) controllers: [Controller; 2],
347    /// Four Score 4-player adapter. When `true`, `$4016`/`$4017` multiplex
348    /// four controllers + an adapter signature over a 24-read serial sequence
349    /// (nesdev "Four score"; matches `Mesen2` / `TetaNES`). When `false` (default)
350    /// the read path is byte-identical to the standard two-controller
351    /// behavior, so the determinism contract and existing save-states are
352    /// unaffected.
353    four_score: bool,
354    /// v2.1.7 P5 — power-on 2 KiB work-RAM fill selection. [`crate::nes::PowerOnRam::Zeroed`]
355    /// (default) leaves the established all-zero power-up state; the other
356    /// variants are opt-in and deterministic. Stored so [`Self::power_cycle`] can
357    /// re-apply the same fill after it zeroes RAM, keeping `power_cycle == fresh
358    /// boot`. At the default this is inert (the zero fill matches `fresh_ram()`).
359    power_on_ram: crate::nes::PowerOnRam,
360    /// v2.1.7 P5 — selected 2C02 die revision (see [`PpuRevision`]). Stored so
361    /// [`Self::power_cycle`] can re-apply it after the PPU is reconstructed
362    /// (the PPU field is lost on rebuild, like the Vs. palette).
363    /// [`PpuRevision::default`] models no extra behavior → byte-identical.
364    ppu_die_revision: PpuRevision,
365    /// v2.1.7 P5 — selected power-up palette pattern (see [`PaletteInit`]).
366    /// Re-applied on [`Self::power_cycle`] after the PPU (and thus its palette
367    /// RAM) is rebuilt. [`PaletteInit::default`] is all-zero → byte-identical.
368    power_up_palette: PaletteInit,
369    /// Players 3 (`$4016`) and 4 (`$4017`) — only polled when
370    /// [`Self::four_score`] is set.
371    controllers34: [Controller; 2],
372    /// Per-port Four Score read counter (0-7 = primary pad, 8-15 = secondary
373    /// pad, 16-23 = signature, then 1s). Reset on each strobe.
374    four_score_idx: [u8; 2],
375    /// Per-port Four Score signature shift register, reloaded on each strobe
376    /// (port 0 = `0x08`, port 1 = `0x04`; shifted out LSB-first).
377    four_score_sig: [u8; 2],
378    /// Output-only `TAStudio` lag-log flag (v1.6.0 Workstream A3): set `true`
379    /// whenever the running program reads a controller port (`$4016`/`$4017`)
380    /// during the current frame; cleared at the top of each
381    /// [`crate::Nes::run_frame`]. A frame still `false` at frame end is a "lag
382    /// frame" (the game polled no input that frame). `debug-hooks`-gated and
383    /// never read back into emulation, so the shipped build stays byte-identical
384    /// and the determinism contract is unaffected.
385    #[cfg(feature = "debug-hooks")]
386    controller_polled: bool,
387    /// Vs. System DIP switches (8 bits, switch 1 = bit 0 .. switch 8 = bit 7).
388    /// Read through the upper bits of `$4016`/`$4017` per the Vs. protocol
389    /// (nesdev "Vs. System"). Only consulted when the cart is
390    /// [`rustynes_mappers::ConsoleType::VsSystem`]; on a standard NES cart the
391    /// `$4016`/`$4017` read path is byte-identical regardless of this value.
392    vs_dip: u8,
393    /// Vs. System coin-acceptor state: bit 0 = acceptor #1 ($4016 bit 5),
394    /// bit 1 = acceptor #2 ($4016 bit 6). A real coin pulse reads true for
395    /// ~40-70 ms; the frontend latches it for a configurable number of frames
396    /// via [`LockstepBus::insert_coin`] and clears it with
397    /// [`LockstepBus::clear_coin`]. Vs.-System carts only.
398    vs_coin: u8,
399    /// Vs. System service button ($4016 bit 2). Vs.-System carts only.
400    vs_service: bool,
401    /// v2.0.0 beta.5 (Vs. `DualSystem`): `true` when this console is the SUB
402    /// half of a `DualSystem` pair — `$4016` reads then return bit 7 = `0x80`
403    /// (the main/sub identity bit the ROM polls; hard-pinned `0` on a single
404    /// console, byte-identically). Set only by the `VsDualSystem` wrapper.
405    vs_is_sub: bool,
406    /// v2.0.0 beta.5 (Vs. `DualSystem`): the external `/IRQ` line driven by the
407    /// PARTNER console's `$4016` bit-1 signal (Mesen2 `IRQSource::External`
408    /// via `UpdateMainSubBit`). OR'd into [`Bus::irq_level`]; always `false`
409    /// on a single console, so the default IRQ path is byte-identical.
410    vs_external_irq: bool,
411    /// v2.0.0 beta.5 (Vs. `DualSystem`): the last `$4016`-write bit-1 value
412    /// (the main/sub comms signal) + a dirty latch the wrapper polls after
413    /// each step batch. The bus only RECORDS the LEVEL (deliberately not
414    /// edge-filtered — see [`Self::vs_4016_bit1_dirty`]); the cross-console
415    /// wiring (asserting the partner's `/IRQ`, the shared-WRAM swap) lives in
416    /// the wrapper — no bus ever references the other console.
417    vs_4016_bit1: bool,
418    /// See [`Self::vs_4016_bit1`] — set on EVERY `$4016` write, regardless
419    /// of whether bit 1 changed; cleared by [`Self::take_vs_mainsub_edge`].
420    /// Deliberately level-driven, not edge-filtered: at reset both consoles
421    /// write `$4016 = $00` to establish the wrapper's seeded main/sub
422    /// levels, and an edge filter starting from a `false` latch would
423    /// swallow that seeded-HIGH -> written-LOW transition and deadlock the
424    /// boot handshake (see the `cpu_write` `$4016` arm for the full
425    /// rationale). Re-applying an unchanged level is idempotent in the
426    /// wrapper, so marking every write dirty (not just changed ones) is
427    /// correct, if conservatively named.
428    vs_4016_bit1_dirty: bool,
429    /// Optional non-standard input-device overlay per port (`$4016`/`$4017`).
430    /// When a port has `Some(device)`, [`Self::read_port`] returns that
431    /// device's byte instead of the standard controller / Four Score serial
432    /// byte. `None` (the default) leaves the existing path byte-identical, so
433    /// the default + Four Score reads and the determinism contract are
434    /// unaffected unless a device is explicitly attached.
435    expansion_device: [Option<crate::input_device::InputDevice>; 2],
436    /// A3 (v2.2.3), default **off**: serve a Zapper's light bit from the
437    /// beam-relative temporal model instead of the frame-granular one. See
438    /// [`Bus::set_zapper_temporal_light`].
439    zapper_temporal_light: bool,
440    /// Famicom built-in **microphone** signal (v2.2.0 "Capstone"). The hardwired
441    /// second Famicom controller carries a push-to-talk microphone whose state is
442    /// read on **`$4016` bit 2** (not `$4017`) — games such as *The Legend of
443    /// Zelda* (killing Pols Voice), *Kid Icarus*, *Raid on Bungeling Bay*, and
444    /// *Takeshi no Chōsenjō* poll it. Modelled as a single live bit (the analog
445    /// mic is quantized to "loud enough / not" by the frontend, matching how the
446    /// Famicom's comparator fed the port): `true` ORs `1` into `$4016.D2`.
447    /// Default `false` leaves the `$4016` read byte-identical (bit 2 is otherwise
448    /// open-bus / 0), so the standard controller path is unaffected until a
449    /// frontend explicitly drives the mic via [`Self::set_microphone`].
450    famicom_mic: bool,
451    /// v1.1.0 beta.1 (T-110-B4) — optional per-game nametable mirroring
452    /// override. `None` (default) defers to the mapper's `nametable_address`
453    /// (byte-identical). When `Some`, the standard `$2000-$3EFF` nametable
454    /// translation uses this mirroring instead — a load-time correction for
455    /// ROMs with a wrong iNES mirroring flag, supplied by the frontend's game
456    /// database. Does NOT affect mapper-supplied VRAM (`nametable_fetch`, e.g.
457    /// 4-screen). Persisted in the save-state so rollback / restore stay
458    /// consistent. The core test suites never set it, so `AccuracyCoin` / the
459    /// oracle are unaffected.
460    nt_mirroring_override: Option<rustynes_mappers::Mirroring>,
461    /// v1.1.0 beta.2 (T-110-C3) — event-viewer log (this frame's CPU-write
462    /// events). Output-only; populated only while `event_logging`, cleared per
463    /// frame. Gated on `debug-hooks` so the default hot path is untouched.
464    #[cfg(feature = "debug-hooks")]
465    events: alloc::vec::Vec<EventRec>,
466    /// Whether the event viewer is recording. Default `false`.
467    #[cfg(feature = "debug-hooks")]
468    event_logging: bool,
469    /// v1.1.0 beta.3 (T-110-E2) — full CPU bus-access log (reads + writes +
470    /// values) for the Lua `onRead`/`onWrite` callbacks. Output-only; populated
471    /// only while `access_logging`, cleared per frame.
472    #[cfg(feature = "debug-hooks")]
473    accesses: alloc::vec::Vec<AccessRec>,
474    /// Whether the bus-access log is recording. Default `false`.
475    #[cfg(feature = "debug-hooks")]
476    access_logging: bool,
477    /// v1.2.0 (T-110-E1) — per-frame interrupt-service log (this frame's
478    /// committed NMI / IRQ / BRK service entries) for the Lua `onNmi`/`onIrq`
479    /// callbacks. Output-only; populated only while `interrupt_logging`, cleared
480    /// per frame.
481    #[cfg(feature = "debug-hooks")]
482    interrupts: alloc::vec::Vec<InterruptRec>,
483    /// Whether the interrupt-service log is recording. Default `false`.
484    #[cfg(feature = "debug-hooks")]
485    interrupt_logging: bool,
486    /// v1.4.0 Workstream D (D2) — armed event-breakpoint categories, packed as a
487    /// bitmask of [`EventBpKind::bit`]. `0` (default) disarms every category, so
488    /// the per-access tap is a single `mask == 0` early-out — the default + the
489    /// feature-off build are byte-identical and pay no per-cycle cost. Output-
490    /// only: a hit records [`Self::event_break_hit`] but never mutates state.
491    #[cfg(feature = "debug-hooks")]
492    event_bp_mask: u16,
493    /// The first event-breakpoint hit of the current frame (`None` until one
494    /// fires). Recorded by the taps, taken by the frontend after `run_frame`.
495    #[cfg(feature = "debug-hooks")]
496    event_break_hit: Option<EventBreakHit>,
497    /// Cumulative CPU cycle counter.
498    pub(crate) cycle: u64,
499
500    /// OAM DMA pending source page (set by `$4014` write; consumed on the
501    /// next `cpu_read`/`cpu_write`).
502    dma_pending: Option<u8>,
503    /// Cycles owed to the OAM DMA before the original access can complete.
504    dma_cycles_owed: u32,
505    /// OAM DMA scratch byte: read on even cycles, written on odd cycles.
506    dma_byte: u8,
507    /// OAM DMA progress index (0..256).
508    dma_idx: u16,
509    /// OAM DMA active source page (latched from `dma_pending`).
510    dma_page: u8,
511    /// CPU read address that OAM DMA halted. While the CPU is halted,
512    /// no-op DMA cycles keep this address on the 6502 core bus.
513    dma_halt_addr: u16,
514    /// Stage-D (`mc-r1-full-cpu`): the OAM DMA's original total cycle count
515    /// (513 or 514) latched at set-up, so the CPU-driven per-cycle
516    /// `oam_dma_step` can recompute `consumed = dma_total - dma_cycles_owed`
517    /// and the alignment across calls. 0 when no OAM DMA is in flight.
518    dma_total: u32,
519
520    /// Edge-detector latch for the PPU NMI line, used by `poll_nmi`.
521    last_nmi_level: bool,
522    /// Latched NMI edge (consumed by `poll_nmi`).
523    nmi_edge_latch: bool,
524    /// v2.5.1 (ADR 0038) — externally asserted /NMI, for co-simulation only.
525    ///
526    /// Active-high here (`true` = the pin is asserted, i.e. /NMI low). It is
527    /// OR'd into the poll rather than replacing it, so an injected NMI and a
528    /// PPU-generated one are the same event to the CPU -- which is the point:
529    /// the API sets the pin the CPU samples and does nothing else. It does not
530    /// bypass the poll, force a vector, or short-circuit the sequence.
531    ///
532    /// The field does not exist in a default build.
533    #[cfg(feature = "cosim-interrupt-inject")]
534    inject_nmi: bool,
535    /// v2.5.1 (ADR 0038) — externally asserted /IRQ. Level-sensitive, exactly
536    /// as the pin is, so it is masked by `I` through the CPU's own logic and a
537    /// pulse shorter than a poll is missed. Modelling it as a latch would make
538    /// injected IRQs behave unlike real ones.
539    #[cfg(feature = "cosim-interrupt-inject")]
540    inject_irq: bool,
541
542    /// v2.0 master-clock R1 substrate (Phase 1): PPU progress in master-clock
543    /// units, consumed by `run_ppu_to(target)` (ticks a dot while
544    /// `ppu_clock + ppu_divider <= target`). Only used under the R1 CPU loop.
545    ppu_clock: u64,
546    /// v2.0 master-clock R1 substrate: the cartridge region's `(cpu_divider,
547    /// ppu_divider)` in master clocks (NTSC 12/4, PAL 16/5, Dendy 15/5),
548    /// computed once at construction. The region never changes after power-on,
549    /// so caching these removes the per-CPU-cycle `match self.cart.region` from
550    /// the hottest R1 paths (`cpu_divider`, `run_ppu_to`). Behaviour-identical:
551    /// the value equals what the prior `region_dividers()` match returned.
552    cpu_div_cached: u8,
553    ppu_div_cached: u8,
554    /// v2.0 master-clock R1 substrate (Phase 1): master clocks consumed by
555    /// bus-side DMA cycles since the CPU last drained the accumulator (folded
556    /// into `Cpu::master_clock` in `end_cycle` to keep the CPU<->PPU phase
557    /// coherent across a DMA span). Drained by `take_dma_mc_consumed`.
558    dma_mc_consumed: u64,
559
560    /// External CPU data bus latch: last value driven onto the bus
561    /// by ANY device (CPU, DMC DMA, OAM DMA conflict reads).
562    ///
563    /// This is the classic "open bus" floating-latch value that NES
564    /// emulation refers to.  Reads from unmapped or open-bus regions
565    /// return this value; the upper 3 bits of the controller-strobe
566    /// register reads (`$4016` / `$4017`) bleed through from this
567    /// latch.  DMC DMA fetches update this latch (because the DMC
568    /// drives the external bus during halt).
569    open_bus: u8,
570    /// Internal CPU data bus latch: last value driven onto the bus
571    /// by a CPU-initiated read or write.
572    ///
573    /// The 2A03 silicon has two distinct data buses.  The
574    /// **internal** bus is driven only by CPU operations (instruction
575    /// fetch, operand read, ALU result, write).  DMC DMA fetches
576    /// drive only the **external** bus (`open_bus` above) — the
577    /// internal bus retains its prior value across a DMC halt.  This
578    /// distinction is invisible while the CPU runs unimpeded (the
579    /// two buses carry the same value), but it surfaces on the SH*
580    /// unstable-store family when DMC DMA interleaves with the
581    /// store's address-high-byte AND computation, and on the `$4015`
582    /// bit-5 open-bus read after a DMC DMA fetch.
583    ///
584    /// Phase 1 of the v1.0.0-final `linked-puzzling-sutherland`
585    /// brief (`to-dos/phase-6-v1.0.0-final/sprint-6-sh-unstable-stores.md`).
586    /// Mirrored from every `cpu_read` / `cpu_write` path; explicitly
587    /// NOT updated by `dmc_dma_read` (the DMC fetch path).
588    internal_data_bus: u8,
589
590    /// Most recent CPU bus access — used by the 2A03 DMC-DMA readout-bug
591    /// emulation. (Address only; some bug variants need the address, the
592    /// bus value is the open-bus latch above.)
593    last_read_addr: u16,
594    /// Side-effect register read whose absolute high-byte operand was
595    /// halted by DMC DMA one CPU read before the actual register access.
596    deferred_dma_replay_addr: u16,
597    /// True while we're servicing a DMC DMA fetch — used to suppress
598    /// recursion / re-entrancy when the DMA controller invokes `raw_cpu_read`.
599    in_dmc_dma: bool,
600    /// v2.0 interleaved-DMA Phase B (`mc-r1-substrate`): the `TriCNES`
601    /// `DMCDMA_Halt` flag — set when the interleaved DMC DMA starts, cleared
602    /// after a GET cycle. Gates whether the current get cycle is the halt
603    /// re-read or the actual sample fetch. Only used by `dmc_dma_step`.
604    dmc_halt: bool,
605    /// Program M (M-2, `mc-r1-dmc-oam-overlap`): whether the most recent
606    /// `dmc_dma_step` performed the GET (vs a halt/dummy/align). Read by the
607    /// read1 overlap loop to decide whether the DMC cycle can share an OAM cycle.
608    dmc_step_was_get: bool,
609    /// W3-Stage-1 (`mc-r1-dma-unified`): the unified engine's OAM-DMA-active
610    /// flag (`TriCNES` `DoOAMDMA` once latched). The 513/514 length is EMERGENT
611    /// from `uni_oam_halt`/`uni_oam_aligned` + the per-cycle dispatch — no
612    /// owed-cycle counter.
613    uni_oam_active: bool,
614    /// W3-Stage-1: `TriCNES` `OAMDMA_Halt` — set when the OAM DMA's FIRST
615    /// serviced cycle lands on the OAM engine's read half (at floor parity:
616    /// `put_cycle == true`, the floor's `self.cycle & 1 == 0` -> 514 case);
617    /// cleared at the end of every OAM-read-half cycle.
618    uni_oam_halt: bool,
619    /// W3-Stage-1: `TriCNES` `OAMDMA_Aligned` — set by the OAM read, consumed
620    /// by the OAM write; force-cleared by a DMC GET (the emergent post-GET
621    /// realign: the next write half becomes an alignment dummy and the byte
622    /// is re-read).
623    uni_oam_aligned: bool,
624    /// W3-Stage-1: `TriCNES` `DMAAddress` — the OAM byte index (0..=255;
625    /// reaching 256 on a write completes the DMA). Only increments on writes,
626    /// so a DMC-GET-stalled byte is re-read.
627    uni_oam_addr: u16,
628
629    /// v2.1.7 "Hardware Revisions & DMA Frontier" — the emulated Ricoh 2A03 die
630    /// revision, gating the DMA unit's "unexpected DMA" extra halt-read on the
631    /// DMC-halt-overlaps-OAM-halt cycle. **Default [`Cpu2A03Revision::Rp2A03G`]**
632    /// = byte-identical to the pre-v2.1.7 core; it performs the extra read *in
633    /// the model*, but that read is a documented no-op on every committed oracle
634    /// (the parked address during a DMC+OAM overlap is never a side-effect
635    /// register — see the enum docs + ADR 0033), so it changes nothing
636    /// observable. [`Cpu2A03Revision::Rp2A03H`] omits the modeled read and is
637    /// consequently byte-identical to `Rp2A03G` across the entire committed DMA
638    /// corpus today (opt-in, deterministic, unverified direction). A config
639    /// knob, NOT part of the save-state: the only state it influences (the
640    /// parked-address side-effect re-read count during a DMC+OAM overlap) is
641    /// fully re-derived from the deterministic timeline, so a save/restore
642    /// round-trip stays byte-identical for a fixed revision.
643    cpu_2a03_revision: Cpu2A03Revision,
644
645    /// Active Game Genie codes, keyed by the PRG address they patch
646    /// (`$8000-$FFFF`). Applied on the CPU read path; empty by default, so
647    /// with no codes active reads are byte-identical to a build without the
648    /// feature (the determinism contract is preserved). NOT part of the
649    /// save-state — codes are a user overlay persisted by the frontend, not
650    /// emulation state. See [`crate::genie`].
651    genie_codes: BTreeMap<u16, GenieCode>,
652
653    /// Which half of the current CPU cycle the lockstep scheduler is in.
654    /// See [`M2Phase`] for the convention; see
655    /// [`LockstepBus::current_m2_phase`] for the read accessor.
656    ///
657    /// Maintained by `tick_one_cpu_cycle`: enters each cycle at
658    /// [`M2Phase::Low`], transitions to [`M2Phase::High`] after sub-dot
659    /// 1 of the 3-PPU-dot tick loop (the M2-rising boundary), then
660    /// resets to [`M2Phase::Low`] at end-of-cycle.  As of Phase B2 of
661    /// the C1 IRQ-timing rework this is still informational; the
662    /// production [`Bus::poll_irq_at_phase`] path reads from the
663    /// `irq_snapshot_*` fields below.
664    m2_phase: M2Phase,
665
666    /// Deferred controller strobe write (Session-24 / Phase 3 of the
667    /// v1.0.0-final brief).  Mirrors Mesen2's `NesControlManager`
668    /// `_writeAddr` / `_writeValue` / `_writePending` triplet (see
669    /// `Core/NES/NesControlManager.cpp` lines 252-273): a CPU write to
670    /// `$4016` (or `$4017`) does NOT directly update the controllers'
671    /// strobe state.  Instead the write is buffered here.
672    /// `controller_write_pending` is set to 1 (odd-cycle write) or 2
673    /// (even-cycle write) at the moment of the CPU write, then
674    /// decremented every CPU cycle at the START of `tick_one_cpu_cycle`
675    /// (BEFORE the 3-dot PPU loop runs); when it reaches 0 the buffered
676    /// value is committed to `Controller::write_strobe`.  Multiple
677    /// writes within the commit window collapse — the latest value
678    /// wins (the buffer is single-slot, the previous value is
679    /// silently overwritten).
680    ///
681    /// This is the load-bearing structural change for `AccuracyCoin`
682    /// `Controller Strobing` Test 4 (a 1-cycle DEC `$4016` strobe pulse
683    /// whose 0→1→0 sequence must NOT fire the latch when it happens
684    /// to span an L→H half-cycle pair — under deferred commit both
685    /// writes target the SAME commit cycle, the second overwrites the
686    /// first, no edge is observed).  See
687    /// `docs/audit/session-24-phase3-controller-strobing-2026-05-23.md`.
688    controller_write_pending: u8,
689    /// Buffered controller-write value (latched at the moment of the
690    /// CPU write; committed when `controller_write_pending` reaches 0).
691    controller_write_value: u8,
692
693    /// Mapper-side IRQ line snapshotted at the conventional M2-low
694    /// boundary of the current CPU cycle (between PPU sub-dot 0 and
695    /// sub-dot 1, per the [`M2Phase`] convention).  Updated by
696    /// [`LockstepBus::tick_one_cpu_cycle`] every cycle; read by
697    /// [`Bus::poll_irq_at_phase`] when `phase == M2Phase::Low`.
698    ///
699    /// Phase B2 of the C1 IRQ-timing rework: the storage is
700    /// unconditional (not gated on the `irq-timing-trace` feature) so
701    /// it's available on every build of the bus.  Per Phase A's
702    /// empirical finding the M2-low and M2-high values are byte-
703    /// identical for every baseline trace ROM, but the storage is kept
704    /// separate so Phase B4's MMC3 sub_dot-aware A12 filter can change
705    /// the two halves' values independently.
706    irq_snapshot_mapper_at_low: bool,
707    /// APU-side IRQ line snapshotted at the conventional M2-low
708    /// boundary.  See [`Self::irq_snapshot_mapper_at_low`].
709    irq_snapshot_apu_at_low: bool,
710    /// Mapper-side IRQ line snapshotted at the conventional M2-high
711    /// boundary of the current CPU cycle.  The exact intra-cycle
712    /// position is "between the end of PPU sub-dot 2 and the call to
713    /// `mapper.notify_cpu_cycle`" — i.e. the historical query point the
714    /// pre-Phase-B2 `Bus::poll_irq` impl used when called from
715    /// `Cpu::idle_tick` after `bus.on_cpu_cycle()` returned.
716    ///
717    /// Read by [`Bus::poll_irq`] and by
718    /// [`Bus::poll_irq_at_phase`] when `phase == M2Phase::High`.
719    irq_snapshot_mapper_at_high: bool,
720    /// APU-side IRQ line snapshotted at the conventional M2-high
721    /// boundary.  See [`Self::irq_snapshot_mapper_at_high`].
722    irq_snapshot_apu_at_high: bool,
723
724    /// Optional IRQ-timing trace buffer (Track C1 pre-work, gated on the
725    /// `irq-timing-trace` cargo feature). See `crates/rustynes-core/src/irq_trace.rs`
726    /// and ADR-0002 "Decision (revised, 2026-05-13)".
727    #[cfg(feature = "irq-timing-trace")]
728    pub(crate) irq_trace: Option<IrqTrace>,
729    /// Scratch latch the `PpuBusAdapter` writes when the mapper sees an
730    /// `notify_a12` call.  Polled between every PPU sub-dot tick inside
731    /// `tick_one_cpu_cycle` and drained into the current cycle record's
732    /// `a12_events`.  Only populated when the trace feature is on.
733    #[cfg(feature = "irq-timing-trace")]
734    pub(crate) trace_a12_latest: Option<bool>,
735    /// Last A12 level seen across cycle boundaries; used to filter out
736    /// "no transition" sub-dots so the trace records only the actual
737    /// rising / falling edges.
738    #[cfg(feature = "irq-timing-trace")]
739    pub(crate) trace_last_a12: bool,
740    /// Scratch buffer for A12 events accumulated during a single CPU
741    /// cycle's 3 PPU dots.  Drained into the trace record at end-of-cycle.
742    #[cfg(feature = "irq-timing-trace")]
743    pub(crate) trace_a12_scratch: alloc::vec::Vec<A12Event>,
744    /// Session-21 (Sprint 1 iteration 2 prereq) bus-access tracker.
745    ///
746    /// Set by `cpu_read` / `cpu_write` / the DMC DMA service path / the
747    /// OAM DMA service path BEFORE `tick_one_cpu_cycle` records the
748    /// per-cycle bus-access columns; consumed (and reset to
749    /// `BusAccess::Idle` / 0) inside `tick_one_cpu_cycle` after the
750    /// record is pushed.  A single CPU cycle has at most one external
751    /// bus access — burn cycles (`idle_tick`) leave the tracker at
752    /// `BusAccess::Idle`, which is the correct semantics for the trace
753    /// (CPU internal cycles do not drive the bus).
754    ///
755    /// The DMA paths set this directly because the bus owns the cycle
756    /// during DMA halt and the CPU's `cpu_read` / `cpu_write` is not
757    /// invoked (the bus's `raw_cpu_read` is invoked instead, which
758    /// does not advance time on its own — `tick_one_cpu_cycle` is
759    /// called separately).
760    #[cfg(feature = "irq-timing-trace")]
761    pub(crate) trace_bus_access: BusAccess,
762    #[cfg(feature = "irq-timing-trace")]
763    pub(crate) trace_bus_addr: u16,
764    #[cfg(feature = "irq-timing-trace")]
765    pub(crate) trace_bus_data: u8,
766    /// PC of the instruction currently executing, latched by the
767    /// `trace_instr` hook (`cpu-instr-cycle-trace`). Copied into each
768    /// `CycleRecord.pc` so the per-cycle trace can be diffed against
769    /// `TriCNES` by ROM PC. Stays at the halted instruction's PC across
770    /// DMA-insertion cycles. `0` unless `cpu-instr-cycle-trace` is on.
771    #[cfg(feature = "irq-timing-trace")]
772    pub(crate) trace_last_pc: u16,
773    /// R1-path PPU position captured at cycle-start (`cpu_clock`) for the
774    /// `trace_end_cycle` diagnostic push (the R1 loop bypasses
775    /// `tick_one_cpu_cycle`'s own snapshot).
776    #[cfg(feature = "irq-timing-trace")]
777    pub(crate) trace_r1_scanline_start: i16,
778    #[cfg(feature = "irq-timing-trace")]
779    pub(crate) trace_r1_dot_start: u16,
780    #[cfg(feature = "irq-timing-trace")]
781    pub(crate) trace_r1_frame_start: u64,
782}
783
784impl LockstepBus {
785    /// Construct from a parsed ROM with a default 44.1 kHz audio sample rate.
786    ///
787    /// # Errors
788    ///
789    /// Returns the underlying [`RomError`] if the bytes don't parse.
790    pub fn new(rom_bytes: &[u8]) -> Result<Self, RomError> {
791        Self::with_sample_rate(rom_bytes, DEFAULT_SAMPLE_RATE)
792    }
793
794    /// Construct with an explicit audio sample rate.
795    ///
796    /// # Errors
797    ///
798    /// Returns the underlying [`RomError`] if the bytes don't parse.
799    // The struct-literal init grows with every feature-gated field; the W3
800    // unified-engine fields pushed it past the line gate.
801    #[allow(clippy::too_many_lines)]
802    pub fn with_sample_rate(rom_bytes: &[u8], sample_rate: u32) -> Result<Self, RomError> {
803        let (cart, mapper) = rustynes_mappers::parse(rom_bytes)?;
804        let mut bus = Self::from_cart_and_mapper(cart, mapper, sample_rate);
805        // Keep the iNES bytes so `power_cycle` can rebuild the mapper to a true
806        // power-on state. Cheap relative to the cart it already holds, and never
807        // serialized into the save-state.
808        bus.rom_bytes = Some(Box::from(rom_bytes));
809        Ok(bus)
810    }
811
812    /// Construct a bus directly from an already-parsed cartridge + boxed mapper.
813    ///
814    /// This is the shared core of [`Self::with_sample_rate`] (iNES / NES 2.0
815    /// path) and [`Self::with_disk`] (Famicom Disk System path). Both produce a
816    /// [`Cartridge`] metadata value plus a `Box<dyn Mapper>`; this routine wires
817    /// up the PPU/APU region, the R1 master-clock dividers, and the rest of the
818    /// bus state identically for both.
819    // The struct-literal init grows with every feature-gated field; the W3
820    // unified-engine fields pushed it past the line gate.
821    #[allow(clippy::too_many_lines)]
822    pub(crate) fn from_cart_and_mapper(
823        cart: Cartridge,
824        mapper: Box<dyn Mapper>,
825        sample_rate: u32,
826    ) -> Self {
827        let region = match cart.region {
828            rustynes_mappers::Region::Pal => PpuRegion::Pal,
829            rustynes_mappers::Region::Dendy => PpuRegion::Dendy,
830            _ => PpuRegion::Ntsc,
831        };
832        let apu_region = match cart.region {
833            rustynes_mappers::Region::Pal => ApuRegion::Pal,
834            rustynes_mappers::Region::Dendy => ApuRegion::Dendy,
835            _ => ApuRegion::Ntsc,
836        };
837        // R1 master-clock dividers, cached once (region is immutable after parse).
838        // Identical to the prior `region_dividers()` match: NTSC 12/4, PAL 16/5,
839        // Dendy 15/5.
840        let (cpu_div_cached, ppu_div_cached): (u8, u8) = match cart.region {
841            rustynes_mappers::Region::Pal => (16, 5),
842            rustynes_mappers::Region::Dendy => (15, 5),
843            _ => (12, 4),
844        };
845        // v2.8.0 Phase 4 — cache the capability flags once (constant per
846        // mapper type); the per-cycle hot loop reads the copy.
847        let mapper_caps = mapper.caps();
848        let mut bus = Self {
849            ram: fresh_ram(),
850            ppu: Ppu::new(region),
851            apu: Apu::new(apu_region, sample_rate),
852            cart,
853            mapper,
854            mapper_caps,
855            // Set by `with_sample_rate` (iNES path); stays `None` for FDS.
856            rom_bytes: None,
857            controllers: [Controller::new(); 2],
858            four_score: false,
859            // v2.1.7 P5 — power-on config knobs, all at their byte-identical
860            // defaults (zeroed RAM, default revision, all-zero power-up palette).
861            power_on_ram: crate::nes::PowerOnRam::Zeroed,
862            ppu_die_revision: PpuRevision::Rp2c02H,
863            power_up_palette: PaletteInit::Zeroed,
864            controllers34: [Controller::new(); 2],
865            four_score_idx: [0; 2],
866            four_score_sig: [0; 2],
867            #[cfg(feature = "debug-hooks")]
868            controller_polled: false,
869            vs_dip: 0,
870            vs_coin: 0,
871            vs_service: false,
872            vs_is_sub: false,
873            vs_external_irq: false,
874            vs_4016_bit1: false,
875            vs_4016_bit1_dirty: false,
876            expansion_device: [None, None],
877            // v2.3.6: ON by default. See `set_zapper_temporal_light` — the frame
878            // model made a Duck Hunt hit impossible.
879            zapper_temporal_light: true,
880            famicom_mic: false,
881            nt_mirroring_override: None,
882            #[cfg(feature = "debug-hooks")]
883            events: alloc::vec::Vec::new(),
884            #[cfg(feature = "debug-hooks")]
885            event_logging: false,
886            #[cfg(feature = "debug-hooks")]
887            accesses: alloc::vec::Vec::new(),
888            #[cfg(feature = "debug-hooks")]
889            access_logging: false,
890            #[cfg(feature = "debug-hooks")]
891            interrupts: alloc::vec::Vec::new(),
892            #[cfg(feature = "debug-hooks")]
893            interrupt_logging: false,
894            #[cfg(feature = "debug-hooks")]
895            event_bp_mask: 0,
896            #[cfg(feature = "debug-hooks")]
897            event_break_hit: None,
898            cycle: 0,
899            dma_pending: None,
900            dma_cycles_owed: 0,
901            dma_byte: 0,
902            dma_idx: 0,
903            dma_page: 0,
904            dma_halt_addr: 0,
905            dma_total: 0,
906            last_nmi_level: false,
907            nmi_edge_latch: false,
908            #[cfg(feature = "cosim-interrupt-inject")]
909            inject_nmi: false,
910            #[cfg(feature = "cosim-interrupt-inject")]
911            inject_irq: false,
912            ppu_clock: 0,
913            cpu_div_cached,
914            ppu_div_cached,
915            dma_mc_consumed: 0,
916            open_bus: 0,
917            internal_data_bus: 0,
918            last_read_addr: 0,
919            deferred_dma_replay_addr: 0,
920            in_dmc_dma: false,
921            dmc_step_was_get: false,
922            uni_oam_active: false,
923            uni_oam_halt: false,
924            uni_oam_aligned: false,
925            uni_oam_addr: 0,
926            cpu_2a03_revision: Cpu2A03Revision::default(),
927            dmc_halt: false,
928            genie_codes: BTreeMap::new(),
929            m2_phase: M2Phase::Low,
930            irq_snapshot_mapper_at_low: false,
931            irq_snapshot_apu_at_low: false,
932            irq_snapshot_mapper_at_high: false,
933            irq_snapshot_apu_at_high: false,
934            controller_write_pending: 0,
935            controller_write_value: 0,
936            #[cfg(feature = "irq-timing-trace")]
937            irq_trace: None,
938            #[cfg(feature = "irq-timing-trace")]
939            trace_a12_latest: None,
940            #[cfg(feature = "irq-timing-trace")]
941            trace_last_a12: false,
942            #[cfg(feature = "irq-timing-trace")]
943            trace_a12_scratch: alloc::vec::Vec::new(),
944            #[cfg(feature = "irq-timing-trace")]
945            trace_bus_access: BusAccess::Idle,
946            #[cfg(feature = "irq-timing-trace")]
947            trace_bus_addr: 0,
948            #[cfg(feature = "irq-timing-trace")]
949            trace_bus_data: 0,
950            #[cfg(feature = "irq-timing-trace")]
951            trace_last_pc: 0,
952            #[cfg(feature = "irq-timing-trace")]
953            trace_r1_scanline_start: 0,
954            #[cfg(feature = "irq-timing-trace")]
955            trace_r1_dot_start: 0,
956            #[cfg(feature = "irq-timing-trace")]
957            trace_r1_frame_start: 0,
958        };
959        // Vs. System / PlayChoice-10: the arcade boards replace the 2C02 with a
960        // 2C03 / 2C04 / 2C05 RGB PPU. For ConsoleType::Nes (the default), the
961        // resolved type is VsPpuType::None -> Composite2C02, is_2c05 = false, so
962        // this is byte-for-byte a no-op on normal carts.
963        bus.reapply_vs_palette();
964        // F-2: under R1 the DMC byte-timer is driven at end-of-cycle by
965        // `cpu_clock_apu_dmc` (main's DMC fire-phase for DMASync).
966        {
967            bus.apu.set_dmc_driven_externally(true);
968            // Interleaved-DMA Phase A: seed the get/put + DMC fire-phase from one
969            // APUAlignment value. Fixed alignment 0 for now; Phase B drives it
970            // from the power-on PRNG (the 2 AccuracyCoin answer-key alignments).
971            bus.apu.seed_apu_alignment(0);
972        }
973        bus
974    }
975
976    /// Construct a Famicom Disk System bus from a `.fds` disk image and a
977    /// user-supplied 8 KiB BIOS (`disksys.rom`).
978    ///
979    /// Parses the disk container ([`rustynes_mappers::parse_fds`]), constructs the
980    /// FDS device ([`rustynes_mappers::Fds`]) as the bus's `Box<dyn Mapper>`, and
981    /// wires the bus exactly like a cartridge build (shared internal
982    /// `from_cart_and_mapper`). The FDS is NTSC/Famicom hardware, so the
983    /// synthetic [`Cartridge`] metadata reports [`rustynes_mappers::Region::Ntsc`].
984    ///
985    /// # Errors
986    ///
987    /// Returns [`RomError`] if the disk image is unparseable, or the BIOS is not
988    /// exactly 8 KiB.
989    pub fn with_disk(
990        disk_bytes: &[u8],
991        bios_bytes: &[u8],
992        sample_rate: u32,
993    ) -> Result<Self, RomError> {
994        let disk = rustynes_mappers::parse_fds(disk_bytes)?;
995        let fds = rustynes_mappers::Fds::new(disk, bios_bytes)?;
996        // Synthetic cartridge metadata: the bus only consults `cart.region`
997        // (verified — see `docs/audit` FDS Stage 1). The FDS device owns all
998        // PRG/CHR/BIOS storage, so the ROM byte fields are empty.
999        let cart = Cartridge {
1000            prg_rom: Box::default(),
1001            chr_rom: Box::default(),
1002            mapper_id: 20,
1003            submapper: 0,
1004            mirroring: rustynes_mappers::Mirroring::Horizontal,
1005            region: rustynes_mappers::Region::Ntsc,
1006            console_type: rustynes_mappers::ConsoleType::Nes,
1007            vs_ppu_type: rustynes_mappers::VsPpuType::None,
1008            vs_dual_system: false,
1009            prg_ram_size: 0x8000,
1010            chr_ram_size: 0x2000,
1011            has_battery: false,
1012            has_trainer: false,
1013            is_nes2: false,
1014        };
1015        Ok(Self::from_cart_and_mapper(cart, Box::new(fds), sample_rate))
1016    }
1017
1018    /// Build a bus that plays an NSF music file. Parses the `.nsf`, builds an
1019    /// [`rustynes_mappers::NsfMapper`] (a synthetic driver + the program image)
1020    /// as the bus's `Box<dyn Mapper>`, and reports synthetic NTSC cartridge
1021    /// metadata (the file carries no CHR / PPU program).
1022    ///
1023    /// # Errors
1024    ///
1025    /// Returns [`RomError::InvalidConfig`] when the NSF header is malformed.
1026    pub fn with_nsf(nsf_bytes: &[u8], sample_rate: u32) -> Result<Self, RomError> {
1027        let nsf = rustynes_mappers::parse_nsf(nsf_bytes)
1028            .map_err(|e| RomError::InvalidConfig(alloc::format!("{e}")))?;
1029        let mapper = rustynes_mappers::NsfMapper::new(&nsf);
1030        let cart = Cartridge {
1031            prg_rom: Box::default(),
1032            chr_rom: Box::default(),
1033            mapper_id: 31, // NSF banking is conventionally documented as mapper 31-like
1034            submapper: 0,
1035            mirroring: rustynes_mappers::Mirroring::Horizontal,
1036            // Playback is NTSC 60 Hz (vblank-NMI-driven) regardless of the
1037            // file's region preference; the PAL flag only feeds the driver's
1038            // init X-register. Exact non-60 Hz play rates are a documented
1039            // deferral (see `nsf.rs` module docs).
1040            region: rustynes_mappers::Region::Ntsc,
1041            console_type: rustynes_mappers::ConsoleType::Nes,
1042            vs_ppu_type: rustynes_mappers::VsPpuType::None,
1043            vs_dual_system: false,
1044            prg_ram_size: 0x2000,
1045            chr_ram_size: 0,
1046            has_battery: false,
1047            has_trainer: false,
1048            is_nes2: false,
1049        };
1050        Ok(Self::from_cart_and_mapper(
1051            cart,
1052            Box::new(mapper),
1053            sample_rate,
1054        ))
1055    }
1056
1057    /// Reset (warm). Defers to `Ppu::reset` and clears DMA state. CPU is
1058    /// reset by the caller.
1059    pub fn reset(&mut self) {
1060        self.ppu.reset();
1061        self.apu.reset();
1062        {
1063            self.apu.set_dmc_driven_externally(true);
1064            self.apu.seed_apu_alignment(0);
1065        }
1066        self.dma_pending = None;
1067        self.dma_cycles_owed = 0;
1068        self.dma_idx = 0;
1069        self.dma_halt_addr = 0;
1070        self.deferred_dma_replay_addr = 0;
1071        self.unified_dma_clear();
1072    }
1073
1074    /// Power-cycle. Zeroes RAM and resets all state. Caller resets the CPU.
1075    pub fn power_cycle(&mut self) {
1076        self.ram.fill(0);
1077        self.ppu = Ppu::new(self.ppu_region());
1078        // Re-apply the Vs./PC10 RGB-PPU configuration (lost when the PPU is
1079        // reconstructed). No-op for ConsoleType::Nes carts.
1080        self.reapply_vs_palette();
1081        // v2.1.7 P5 — re-apply the PPU-revision + power-up-palette config lost
1082        // when the PPU was reconstructed above, so `power_cycle == fresh boot`
1083        // holds for these knobs too (a core-only consumer that power-cycles
1084        // without a frontend still gets the configured hardware). All no-ops at
1085        // their defaults, so a default power-cycle stays byte-identical.
1086        self.ppu.set_revision(self.ppu_die_revision);
1087        self.ppu.apply_power_up_palette(self.power_up_palette);
1088        // v2.1.7 P5 — re-apply the power-on work-RAM fill after the `fill(0)`
1089        // above. At the default (`Zeroed`) this is the same zero fill.
1090        self.apply_power_on_ram();
1091        self.apu = Apu::new(self.apu_region(), self.apu.sample_rate);
1092        {
1093            self.apu.set_dmc_driven_externally(true);
1094            self.apu.seed_apu_alignment(0);
1095        }
1096        self.controllers = [Controller::new(); 2];
1097        // The Four Score stays "plugged in" (it's hardware config), but its
1098        // transient strobe/read state resets like the controllers above.
1099        self.controllers34 = [Controller::new(); 2];
1100        self.four_score_idx = [0; 2];
1101        self.four_score_sig = [0; 2];
1102        // Vs. System coin/service inputs are transient (DIP switches are
1103        // hardware config and persist across a power-cycle, like the panel).
1104        self.vs_coin = 0;
1105        self.vs_service = false;
1106        // v2.0.0 beta.5 (Vs. DualSystem): the comms latch + external IRQ are
1107        // transient signals; the sub identity is cabinet wiring and persists
1108        // (re-applied by the wrapper anyway).
1109        self.vs_external_irq = false;
1110        self.vs_4016_bit1 = false;
1111        self.vs_4016_bit1_dirty = false;
1112        // Non-standard input devices are unplugged on power-cycle (they are
1113        // re-attached explicitly by the frontend, like the controllers above).
1114        self.expansion_device = [None, None];
1115        // The microphone is a transient live signal; a power-cycle releases it
1116        // (the frontend re-drives it each frame while a key is held).
1117        self.famicom_mic = false;
1118        self.cycle = 0;
1119        self.dma_pending = None;
1120        self.dma_cycles_owed = 0;
1121        self.dma_idx = 0;
1122        self.dma_halt_addr = 0;
1123        self.last_nmi_level = false;
1124        self.nmi_edge_latch = false;
1125        self.open_bus = 0;
1126        self.internal_data_bus = 0;
1127        self.deferred_dma_replay_addr = 0;
1128        self.m2_phase = M2Phase::Low;
1129        self.irq_snapshot_mapper_at_low = false;
1130        self.irq_snapshot_apu_at_low = false;
1131        self.irq_snapshot_mapper_at_high = false;
1132        self.irq_snapshot_apu_at_high = false;
1133        self.unified_dma_clear();
1134        // A cold boot must reset EVERY run-history-dependent field, or the
1135        // post-power-cycle machine depends on how long it ran before — breaking
1136        // the `power_cycle == fresh boot` equivalence (netplay power-cycles
1137        // both peers at session start and requires byte-identical state). A
1138        // residual `ppu_clock` in particular carries the old master-clock
1139        // CPU/PPU phase into the "new" boot, diverging timing-sensitive games
1140        // from frame 0. Mirrors the `with_sample_rate` initial values.
1141        self.ppu_clock = 0;
1142        self.dma_byte = 0;
1143        self.dma_page = 0;
1144        self.dma_total = 0;
1145        self.dma_mc_consumed = 0;
1146        self.last_read_addr = 0;
1147        self.in_dmc_dma = false;
1148        self.dmc_step_was_get = false;
1149        self.dmc_halt = false;
1150        self.controller_write_pending = 0;
1151        self.controller_write_value = 0;
1152        // Rebuild the mapper to its power-on state (fresh bank registers, cleared
1153        // CHR-RAM + volatile PRG-RAM), so a power-cycle is a true cold boot for
1154        // mapper-stateful games (MMC1/MMC3/…) too — without this, a stateful
1155        // mapper's banking + CHR-RAM survive, so two netplay peers that power-
1156        // cycled from different running states would desync. The existing `cart`
1157        // metadata (incl. any post-load `set_vs_ppu_type` override) is kept; only
1158        // the mapper is replaced. FDS (`rom_bytes == None`) keeps its mapper.
1159        // This also clears battery PRG-RAM (a battery-pull); RustyNES does not
1160        // persist standard battery saves to disk, so nothing on-disk is lost.
1161        if let Some(bytes) = self.rom_bytes.take() {
1162            if let Ok((_cart, mapper)) = rustynes_mappers::parse(&bytes) {
1163                self.mapper = mapper;
1164                // v2.8.0 Phase 4 — re-cache the capability flags for the
1165                // fresh mapper instance (same type, same flags, but keep
1166                // the invariant mechanical).
1167                self.mapper_caps = self.mapper.caps();
1168            }
1169            self.rom_bytes = Some(bytes);
1170        }
1171    }
1172
1173    /// Developer-mode power-on randomization (Phase 7 / T-72-005).
1174    ///
1175    /// Fills the 2 KiB CPU work RAM and the external open-bus latch from a
1176    /// deterministic `xorshift64` PRNG. Real hardware powers up with
1177    /// unreliable RAM (see nesdev "CPU power up state"); games that depend on
1178    /// a particular post-power-on RAM pattern are buggy, and this option
1179    /// surfaces such bugs the way Mesen2's "randomize RAM on power-on" does.
1180    ///
1181    /// The fill is **seeded and deterministic** — the same `seed` always
1182    /// yields the same power-on state, so the
1183    /// `same seed + ROM + input ⇒ bit-identical` determinism contract (and
1184    /// therefore save-state round-trip and the regression oracle) is
1185    /// preserved. CI and tests use the default (zeroed) path; this is opt-in
1186    /// via [`crate::Nes::from_rom_with_power_on_seed`].
1187    ///
1188    /// CPU/PPU phase alignment and DMA get/put phase are intentionally **not**
1189    /// randomized here: the lockstep scheduler's phase is deterministic by
1190    /// design and randomizing it is entangled with the v2.0 master-clock
1191    /// scheduling refactor (see `docs/audit/phase-7-assessment-2026-05-24.md`).
1192    pub fn randomize_power_on_ram(&mut self, seed: u64) {
1193        // Avoid the xorshift64 zero fixed point.
1194        let mut s = if seed == 0 {
1195            0x9E37_79B9_7F4A_7C15
1196        } else {
1197            seed
1198        };
1199        let mut next = || {
1200            s ^= s << 13;
1201            s ^= s >> 7;
1202            s ^= s << 17;
1203            // Byte 3 (bits 24-31) — extracted without a truncating cast.
1204            s.to_le_bytes()[3]
1205        };
1206        for byte in self.ram.iter_mut() {
1207            *byte = next();
1208        }
1209        self.open_bus = next();
1210    }
1211
1212    /// Assert or release the injected /NMI pin. See [`crate::Nes::inject_nmi`].
1213    #[cfg(feature = "cosim-interrupt-inject")]
1214    pub const fn set_inject_nmi(&mut self, asserted: bool) {
1215        self.inject_nmi = asserted;
1216    }
1217
1218    /// Assert or release the injected /IRQ pin. See [`crate::Nes::inject_irq`].
1219    #[cfg(feature = "cosim-interrupt-inject")]
1220    pub const fn set_inject_irq(&mut self, asserted: bool) {
1221        self.inject_irq = asserted;
1222    }
1223
1224    /// Borrow the framebuffer (RGBA8, 256x240).
1225    #[must_use]
1226    pub fn framebuffer(&self) -> &[u8] {
1227        self.ppu.framebuffer()
1228    }
1229
1230    /// v1.7.0 "Forge" Workstream B (B3) — overwrite the RGBA8 output framebuffer
1231    /// (the Lua `emu:setScreenBuffer`). Output-only; see
1232    /// [`rustynes_ppu::Ppu::debug_set_framebuffer`]. `debug-hooks`-gated.
1233    #[cfg(feature = "debug-hooks")]
1234    pub fn debug_set_framebuffer(&mut self, rgba: &[u8]) {
1235        self.ppu.debug_set_framebuffer(rgba);
1236    }
1237
1238    /// Borrow the parallel palette-index framebuffer (256x240 `u16`s) for the
1239    /// `NES_NTSC` composite filter. See [`rustynes_ppu::Ppu::index_framebuffer`].
1240    #[must_use]
1241    pub fn index_framebuffer(&self) -> &[u16] {
1242        self.ppu.index_framebuffer()
1243    }
1244
1245    /// v1.2.0 C3 (hd-pack) — borrow the per-pixel HD-pack tile-source buffer.
1246    /// See [`rustynes_ppu::Ppu::hd_tile_source`]. Output-only telemetry.
1247    #[cfg(feature = "hd-pack")]
1248    #[must_use]
1249    pub fn hd_tile_source(&self) -> &[rustynes_ppu::HdTileSource] {
1250        self.ppu.hd_tile_source()
1251    }
1252
1253    /// The per-frame NTSC composite colour phase for the `NES_NTSC` filter
1254    /// (`0..=2` on NTSC; frame parity `0..=1` on PAL/Dendy). See
1255    /// [`rustynes_ppu::Ppu::ntsc_phase`].
1256    #[must_use]
1257    pub const fn ntsc_phase(&self) -> u8 {
1258        self.ppu.ntsc_phase()
1259    }
1260
1261    /// v1.1.0 beta.1 — install (`Some`) or clear (`None`) a custom 64-entry base
1262    /// palette from a loaded `.pal` file. A presentation override; `None` (default)
1263    /// is byte-identical to the built-in palette.
1264    pub const fn set_custom_palette(&mut self, base: Option<[[u8; 3]; 64]>) {
1265        self.ppu.set_custom_palette(base);
1266    }
1267
1268    /// v1.7.0 "Forge" F3 — set the PPU extra-scanlines overclock (extra idle
1269    /// vblank lines per frame). `0` (default) is byte-identical to stock timing.
1270    pub const fn set_extra_scanlines(&mut self, lines: u16) {
1271        self.ppu.set_extra_scanlines(lines);
1272    }
1273
1274    /// v1.7.0 F3 — the configured extra-scanline count (`0` = stock).
1275    #[must_use]
1276    pub const fn extra_scanlines(&self) -> u16 {
1277        self.ppu.extra_scanlines()
1278    }
1279
1280    /// v2.1.8 A1 — enable/disable the specialized visible-scanline fast dot
1281    /// path. `false` (default) is byte-identical to a build without it. See
1282    /// [`rustynes_ppu::Ppu::set_fast_dotloop`].
1283    pub const fn set_fast_dotloop(&mut self, enabled: bool) {
1284        self.ppu.set_fast_dotloop(enabled);
1285    }
1286
1287    /// v2.1.8 A1 — whether the visible-scanline fast dot path is enabled.
1288    #[must_use]
1289    pub const fn fast_dotloop(&self) -> bool {
1290        self.ppu.fast_dotloop()
1291    }
1292
1293    /// v2.1.4 F2.3 — enable/disable the optional OAM-decay accuracy model.
1294    /// `false` (default) is byte-identical to a decay-free PPU. See
1295    /// [`rustynes_ppu::Ppu::set_oam_decay`].
1296    pub const fn set_oam_decay(&mut self, enabled: bool) {
1297        self.ppu.set_oam_decay(enabled);
1298    }
1299
1300    /// v2.1.7 P5 — select the emulated 2C02 die revision, storing it so a
1301    /// power-cycle re-applies it, and applying it to the live PPU now. The
1302    /// default revision is byte-identical. See [`PpuRevision`].
1303    pub const fn set_ppu_revision(&mut self, revision: PpuRevision) {
1304        self.ppu_die_revision = revision;
1305        self.ppu.set_revision(revision);
1306    }
1307
1308    /// v2.1.7 P5 — the currently-selected 2C02 die revision.
1309    #[must_use]
1310    pub const fn ppu_revision(&self) -> PpuRevision {
1311        self.ppu_die_revision
1312    }
1313
1314    /// v2.1.7 P5 — apply a power-up palette-RAM pattern, storing it so a
1315    /// power-cycle re-applies it and writing it to the live PPU's palette RAM
1316    /// now. The default ([`PaletteInit::Zeroed`]) is byte-identical. See
1317    /// [`PaletteInit`].
1318    pub const fn set_power_up_palette(&mut self, init: PaletteInit) {
1319        self.power_up_palette = init;
1320        self.ppu.apply_power_up_palette(init);
1321    }
1322
1323    /// v2.1.7 P5 — the currently-selected power-up palette pattern.
1324    #[must_use]
1325    pub const fn power_up_palette(&self) -> PaletteInit {
1326        self.power_up_palette
1327    }
1328
1329    /// v2.1.7 P5 — select the power-on work-RAM fill, storing it so a
1330    /// power-cycle re-applies it, and applying it to the current RAM now. The
1331    /// default ([`crate::nes::PowerOnRam::Zeroed`]) is byte-identical. See [`crate::nes::PowerOnRam`].
1332    pub fn set_power_on_ram(&mut self, ram: crate::nes::PowerOnRam) {
1333        self.power_on_ram = ram;
1334        self.apply_power_on_ram();
1335    }
1336
1337    /// v2.1.7 P5 — the currently-selected power-on work-RAM fill.
1338    #[must_use]
1339    pub const fn power_on_ram(&self) -> crate::nes::PowerOnRam {
1340        self.power_on_ram
1341    }
1342
1343    /// v2.1.7 P5 — apply the stored [`Self::power_on_ram`] selection to the 2 KiB
1344    /// work RAM (and the open-bus latch). Called by [`Self::set_power_on_ram`]
1345    /// and re-applied by [`Self::power_cycle`] after it zeroes RAM. RAM is not
1346    /// consulted during the reset sequence (only the `$FFFC/D` vector is), so
1347    /// applying it here is correct. Deterministic: no wall-clock / OS RNG.
1348    fn apply_power_on_ram(&mut self) {
1349        match self.power_on_ram {
1350            crate::nes::PowerOnRam::Zeroed => {
1351                self.ram.fill(0);
1352                self.open_bus = 0;
1353            }
1354            crate::nes::PowerOnRam::Seeded(seed) => self.randomize_power_on_ram(seed),
1355            crate::nes::PowerOnRam::Filled(byte) => {
1356                self.ram.fill(byte);
1357                self.open_bus = byte;
1358            }
1359        }
1360    }
1361
1362    /// v2.1.4 F2.3 — whether the optional OAM-decay model is enabled.
1363    #[must_use]
1364    pub const fn oam_decay_enabled(&self) -> bool {
1365        self.ppu.oam_decay_enabled()
1366    }
1367
1368    /// v2.1.7 — set the emulated 2A03 die revision (DMA "unexpected read" axis).
1369    /// [`Cpu2A03Revision::Rp2A03G`] (default) is byte-identical to the pre-v2.1.7
1370    /// core; [`Cpu2A03Revision::Rp2A03H`] is the opt-in later-die model. See the
1371    /// [`Cpu2A03Revision`] docs + ADR 0033.
1372    pub const fn set_cpu_2a03_revision(&mut self, revision: Cpu2A03Revision) {
1373        self.cpu_2a03_revision = revision;
1374    }
1375
1376    /// v2.1.7 — the configured 2A03 die revision (default
1377    /// [`Cpu2A03Revision::Rp2A03G`]).
1378    #[must_use]
1379    pub const fn cpu_2a03_revision(&self) -> Cpu2A03Revision {
1380        self.cpu_2a03_revision
1381    }
1382
1383    /// Cartridge region (NTSC / PAL / Dendy / Multi). Drives wall-clock
1384    /// frame pacing in the frontend and clock-divider selection inside the
1385    /// PPU + APU.
1386    #[must_use]
1387    pub const fn region(&self) -> rustynes_mappers::Region {
1388        self.cart.region
1389    }
1390
1391    /// Length in bytes of the loaded cartridge's PRG-ROM (read-only metadata).
1392    #[must_use]
1393    pub const fn prg_rom_len(&self) -> usize {
1394        self.cart.prg_rom.len()
1395    }
1396
1397    /// Length in bytes of the loaded cartridge's CHR-ROM (0 when the board uses
1398    /// CHR-RAM). Read-only metadata.
1399    #[must_use]
1400    pub const fn chr_rom_len(&self) -> usize {
1401        self.cart.chr_rom.len()
1402    }
1403
1404    /// Enable the per-CPU-cycle IRQ-timing trace fixture with the given
1405    /// record capacity.  Records past the cap are silently dropped (see
1406    /// `IrqTrace::overflow`).  See ADR-0002 "Decision (revised,
1407    /// 2026-05-13)" → "Test fixture" and
1408    /// `crates/rustynes-core/src/irq_trace.rs`.
1409    #[cfg(feature = "irq-timing-trace")]
1410    pub fn enable_irq_trace(&mut self, capacity: usize) {
1411        self.irq_trace = Some(IrqTrace::with_capacity(capacity));
1412        self.trace_a12_latest = None;
1413        self.trace_a12_scratch.clear();
1414        // Snapshot whatever A12 level the PPU last drove so the first
1415        // recorded transition matches reality (the PPU's `last_a12` is
1416        // private to its module; we accept "first cycle may miss a level
1417        // assignment" as a cold-start artifact, matching every existing
1418        // diagnostic probe).
1419        self.trace_last_a12 = false;
1420        // Session-21: reset bus-access tracker so the first traced cycle
1421        // reflects accurate (CPU-driven) state rather than a stale
1422        // pre-trace driver.
1423        self.trace_bus_access = BusAccess::Idle;
1424        self.trace_bus_addr = 0;
1425        self.trace_bus_data = 0;
1426    }
1427
1428    /// Take the accumulated IRQ trace, leaving the bus's trace slot empty.
1429    /// Returns `None` if tracing was never enabled.
1430    #[cfg(feature = "irq-timing-trace")]
1431    #[must_use]
1432    pub const fn take_irq_trace(&mut self) -> Option<IrqTrace> {
1433        self.irq_trace.take()
1434    }
1435
1436    /// Borrow the in-flight IRQ trace for inspection without taking it.
1437    #[cfg(feature = "irq-timing-trace")]
1438    #[must_use]
1439    pub const fn irq_trace(&self) -> Option<&IrqTrace> {
1440        self.irq_trace.as_ref()
1441    }
1442
1443    /// Direct CPU-bus probe (does **not** advance time). Intended for
1444    /// blargg-style status polls at `$6000-$7FFF` and the test harness's
1445    /// mapper-resident WRAM peek. Note that this still has side effects on
1446    /// PPU registers (`$2002` clears VBL and toggle, `$2007` reads advance
1447    /// the buffer); callers should avoid touching `$2000-$3FFF` via peek.
1448    pub fn peek_cpu(&mut self, addr: u16) -> u8 {
1449        self.raw_cpu_read(addr)
1450    }
1451
1452    /// Add a Game Genie code (6 or 8 characters, case-insensitive). The code
1453    /// patches a PRG address (`$8000-$FFFF`) on the CPU read path; adding a
1454    /// code at an address that already has one replaces it.
1455    ///
1456    /// # Errors
1457    ///
1458    /// Returns [`GenieError`] if the code string cannot be decoded.
1459    pub fn add_genie_code(&mut self, code: &str) -> Result<(), GenieError> {
1460        let gc = GenieCode::new(code)?;
1461        self.genie_codes.insert(gc.addr(), gc);
1462        Ok(())
1463    }
1464
1465    /// Remove the active Game Genie code whose canonical (upper-case) string
1466    /// matches `code`. No-op if no such code is active.
1467    pub fn remove_genie_code(&mut self, code: &str) {
1468        let want = code.to_ascii_uppercase();
1469        self.genie_codes.retain(|_, gc| gc.code() != want.as_str());
1470    }
1471
1472    /// Remove all active Game Genie codes.
1473    pub fn clear_genie_codes(&mut self) {
1474        self.genie_codes.clear();
1475    }
1476
1477    /// Iterate the active Game Genie codes (address-sorted).
1478    pub fn genie_codes(&self) -> impl Iterator<Item = &GenieCode> {
1479        self.genie_codes.values()
1480    }
1481
1482    /// Apply any active Game Genie code at `addr` to a freshly-read byte.
1483    /// Fast-paths (single branch) when no codes are active.
1484    fn apply_genie(&self, addr: u16, original: u8) -> u8 {
1485        if self.genie_codes.is_empty() {
1486            return original;
1487        }
1488        self.genie_codes
1489            .get(&addr)
1490            .map_or(original, |gc| gc.read(original))
1491    }
1492
1493    /// Side-effect-free CPU bus sample for the debugger hex viewer.
1494    ///
1495    /// Returns the bus's view of the byte at `addr` without the side
1496    /// effects `peek_cpu` / `raw_cpu_read` carry on PPU register space
1497    /// (no VBL clear, no PPUDATA buffer advance, no open-bus update). For
1498    /// PPU registers we read back the cached snapshot; for mappers we go
1499    /// through `cpu_read` — the overwhelming majority of mappers are
1500    /// idempotent on `$8000-$FFFF` reads, and the few that latch on read
1501    /// (MMC2 in particular) document that behavior as inherent.
1502    ///
1503    /// Takes `&mut self` because mapper `cpu_read` is `&mut` — but no
1504    /// emulator-visible state advances. The CPU cycle counter, PPU
1505    /// scheduler, and APU all stay put.
1506    pub fn debug_peek_cpu(&mut self, addr: u16) -> u8 {
1507        match addr {
1508            0x0000..=0x1FFF => self.ram[(addr & 0x07FF) as usize],
1509            0x2000..=0x3FFF => {
1510                let reg = (addr & 7) as u8;
1511                let regs = self.ppu.debug_registers();
1512                match reg {
1513                    0 => regs[0],
1514                    1 => regs[1],
1515                    2 => regs[2],
1516                    3 => regs[3],
1517                    _ => 0,
1518                }
1519            }
1520            0x4015 => {
1521                let mut v = 0u8;
1522                if self.apu.pulse1_out() != 0 {
1523                    v |= 0x01;
1524                }
1525                if self.apu.pulse2_out() != 0 {
1526                    v |= 0x02;
1527                }
1528                if self.apu.triangle_out() != 0 {
1529                    v |= 0x04;
1530                }
1531                if self.apu.noise_out() != 0 {
1532                    v |= 0x08;
1533                }
1534                if self.apu.frame_irq_pending() {
1535                    v |= 0x40;
1536                }
1537                if self.apu.dmc_irq_pending() {
1538                    v |= 0x80;
1539                }
1540                v
1541            }
1542            0x4016 => 0x40 | self.peek_port(0) | (u8::from(self.famicom_mic) << 2),
1543            0x4017 => 0x40 | self.peek_port(1),
1544            0x4000..=0x4014 | 0x4018..=0x401F => self.open_bus,
1545            0x4020..=0xFFFF => {
1546                // Mirror the production read path so the debugger hex viewer
1547                // shows the Game-Genie-substituted byte the CPU would see.
1548                let raw = self.mapper.cpu_read(addr);
1549                self.apply_genie(addr, raw)
1550            }
1551        }
1552    }
1553
1554    /// Side-effect-free PPU bus sample (`$0000-$3FFF`).
1555    ///
1556    /// `$0000-$1FFF` -> mapper CHR, `$2000-$3EFF` -> nametable
1557    /// (via mapper's mirroring), `$3F00-$3FFF` -> palette RAM.
1558    pub fn debug_peek_ppu(&mut self, addr: u16) -> u8 {
1559        let addr = addr & 0x3FFF;
1560        match addr {
1561            0x0000..=0x1FFF => self.mapper.ppu_read(addr),
1562            0x2000..=0x3EFF => {
1563                if let Some(v) = self.mapper.nametable_fetch(addr) {
1564                    v
1565                } else {
1566                    let phys = match self.nt_mirroring_override {
1567                        Some(m) => override_nt_addr(m, addr) as usize,
1568                        None => self.mapper.nametable_address(addr) as usize,
1569                    };
1570                    let ciram = self.ppu.ciram();
1571                    ciram[phys % ciram.len()]
1572                }
1573            }
1574            0x3F00..=0x3FFF => {
1575                let idx = (addr & 0x1F) as usize;
1576                let palette = self.ppu.palette_ram();
1577                // Mirror sprite-palette zero into BG-palette zero.
1578                let idx = if idx & 0x13 == 0x10 { idx & 0x0F } else { idx };
1579                palette[idx]
1580            }
1581            _ => 0,
1582        }
1583    }
1584
1585    /// v1.7.0 "Forge" Workstream A1 — debugger writeback. The structural mirror
1586    /// of [`Self::debug_peek_ppu`]: `$0000-$1FFF` → mapper CHR (`ppu_write`,
1587    /// a no-op on CHR-ROM), `$2000-$3EFF` → nametable (mapper-absorbed, else
1588    /// CIRAM via the active mirroring), `$3F00-$3FFF` → palette RAM.
1589    ///
1590    /// Side-effect-free w.r.t. the run loop: it is reached *only* through the
1591    /// gated post-frame poke path (the same caller-side, after-`run_frame` stage
1592    /// the raw RAM cheats use), so the deterministic core run loop is unchanged
1593    /// and the no-edit path is byte-identical. `debug-hooks`-gated.
1594    #[cfg(feature = "debug-hooks")]
1595    pub fn debug_poke_ppu(&mut self, addr: u16, value: u8) {
1596        let addr = addr & 0x3FFF;
1597        match addr {
1598            0x0000..=0x1FFF => self.mapper.ppu_write(addr & 0x1FFF, value),
1599            0x2000..=0x3EFF => {
1600                let nt_addr = if addr >= 0x3000 { addr - 0x1000 } else { addr };
1601                // Give the mapper a chance to absorb the write (ExRAM
1602                // nametables, fill-mode drops), exactly like `write_vram`.
1603                if !self.mapper.nametable_write(nt_addr, value) {
1604                    let phys = match self.nt_mirroring_override {
1605                        Some(m) => override_nt_addr(m, nt_addr) as usize,
1606                        None => self.mapper.nametable_address(nt_addr) as usize,
1607                    };
1608                    self.ppu.debug_poke_ciram(phys, value);
1609                }
1610            }
1611            0x3F00..=0x3FFF => self.ppu.debug_poke_palette((addr & 0x1F) as u8, value),
1612            _ => {}
1613        }
1614    }
1615
1616    /// v1.7.0 "Forge" Workstream A1 — debugger writeback for one OAM byte
1617    /// (`idx` = 0..256). `debug-hooks`-gated; reached only through the gated
1618    /// post-frame poke path, so the default build is byte-identical.
1619    #[cfg(feature = "debug-hooks")]
1620    pub const fn debug_poke_oam(&mut self, idx: u8, value: u8) {
1621        self.ppu.debug_poke_oam(idx, value);
1622    }
1623
1624    /// Borrow the PPU (debugger / tests).
1625    #[must_use]
1626    pub const fn ppu(&self) -> &Ppu {
1627        &self.ppu
1628    }
1629
1630    /// Mutably borrow the PPU (debugger / tests).
1631    pub const fn ppu_mut(&mut self) -> &mut Ppu {
1632        &mut self.ppu
1633    }
1634
1635    /// Borrow the APU (debugger / tests).
1636    #[must_use]
1637    pub const fn apu(&self) -> &Apu {
1638        &self.apu
1639    }
1640
1641    /// Mutably borrow the APU (debugger / tests).
1642    pub const fn apu_mut(&mut self) -> &mut Apu {
1643        &mut self.apu
1644    }
1645
1646    /// Set the buttons currently held on player `port`. Ports 0/1 are the
1647    /// standard `$4016`/`$4017` controllers; ports 2/3 are players 3/4 on the
1648    /// Four Score adapter (only polled when [`Self::set_four_score`] is on).
1649    /// The change takes effect on the next strobe edge.
1650    ///
1651    /// # Panics
1652    ///
1653    /// Panics if `port` is not in `0..=3`.
1654    pub const fn set_buttons(&mut self, port: usize, buttons: Buttons) {
1655        assert!(
1656            port < 4,
1657            "controller port must be 0..=3 (2/3 are the Four Score)"
1658        );
1659        match port {
1660            0 | 1 => self.controllers[port].set_buttons(buttons),
1661            _ => self.controllers34[port - 2].set_buttons(buttons),
1662        }
1663    }
1664
1665    /// Enable the **beam-relative** Zapper light model.
1666    ///
1667    /// **Default ON since v2.3.6.** The light bit is derived from where the CRT
1668    /// beam is at the moment of the `$4016`/`$4017` read: dark before the beam
1669    /// paints the aim row, lit while the photodiode holds (~19-26 scanlines,
1670    /// per the `NESdev` wiki's capacitor model), dark once it drains. That is what
1671    /// real hardware does, and the frame-granular model structurally cannot
1672    /// express it — it returns one answer for the whole frame, sampled at
1673    /// end-of-frame, so every read during frame N reports frame N-1.
1674    ///
1675    /// # Why it was promoted (v2.3.6)
1676    ///
1677    /// A3 (v2.2.3) shipped this off, on the reasoning that "there is no pass/fail
1678    /// light-gun test ROM… the supported titles re-poll every frame and are
1679    /// satisfied by either model". **The second half of that was false**, and no
1680    /// test ROM was needed to show it — the game itself is the oracle.
1681    ///
1682    /// *Duck Hunt* requires the gun to see **nothing for one frame** and then a
1683    /// bright spot in the next. Under the frame model it received exactly the
1684    /// inverse: on the blanked frame it read the previous (bright) frame's
1685    /// answer, and on the target frame it read the blanked frame's. The shot was
1686    /// discarded before hit-testing, so the gun fired and **nothing could ever be
1687    /// hit** — reported by the maintainer, then reproduced headlessly from the
1688    /// game's own `$4017` traffic (`zapper_light_probe`).
1689    ///
1690    /// Measured A/B on the same ROM, aim and inputs: frame model → score 000000,
1691    /// duck still flying; beam-relative → score 000500, duck marked hit.
1692    ///
1693    /// Turning it off restores the pre-v2.3.6 frame-granular behaviour.
1694    /// Deterministic either way: the answer is a pure function of framebuffer +
1695    /// aim + scanline and holds no state, so it adds nothing to serialize.
1696    pub const fn set_zapper_temporal_light(&mut self, on: bool) {
1697        self.zapper_temporal_light = on;
1698    }
1699
1700    /// Whether the beam-relative Zapper light model is enabled (A3).
1701    #[must_use]
1702    pub const fn zapper_temporal_light(&self) -> bool {
1703        self.zapper_temporal_light
1704    }
1705
1706    /// Attach (or replace) a non-standard overlay device on `port` (0 =
1707    /// `$4016`, 1 = `$4017`). Pass `None` to unplug the device and return the
1708    /// port to the standard controller / Four Score path (byte-identical).
1709    ///
1710    /// # Panics
1711    ///
1712    /// Panics if `port` is not in `0..=1`.
1713    pub fn set_expansion_device(
1714        &mut self,
1715        port: usize,
1716        device: Option<crate::input_device::InputDevice>,
1717    ) {
1718        assert!(port < 2, "expansion-device port must be 0..=1");
1719        self.expansion_device[port] = device;
1720    }
1721
1722    /// Borrow the overlay device attached to `port`, if any.
1723    ///
1724    /// # Panics
1725    ///
1726    /// Panics if `port` is not in `0..=1`.
1727    #[must_use]
1728    pub const fn expansion_device(&self, port: usize) -> &Option<crate::input_device::InputDevice> {
1729        assert!(port < 2, "expansion-device port must be 0..=1");
1730        &self.expansion_device[port]
1731    }
1732
1733    /// Update an attached Vaus paddle's position + fire state on `port`. No-op
1734    /// if the attached device is not a Vaus (or no device is attached).
1735    ///
1736    /// # Panics
1737    ///
1738    /// Panics if `port` is not in `0..=1`.
1739    pub const fn set_paddle(&mut self, port: usize, position: u8, fire: bool) {
1740        assert!(port < 2, "paddle port must be 0..=1");
1741        if let Some(crate::input_device::InputDevice::Vaus(v)) = &mut self.expansion_device[port] {
1742            v.set(position, fire);
1743        }
1744    }
1745
1746    /// Update an attached Zapper's aim point + trigger on `port`. No-op if the
1747    /// attached device is not a Zapper (or no device is attached).
1748    ///
1749    /// # Panics
1750    ///
1751    /// Panics if `port` is not in `0..=1`.
1752    pub const fn set_zapper(&mut self, port: usize, x: u16, y: u16, trigger: bool) {
1753        assert!(port < 2, "zapper port must be 0..=1");
1754        if let Some(crate::input_device::InputDevice::Zapper(z)) = &mut self.expansion_device[port]
1755        {
1756            z.set(x, y, trigger);
1757        }
1758    }
1759
1760    /// Drive the Famicom built-in microphone signal (read on `$4016` bit 2).
1761    ///
1762    /// `pressed` = the frontend's quantized "mic is loud" verdict. Additive:
1763    /// leaving it `false` (the default) keeps the `$4016` read byte-identical.
1764    pub const fn set_microphone(&mut self, pressed: bool) {
1765        self.famicom_mic = pressed;
1766    }
1767
1768    /// Whether the Famicom microphone signal is currently asserted.
1769    #[must_use]
1770    pub const fn microphone(&self) -> bool {
1771        self.famicom_mic
1772    }
1773
1774    /// Update an attached Power Pad's live button mask (bit `i` = mat button
1775    /// `i+1`) on `port`. No-op if the attached device is not a Power Pad.
1776    ///
1777    /// # Panics
1778    ///
1779    /// Panics if `port` is not in `0..=1`.
1780    pub const fn set_power_pad(&mut self, port: usize, buttons: u16) {
1781        assert!(port < 2, "power pad port must be 0..=1");
1782        if let Some(crate::input_device::InputDevice::PowerPad(p)) =
1783            &mut self.expansion_device[port]
1784        {
1785            p.set(buttons);
1786        }
1787    }
1788
1789    /// Update an attached SNES mouse's movement + buttons + sensitivity on
1790    /// `port`. No-op if the attached device is not a mouse.
1791    ///
1792    /// # Panics
1793    ///
1794    /// Panics if `port` is not in `0..=1`.
1795    pub const fn set_snes_mouse(
1796        &mut self,
1797        port: usize,
1798        dx: i16,
1799        dy: i16,
1800        left: bool,
1801        right: bool,
1802        sensitivity: u8,
1803    ) {
1804        assert!(port < 2, "mouse port must be 0..=1");
1805        if let Some(crate::input_device::InputDevice::SnesMouse(m)) =
1806            &mut self.expansion_device[port]
1807        {
1808            m.set(dx, dy, left, right, sensitivity);
1809        }
1810    }
1811
1812    /// Update an attached Family BASIC keyboard's pressed-key bitmap on `port`
1813    /// (one byte per matrix row). No-op if the attached device is not a keyboard.
1814    ///
1815    /// # Panics
1816    ///
1817    /// Panics if `port` is not in `0..=1`.
1818    pub const fn set_family_keyboard(&mut self, port: usize, keys: [u8; 9]) {
1819        assert!(port < 2, "keyboard port must be 0..=1");
1820        if let Some(crate::input_device::InputDevice::FamilyKeyboard(k)) =
1821            &mut self.expansion_device[port]
1822        {
1823            k.set_keys(keys);
1824        }
1825    }
1826
1827    /// v1.3.0 Workstream F1 — update an attached Family Trainer mat's 12-button
1828    /// mask on `port`. No-op if the attached device is not a Family Trainer.
1829    ///
1830    /// # Panics
1831    ///
1832    /// Panics if `port` is not in `0..=1`.
1833    pub const fn set_family_trainer(&mut self, port: usize, buttons: u16) {
1834        assert!(port < 2, "family trainer port must be 0..=1");
1835        if let Some(crate::input_device::InputDevice::FamilyTrainer(p)) =
1836            &mut self.expansion_device[port]
1837        {
1838            p.set(buttons);
1839        }
1840    }
1841
1842    /// v1.3.0 Workstream F1 — update an attached Subor keyboard's pressed-key
1843    /// bitmap on `port`. No-op if the attached device is not a Subor keyboard.
1844    ///
1845    /// # Panics
1846    ///
1847    /// Panics if `port` is not in `0..=1`.
1848    pub const fn set_subor_keyboard(&mut self, port: usize, keys: [u8; 9]) {
1849        assert!(port < 2, "subor keyboard port must be 0..=1");
1850        if let Some(crate::input_device::InputDevice::SuborKeyboard(k)) =
1851            &mut self.expansion_device[port]
1852        {
1853            k.set_keys(keys);
1854        }
1855    }
1856
1857    /// v1.3.0 Workstream F1 — update an attached Konami Hyper Shot's 4-button
1858    /// mask on `port`. No-op if the attached device is not a Konami Hyper Shot.
1859    ///
1860    /// # Panics
1861    ///
1862    /// Panics if `port` is not in `0..=1`.
1863    pub const fn set_konami_hyper_shot(&mut self, port: usize, buttons: u8) {
1864        assert!(port < 2, "konami hyper shot port must be 0..=1");
1865        if let Some(crate::input_device::InputDevice::KonamiHyperShot(h)) =
1866            &mut self.expansion_device[port]
1867        {
1868            h.set(buttons);
1869        }
1870    }
1871
1872    /// v1.3.0 Workstream F1 — update an attached Bandai Hyper Shot's 8-sensor
1873    /// mask on `port`. No-op if the attached device is not a Bandai Hyper Shot.
1874    ///
1875    /// # Panics
1876    ///
1877    /// Panics if `port` is not in `0..=1`.
1878    pub const fn set_bandai_hyper_shot(&mut self, port: usize, sensors: u8) {
1879        assert!(port < 2, "bandai hyper shot port must be 0..=1");
1880        if let Some(crate::input_device::InputDevice::BandaiHyperShot(b)) =
1881            &mut self.expansion_device[port]
1882        {
1883            b.set(sensors);
1884        }
1885    }
1886
1887    /// v1.1.0 beta.1 (T-110-B4) — set (`Some`) or clear (`None`) the per-game
1888    /// nametable mirroring override. A frontend load-time correction; `None`
1889    /// (default) defers to the mapper (byte-identical).
1890    pub const fn set_mirroring_override(&mut self, m: Option<rustynes_mappers::Mirroring>) {
1891        self.nt_mirroring_override = m;
1892    }
1893
1894    /// The current per-game mirroring override (for the save-state).
1895    #[must_use]
1896    pub const fn mirroring_override(&self) -> Option<rustynes_mappers::Mirroring> {
1897        self.nt_mirroring_override
1898    }
1899
1900    /// Whether the loaded mapper hardwires its nametable mirroring (so an
1901    /// external mirroring correction is safe to honor). See
1902    /// [`rustynes_mappers::Mapper::has_hardwired_mirroring`].
1903    #[must_use]
1904    pub fn mapper_has_hardwired_mirroring(&self) -> bool {
1905        self.mapper.has_hardwired_mirroring()
1906    }
1907
1908    /// v1.1.0 beta.2 (T-110-C3) — start/stop event-viewer recording.
1909    #[cfg(feature = "debug-hooks")]
1910    pub const fn set_event_logging(&mut self, enabled: bool) {
1911        self.event_logging = enabled;
1912    }
1913
1914    /// Whether event-viewer recording is on.
1915    #[cfg(feature = "debug-hooks")]
1916    #[must_use]
1917    pub const fn event_logging(&self) -> bool {
1918        self.event_logging
1919    }
1920
1921    /// The events captured so far this frame.
1922    #[cfg(feature = "debug-hooks")]
1923    #[must_use]
1924    #[allow(clippy::missing_const_for_fn)] // Vec->slice deref is not const.
1925    pub fn events(&self) -> &[EventRec] {
1926        &self.events
1927    }
1928
1929    /// v1.1.0 beta.3 (T-110-E2) — start/stop the Lua bus-access log.
1930    #[cfg(feature = "debug-hooks")]
1931    pub const fn set_access_logging(&mut self, enabled: bool) {
1932        self.access_logging = enabled;
1933    }
1934
1935    /// Whether the bus-access log is recording.
1936    #[cfg(feature = "debug-hooks")]
1937    #[must_use]
1938    pub const fn access_logging(&self) -> bool {
1939        self.access_logging
1940    }
1941
1942    /// The CPU bus accesses captured so far this frame.
1943    #[cfg(feature = "debug-hooks")]
1944    #[must_use]
1945    #[allow(clippy::missing_const_for_fn)] // Vec->slice deref is not const.
1946    pub fn accesses(&self) -> &[AccessRec] {
1947        &self.accesses
1948    }
1949
1950    /// Clear the bus-access log (called per frame by the run loop).
1951    #[cfg(feature = "debug-hooks")]
1952    pub fn clear_accesses(&mut self) {
1953        self.accesses.clear();
1954    }
1955
1956    /// v1.2.0 (T-110-E1) — start/stop the Lua interrupt-service log.
1957    #[cfg(feature = "debug-hooks")]
1958    pub const fn set_interrupt_logging(&mut self, enabled: bool) {
1959        self.interrupt_logging = enabled;
1960    }
1961
1962    /// Whether the interrupt-service log is recording.
1963    #[cfg(feature = "debug-hooks")]
1964    #[must_use]
1965    pub const fn interrupt_logging(&self) -> bool {
1966        self.interrupt_logging
1967    }
1968
1969    /// The interrupt-service entries captured so far this frame.
1970    #[cfg(feature = "debug-hooks")]
1971    #[must_use]
1972    #[allow(clippy::missing_const_for_fn)] // Vec->slice deref is not const.
1973    pub fn interrupts(&self) -> &[InterruptRec] {
1974        &self.interrupts
1975    }
1976
1977    /// Clear the interrupt-service log (called per frame by the run loop).
1978    #[cfg(feature = "debug-hooks")]
1979    pub fn clear_interrupts(&mut self) {
1980        self.interrupts.clear();
1981    }
1982
1983    /// v1.4.0 Workstream D (D2) — set the armed event-breakpoint category mask
1984    /// (a bit-OR of [`EventBpKind::bit`]). `0` disarms all (the default + the
1985    /// per-cycle-cheap path).
1986    #[cfg(feature = "debug-hooks")]
1987    pub const fn set_event_breakpoints(&mut self, mask: u16) {
1988        self.event_bp_mask = mask;
1989    }
1990
1991    /// The armed event-breakpoint category mask.
1992    #[cfg(feature = "debug-hooks")]
1993    #[must_use]
1994    pub const fn event_breakpoints(&self) -> u16 {
1995        self.event_bp_mask
1996    }
1997
1998    /// Take the first event-breakpoint hit of the current frame (cleared on
1999    /// read). The frontend polls this after `run_frame`.
2000    #[cfg(feature = "debug-hooks")]
2001    pub const fn take_event_break_hit(&mut self) -> Option<EventBreakHit> {
2002        self.event_break_hit.take()
2003    }
2004
2005    /// Clear any recorded event-breakpoint hit (called per frame by the run
2006    /// loop so each frame starts fresh).
2007    #[cfg(feature = "debug-hooks")]
2008    pub const fn clear_event_break_hit(&mut self) {
2009        self.event_break_hit = None;
2010    }
2011
2012    /// v1.4.0 Workstream D (D2) — observational event-breakpoint tap. If `kind`
2013    /// is armed and no hit has been recorded yet this frame, latch the event
2014    /// with its full timing context. Pure observation — no emulator-visible
2015    /// state changes, so determinism holds. The `mask == 0` fast path keeps the
2016    /// default (no armed categories) cheap.
2017    #[cfg(feature = "debug-hooks")]
2018    const fn record_event_break(&mut self, kind: EventBpKind, addr: u16) {
2019        if self.event_bp_mask & kind.bit() == 0 || self.event_break_hit.is_some() {
2020            return;
2021        }
2022        self.event_break_hit = Some(EventBreakHit {
2023            kind,
2024            addr,
2025            frame: self.ppu.frame(),
2026            cycle: self.cycle,
2027            scanline: self.ppu.scanline(),
2028            dot: self.ppu.dot(),
2029        });
2030    }
2031
2032    /// Clear the event log (called at each frame start while recording).
2033    #[cfg(feature = "debug-hooks")]
2034    pub fn clear_events(&mut self) {
2035        self.events.clear();
2036    }
2037
2038    /// Clear the per-frame `TAStudio` lag-log "controller polled" flag (called at
2039    /// the top of each [`crate::Nes::run_frame`]). `debug-hooks`-gated;
2040    /// output-only, so the shipped build is byte-identical.
2041    #[cfg(feature = "debug-hooks")]
2042    pub(crate) const fn clear_controller_polled(&mut self) {
2043        self.controller_polled = false;
2044    }
2045
2046    /// `true` if a controller port (`$4016`/`$4017`) was read since the last
2047    /// [`Self::clear_controller_polled`] — i.e. during the current frame.
2048    #[cfg(feature = "debug-hooks")]
2049    #[must_use]
2050    pub(crate) const fn controller_polled(&self) -> bool {
2051        self.controller_polled
2052    }
2053
2054    /// Sample the framebuffer luminance at each attached Zapper's aim point.
2055    /// Called once per frame (only does work when a Zapper is attached, so the
2056    /// no-device path is byte-identical).
2057    pub fn sample_zapper_light(&mut self) {
2058        let has_zapper = self
2059            .expansion_device
2060            .iter()
2061            .any(|d| matches!(d, Some(crate::input_device::InputDevice::Zapper(_))));
2062        if !has_zapper {
2063            return;
2064        }
2065        // Borrow the framebuffer once; copy the per-port aim sample.
2066        for port in 0..2 {
2067            if let Some(crate::input_device::InputDevice::Zapper(_)) = &self.expansion_device[port]
2068            {
2069                // Take the device out to avoid the &mut self / &self.ppu borrow
2070                // conflict, sample, then put it back.
2071                let mut dev = self.expansion_device[port].take();
2072                if let Some(crate::input_device::InputDevice::Zapper(z)) = &mut dev {
2073                    z.sample_light(self.ppu.framebuffer());
2074                }
2075                self.expansion_device[port] = dev;
2076            }
2077        }
2078    }
2079
2080    /// Borrow controller `port` (0/1 = `$4016`/`$4017`; 2/3 = Four Score
2081    /// players 3/4).
2082    ///
2083    /// # Panics
2084    ///
2085    /// Panics if `port` is not in `0..=3`.
2086    #[must_use]
2087    pub const fn controller(&self, port: usize) -> &Controller {
2088        match port {
2089            0 | 1 => &self.controllers[port],
2090            _ => &self.controllers34[port - 2],
2091        }
2092    }
2093
2094    /// Has the PPU completed a frame? Drains the latch.
2095    pub const fn take_frame_complete(&mut self) -> bool {
2096        self.ppu.take_frame_complete()
2097    }
2098
2099    /// Drain finalized audio samples (host sample rate, normalized `[0, ~1]`).
2100    pub fn drain_audio(&mut self) -> Vec<f32> {
2101        self.apu.drain_audio()
2102    }
2103
2104    /// Drain into a slice.
2105    pub fn drain_audio_into(&mut self, out: &mut [f32]) -> usize {
2106        self.apu.drain_audio_into(out)
2107    }
2108
2109    /// Cumulative CPU cycle count.
2110    #[must_use]
2111    pub const fn cycle(&self) -> u64 {
2112        self.cycle
2113    }
2114
2115    /// Returns the M2 phase the lockstep scheduler is currently in.
2116    ///
2117    /// The scheduler ticks the PPU 3 dots per CPU cycle.  Convention:
2118    /// [`M2Phase::Low`] is the FIRST half of the cycle (the cycle's
2119    /// pre-sub-dot-1 portion, corresponding to silicon's `φ1`);
2120    /// [`M2Phase::High`] is the SECOND half (post-sub-dot-1, silicon's
2121    /// `φ2`).  The boundary is the M2-rising edge between sub-dot 1 and
2122    /// sub-dot 2.
2123    ///
2124    /// At the start of `tick_one_cpu_cycle` the bus is in [`M2Phase::Low`].
2125    /// After sub-dot 1 of the 3-PPU-dot tick loop, it transitions to
2126    /// [`M2Phase::High`].  After the cycle's last sub-dot the bus
2127    /// advances the cycle counter and returns to [`M2Phase::Low`] for
2128    /// the next cycle.
2129    ///
2130    /// This accessor is informational.  As of Phase B2 the bus stores
2131    /// per-phase IRQ snapshots (read by [`Bus::poll_irq_at_phase`])
2132    /// independently of this accessor — `current_m2_phase()` itself is
2133    /// not consulted by the CPU's IRQ sample path.
2134    #[must_use]
2135    pub const fn current_m2_phase(&self) -> M2Phase {
2136        self.m2_phase
2137    }
2138
2139    /// Set the Vs. System 8-bit DIP switch bank (switch 1 = bit 0 ..
2140    /// switch 8 = bit 7). No effect on non-Vs. carts. Default 0.
2141    pub const fn set_vs_dip(&mut self, dip: u8) {
2142        self.vs_dip = dip;
2143    }
2144
2145    /// Current Vs. System DIP switch bank.
2146    #[must_use]
2147    pub const fn vs_dip(&self) -> u8 {
2148        self.vs_dip
2149    }
2150
2151    /// Push the cartridge's current [`rustynes_mappers::VsPpuType`] into the PPU
2152    /// (output palette + 2C05 `$2000`/`$2001` swap + `$2002` identifier).
2153    ///
2154    /// Called from the constructor, [`Self::power_cycle`], and
2155    /// [`Self::set_vs_ppu_type`]. For [`rustynes_mappers::ConsoleType::Nes`] carts
2156    /// the resolved type is [`rustynes_mappers::VsPpuType::None`] -> `Composite2C02`,
2157    /// `is_2c05 = false`, so this is byte-for-byte a no-op on normal carts.
2158    const fn reapply_vs_palette(&mut self) {
2159        let vs = self.cart.vs_ppu_type;
2160        let palette = vs_palette_to_ppu(vs.ppu_palette());
2161        self.ppu
2162            .set_palette(palette, vs.is_2c05(), vs.ppu_2c05_id());
2163    }
2164
2165    /// Override the Vs. System PPU type and re-apply the output palette / 2C05
2166    /// quirks immediately.
2167    ///
2168    /// iNES-1.0 dumps carry no NES 2.0 byte-13, so the parser defaults a Vs.
2169    /// cart to [`rustynes_mappers::VsPpuType::Rp2C03`]; a per-game database (keyed on
2170    /// the ROM SHA-256) supplies the correct 2C04-000x / 2C05 type, which the
2171    /// frontend applies through this setter. No effect on the running game's
2172    /// logic — only the colour LUT the PPU emits through. No-op shape on
2173    /// non-Vs. carts (the default path never calls this).
2174    pub const fn set_vs_ppu_type(&mut self, t: rustynes_mappers::VsPpuType) {
2175        self.cart.vs_ppu_type = t;
2176        self.reapply_vs_palette();
2177    }
2178
2179    /// Latch a Vs. System coin insertion. `acceptor` 0 = acceptor #1 ($4016
2180    /// bit 5), 1 = acceptor #2 ($4016 bit 6); any other value is ignored. The
2181    /// frontend should clear the latch (see [`Self::clear_coin`]) after the
2182    /// real-hardware ~40-70 ms window (a few frames). No effect on non-Vs.
2183    /// carts.
2184    pub const fn insert_coin(&mut self, acceptor: u8) {
2185        match acceptor {
2186            0 => self.vs_coin |= 0x01,
2187            1 => self.vs_coin |= 0x02,
2188            _ => {}
2189        }
2190    }
2191
2192    /// Clear all latched Vs. System coin-insert signals.
2193    pub const fn clear_coin(&mut self) {
2194        self.vs_coin = 0;
2195    }
2196
2197    /// Set / clear the Vs. System service button ($4016 bit 2).
2198    pub const fn set_vs_service(&mut self, pressed: bool) {
2199        self.vs_service = pressed;
2200    }
2201
2202    /// v2.0.0 beta.5 (Vs. `DualSystem`): mark this console as the SUB half of a
2203    /// `DualSystem` pair (`$4016` reads return bit 7 = `0x80`). Wrapper-only.
2204    pub const fn set_vs_sub(&mut self, is_sub: bool) {
2205        self.vs_is_sub = is_sub;
2206    }
2207
2208    /// v2.0.0 beta.5 (Vs. `DualSystem`): drive this console's external `/IRQ`
2209    /// line (the partner console's `$4016` bit-1 signal, Mesen2
2210    /// `IRQSource::External`). Wrapper-only; OR'd into the IRQ level.
2211    pub const fn set_vs_external_irq(&mut self, asserted: bool) {
2212        self.vs_external_irq = asserted;
2213    }
2214
2215    /// v2.0.0 beta.5 (Vs. `DualSystem`): poll-and-clear the latched `$4016`
2216    /// bit-1 (main/sub comms signal) LEVEL. Returns `Some(level)` whenever
2217    /// this console wrote `$4016` since the last poll — deliberately
2218    /// level-driven, not edge-filtered (see the `vs_4016_bit1_dirty` field
2219    /// doc); the wrapper turns the level into the partner's external-IRQ
2220    /// assert (LOW asserts, HIGH clears). The shared-WRAM convergence
2221    /// (`pump_comms`'s separate `drain_vs_dual_wram_writes` step) runs on
2222    /// BOTH consoles every poll, independent of this bit-1 signal.
2223    pub const fn take_vs_mainsub_edge(&mut self) -> Option<bool> {
2224        if self.vs_4016_bit1_dirty {
2225            self.vs_4016_bit1_dirty = false;
2226            Some(self.vs_4016_bit1)
2227        } else {
2228            None
2229        }
2230    }
2231
2232    /// v2.0.0 beta.5 (Vs. `DualSystem`): provision the mapper-99 shared 2 KiB
2233    /// WRAM window (`$6000-$7FFF`). Wrapper-only; no-op on other boards.
2234    pub fn enable_vs_dual_wram(&mut self) {
2235        self.mapper.enable_vs_dual_wram();
2236    }
2237
2238    /// v2.0.0 beta.5 (Vs. `DualSystem`): mark the mapper as the SUB
2239    /// console's instance (banks the second PRG half + upper CHR pages —
2240    /// the two CPUs run different programs). Wrapper-only cabinet wiring.
2241    pub fn set_vs_dual_sub(&mut self) {
2242        self.mapper.set_vs_dual_sub();
2243    }
2244
2245    /// v2.0.0 beta.5 (Vs. `DualSystem`): drain this console's shared-WRAM
2246    /// write log for the wrapper to replay into the partner console (the
2247    /// fully-shared MAME model). Empty off-board. Allocates a fresh `Vec`
2248    /// each call — fine for diagnostics/tests, NOT used by the hot
2249    /// `pump_comms` path (see [`Self::drain_vs_dual_wram_writes`]).
2250    pub fn take_vs_dual_wram_writes(&mut self) -> alloc::vec::Vec<(u16, u8)> {
2251        self.mapper.take_vs_dual_wram_writes()
2252    }
2253
2254    /// v2.0.0 beta.5 (Vs. `DualSystem`): drain this console's shared-WRAM
2255    /// write log into a caller-owned, reusable `dst` buffer — the
2256    /// hot-path counterpart of [`Self::take_vs_dual_wram_writes`], used by
2257    /// `VsDualSystem::pump_comms` (called after every stepped instruction)
2258    /// to avoid allocating a fresh `Vec` on every call.
2259    pub fn drain_vs_dual_wram_writes(&mut self, dst: &mut alloc::vec::Vec<(u16, u8)>) {
2260        self.mapper.drain_vs_dual_wram_writes(dst);
2261    }
2262
2263    /// v2.0.0 beta.5 (Vs. `DualSystem`): replay one partner-console write
2264    /// into this console's shared-WRAM copy (no re-log).
2265    pub fn apply_vs_dual_wram_write(&mut self, offset: u16, value: u8) {
2266        self.mapper.apply_vs_dual_wram_write(offset, value);
2267    }
2268
2269    /// v2.0.0 beta.5 (Vs. `DualSystem`): take the shared-WRAM copy (wrapper
2270    /// snapshot-restore normalization). `None` off-board.
2271    pub fn take_vs_dual_wram(&mut self) -> Option<alloc::boxed::Box<[u8]>> {
2272        self.mapper.take_vs_dual_wram()
2273    }
2274
2275    /// v2.0.0 beta.5 (Vs. `DualSystem`): install a shared-WRAM copy (the
2276    /// other half of the restore normalization).
2277    pub fn set_vs_dual_wram(&mut self, wram: alloc::boxed::Box<[u8]>) {
2278        self.mapper.set_vs_dual_wram(wram);
2279    }
2280
2281    /// True when the running cart is Vs. System hardware (NES 2.0 console type).
2282    #[must_use]
2283    pub fn is_vs_system(&self) -> bool {
2284        self.cart.console_type == rustynes_mappers::ConsoleType::VsSystem
2285    }
2286
2287    /// True when the cart's header marks a Vs. `DualSystem` board (two CPUs /
2288    /// two PPUs). Detection only — the dual-console emulation is a documented
2289    /// v2.0 deferral (`docs/audit/vs-dualsystem-design-2026-06-11.md`); this
2290    /// lets the frontend surface a clear note instead of a black screen.
2291    #[must_use]
2292    pub const fn is_vs_dual_system(&self) -> bool {
2293        self.cart.vs_dual_system
2294    }
2295
2296    /// Overlay the Vs. System `$4016` upper bits (service, DIP 1/2, coins) onto
2297    /// the standard controller read. No-op on non-Vs. carts, so the standard
2298    /// `$4016` read is byte-identical.
2299    ///
2300    /// Layout (nesdev "Vs. System" §`$4016` read): `PCCD DS0B` — bit 0 = right
2301    /// stick (already in `base`), bit 2 = service, bit 3 = DIP switch 1, bit 4 =
2302    /// DIP switch 2, bit 5 = coin #1, bit 6 = coin #2, bit 7 = primary CPU.
2303    /// Bit 7 is `0` on a single console / the `DualSystem` MAIN half and `0x80`
2304    /// on the `DualSystem` SUB half (Mesen2 `IsVsMainConsole() ? 0 : 0x80` —
2305    /// the identity bit the `DualSystem` ROM polls; v2.0.0 beta.5).
2306    fn vs_overlay_4016(&self, base: u8) -> u8 {
2307        if !self.is_vs_system() {
2308            return base;
2309        }
2310        // Keep only bit 0 (controller D0) + bit 1 (D1, always 0 here); the Vs.
2311        // bus drives bits 2-7 from the panel, not from open bus.
2312        let mut v = base & 0x01;
2313        if self.vs_service {
2314            v |= 0x04;
2315        }
2316        // DIP switch 1 -> bit 3, switch 2 -> bit 4.
2317        v |= (self.vs_dip & 0x01) << 3;
2318        v |= ((self.vs_dip >> 1) & 0x01) << 4;
2319        // Coin acceptors -> bits 5/6.
2320        v |= (self.vs_coin & 0x03) << 5;
2321        // v2.0.0 beta.5: the DualSystem main/sub identity bit.
2322        if self.vs_is_sub {
2323            v |= 0x80;
2324        }
2325        v
2326    }
2327
2328    /// Overlay the Vs. System `$4017` upper bits (DIP 3-8) onto the standard
2329    /// controller read. No-op on non-Vs. carts.
2330    ///
2331    /// Layout (nesdev "Vs. System" §`$4017` read): `DDDD DD0B` — bit 0 = left
2332    /// stick (already in `base`), bits 2-7 = DIP switches 3 through 8.
2333    fn vs_overlay_4017(&self, base: u8) -> u8 {
2334        if !self.is_vs_system() {
2335            return base;
2336        }
2337        // DIP switches 3..=8 occupy bits 2..=7 (switch 3 = DIP bit 2 -> $4017
2338        // bit 2, switch 8 = DIP bit 7 -> $4017 bit 7); a 1:1 mapping.
2339        (base & 0x01) | (self.vs_dip & 0xFC)
2340    }
2341
2342    /// Mapper debug info for the debugger UI: the mapper's own bank/IRQ state
2343    /// ENRICHED (v1.5.0 "Lens" Workstream I8) with the cartridge-level metadata
2344    /// the bus owns — submapper, accuracy tier, ROM/RAM sizes, battery, the IRQ
2345    /// mechanism, and the expansion-audio chip. Output-only; these enrichment
2346    /// fields are filled here rather than in each of the 100+ mappers, and they
2347    /// default to empty (so a mapper's own `debug_info()` is unchanged).
2348    #[must_use]
2349    pub fn mapper_debug_info(&self) -> rustynes_mappers::MapperDebugInfo {
2350        let mut info = self.mapper.debug_info();
2351        let cart = &self.cart;
2352        info.submapper = cart.submapper;
2353        info.tier = rustynes_mappers::mapper_tier(cart.mapper_id, cart.submapper)
2354            .map_or("", rustynes_mappers::MapperTier::name);
2355        info.prg_rom_size = cart.prg_rom.len();
2356        info.chr_rom_size = cart.chr_rom.len();
2357        info.prg_ram_size = cart.prg_ram_size as usize;
2358        info.chr_ram_size = cart.chr_ram_size as usize;
2359        info.has_battery = cart.has_battery;
2360        // IRQ mechanism: named per the documented per-mapper IRQ family table
2361        // (docs/mappers.md). MMC3/RAMBO use PPU A12; MMC5 uses scanline
2362        // detection; the VRC/FME-7/N163 families tick on the CPU-cycle hook.
2363        info.irq_kind = match cart.mapper_id {
2364            4 | 64 | 118 | 119 | 206 => "PPU A12 counter (MMC3-style)",
2365            5 => "PPU scanline (MMC5)",
2366            // CPU-cycle-clocked IRQ counters surface via the caps hook.
2367            _ if self.mapper_caps.cpu_cycle_hook => "CPU cycle (VRC / FME-7 / N163)",
2368            _ => "",
2369        };
2370        info.expansion_audio = if self.mapper_caps.audio {
2371            Some(match cart.mapper_id {
2372                5 => "MMC5",
2373                19 | 210 => "Namco 163",
2374                20 => "FDS",
2375                24 | 26 => "VRC6",
2376                69 => "Sunsoft 5B",
2377                85 => "VRC7 (OPLL)",
2378                _ => "Expansion audio",
2379            })
2380        } else {
2381            None
2382        };
2383        info
2384    }
2385
2386    /// The cached per-cycle mapper capability flags (see
2387    /// [`rustynes_mappers::MapperCaps`]). `caps.audio` reflects whether the
2388    /// loaded mapper has on-cart expansion audio with the `mapper-audio` feature
2389    /// compiled in — used by the frontend to surface expansion-channel mixing
2390    /// controls only for boards that actually have them.
2391    #[must_use]
2392    pub const fn mapper_caps(&self) -> rustynes_mappers::MapperCaps {
2393        self.mapper_caps
2394    }
2395
2396    /// Borrow CPU RAM (2 KiB).
2397    #[must_use]
2398    pub fn ram_bytes(&self) -> &[u8] {
2399        &*self.ram
2400    }
2401
2402    /// Borrow both controllers as a slice.
2403    #[must_use]
2404    pub const fn controllers_ref(&self) -> &[Controller; 2] {
2405        &self.controllers
2406    }
2407
2408    /// Borrow the Four Score players 3 & 4 (save-state).
2409    #[must_use]
2410    pub const fn controllers34_ref(&self) -> &[Controller; 2] {
2411        &self.controllers34
2412    }
2413
2414    /// Enable/disable the Four Score 4-player adapter. Off by default; while
2415    /// off, `$4016`/`$4017` behave exactly as the standard two controllers
2416    /// (byte-identical reads — determinism + save-states unaffected).
2417    pub const fn set_four_score(&mut self, enabled: bool) {
2418        self.four_score = enabled;
2419    }
2420
2421    /// Whether the Four Score adapter is currently enabled.
2422    #[must_use]
2423    pub const fn four_score(&self) -> bool {
2424        self.four_score
2425    }
2426
2427    // --- Famicom Disk System disk control (delegates to the mapper) ---
2428
2429    /// Number of disk sides in the inserted FDS image (0 for cartridge builds).
2430    #[must_use]
2431    pub fn disk_side_count(&self) -> usize {
2432        self.mapper.disk_side_count()
2433    }
2434
2435    /// The currently inserted FDS disk side, or `None` when ejected (or for a
2436    /// cartridge build).
2437    #[must_use]
2438    pub fn inserted_disk_side(&self) -> Option<usize> {
2439        self.mapper.inserted_disk_side()
2440    }
2441
2442    /// Insert FDS side `i` (`Some`) or eject (`None`). No-op on cartridge builds.
2443    pub fn set_disk_side(&mut self, side: Option<usize>) {
2444        self.mapper.set_disk_side(side);
2445    }
2446
2447    /// Number of selectable NSF songs (0 for cartridge / disk builds).
2448    #[must_use]
2449    pub fn nsf_song_count(&self) -> u8 {
2450        self.mapper.nsf_song_count()
2451    }
2452
2453    /// The currently-selected 0-based NSF song (0 for cartridge / disk builds).
2454    #[must_use]
2455    pub fn nsf_current_song(&self) -> u8 {
2456        self.mapper.nsf_current_song()
2457    }
2458
2459    /// Select a 0-based NSF song. Returns `true` if this is an NSF build (so the
2460    /// caller re-runs the reset that re-enters the driver's `init`).
2461    pub fn nsf_set_song(&mut self, song: u8) -> bool {
2462        self.mapper.nsf_set_song(song)
2463    }
2464
2465    /// Start recording the diagnostic FDS read-stream trace (off by default;
2466    /// observation-only). No-op on cartridge builds.
2467    pub fn enable_fds_trace(&mut self) {
2468        self.mapper.enable_fds_trace();
2469    }
2470
2471    /// Drain the accumulated FDS read-stream trace records (empty for cartridge
2472    /// builds / when tracing was never enabled).
2473    pub fn take_fds_trace(&mut self) -> Vec<rustynes_mappers::FdsTraceRec> {
2474        self.mapper.take_fds_trace()
2475    }
2476
2477    /// Re-serialize the (possibly-modified) FDS disk image to its byte layout
2478    /// for host persistence. Empty for cartridge builds.
2479    #[must_use]
2480    pub fn disk_image_bytes(&self) -> Vec<u8> {
2481        self.mapper.disk_image_bytes()
2482    }
2483
2484    /// Whether the FDS disk image has unsaved writes.
2485    #[must_use]
2486    pub fn disk_is_dirty(&self) -> bool {
2487        self.mapper.disk_is_dirty()
2488    }
2489
2490    /// Clear the FDS disk dirty flag (after the host persists the image).
2491    pub fn clear_disk_dirty(&mut self) {
2492        self.mapper.clear_disk_dirty();
2493    }
2494
2495    /// Mark the inserted FDS disk read-only (`true`) or writable (`false`).
2496    pub fn set_disk_write_protected(&mut self, protected: bool) {
2497        self.mapper.set_disk_write_protected(protected);
2498    }
2499
2500    /// Commit a controller-strobe write to all controllers, resetting the
2501    /// Four Score read sequence + reloading its signature when enabled.
2502    const fn commit_controller_strobe(&mut self, value: u8) {
2503        self.controllers[0].write_strobe(value);
2504        self.controllers[1].write_strobe(value);
2505        // Forward the strobe to any attached overlay device (only the Vaus
2506        // latches on it; the Zapper ignores it). Done unconditionally — the
2507        // standard controllers above are still strobed, so detaching a device
2508        // returns to byte-identical behavior.
2509        if let Some(d) = &mut self.expansion_device[0] {
2510            d.write_strobe(value);
2511        }
2512        if let Some(d) = &mut self.expansion_device[1] {
2513            d.write_strobe(value);
2514        }
2515        if self.four_score {
2516            self.controllers34[0].write_strobe(value);
2517            self.controllers34[1].write_strobe(value);
2518            // Reset the 24-read sequence + reload the adapter signature
2519            // (port 0 = 0x08, port 1 = 0x04, shifted out LSB-first).
2520            self.four_score_idx = [0, 0];
2521            self.four_score_sig = [0x08, 0x04];
2522        }
2523    }
2524
2525    /// Read the D0 controller bit for `port` (0 = `$4016`, 1 = `$4017`),
2526    /// advancing the shift register. Four Score off → just
2527    /// `controllers[port].read()`; on → the multiplexed 24-read sequence
2528    /// (primary pad → secondary pad → signature → 1s).
2529    fn read_port(&mut self, port: usize) -> u8 {
2530        // v1.6.0 Workstream A3 (`TAStudio` lag log): any read of $4016/$4017
2531        // counts as the game polling input this frame. Output-only; gated.
2532        #[cfg(feature = "debug-hooks")]
2533        {
2534            self.controller_polled = true;
2535        }
2536        // A non-standard overlay device takes over the port entirely: it
2537        // returns its own bit-positioned byte (Vaus = bits 3/4, Zapper =
2538        // bits 3/4) instead of the standard D0 shift-register bit. The
2539        // standard controller is still strobed (in `commit_controller_strobe`)
2540        // so detaching the device restores byte-identical behavior.
2541        // A3 (v2.2.3, opt-in): serve the Zapper's light bit from the
2542        // beam-relative model. `read_at_scanline` takes `&self` and the PPU is a
2543        // different field, so these are disjoint borrows. Off by default, so the
2544        // shipped path below is byte-identical.
2545        if self.zapper_temporal_light
2546            && let Some(crate::input_device::InputDevice::Zapper(z)) = &self.expansion_device[port]
2547        {
2548            // `scanline()` is `i16` but is non-negative on every current region
2549            // (visible 0..=239, then post-render / vblank up to the pre-render
2550            // line — 261 NTSC / 311 PAL, NOT -1), so `try_from` always succeeds
2551            // and this resolves to `read_at_scanline`, which already yields
2552            // no-light for the pre-render line (`prerender - y >= HOLD` for every
2553            // visible aim). The `Err` arm is a total-conversion fallback: if a
2554            // future convention ever produced a negative scanline (a -1
2555            // pre-render), the correct answer is "no light yet" —
2556            // `read_before_visible` — rather than the row-0 fold a bare
2557            // `unwrap_or(0)` would give.
2558            return match u16::try_from(self.ppu.scanline()) {
2559                Ok(sl) => z.read_at_scanline(self.ppu.framebuffer(), sl),
2560                Err(_) => z.read_before_visible(),
2561            };
2562        }
2563        if let Some(d) = &mut self.expansion_device[port] {
2564            return d.read();
2565        }
2566        if !self.four_score || self.controllers[port].strobe {
2567            return self.controllers[port].read();
2568        }
2569        let idx = self.four_score_idx[port];
2570        let bit = if idx < 8 {
2571            self.controllers[port].read()
2572        } else if idx < 16 {
2573            self.controllers34[port].read()
2574        } else if idx < 24 {
2575            let b = self.four_score_sig[port] & 1;
2576            self.four_score_sig[port] = (self.four_score_sig[port] >> 1) | 0x80;
2577            b
2578        } else {
2579            1
2580        };
2581        if idx < 24 {
2582            self.four_score_idx[port] += 1;
2583        }
2584        bit
2585    }
2586
2587    /// Side-effect-free companion to [`Self::read_port`] (debugger peek).
2588    fn peek_port(&self, port: usize) -> u8 {
2589        // Mirror the temporal-Zapper branch in `read_port` so a debugger peek
2590        // shows the same `$4016`/`$4017` byte the CPU would receive. Without
2591        // this, with `zapper_temporal_light` on, `peek_port` fell through to the
2592        // overlay's frame-granular `peek()` and could disagree with the live
2593        // read. `peek` is non-mutating and all of `scanline()` / `framebuffer()`
2594        // / `read_at_scanline` / `read_before_visible` take `&self`, so this is a
2595        // pure read; it costs `peek_port` its `const` (try_from/match are not
2596        // const here), which nothing relied on. Off by default → byte-identical.
2597        if self.zapper_temporal_light
2598            && let Some(crate::input_device::InputDevice::Zapper(z)) = &self.expansion_device[port]
2599        {
2600            return u16::try_from(self.ppu.scanline()).map_or_else(
2601                |_| z.read_before_visible(),
2602                |sl| z.read_at_scanline(self.ppu.framebuffer(), sl),
2603            );
2604        }
2605        if let Some(d) = &self.expansion_device[port] {
2606            return d.peek();
2607        }
2608        if !self.four_score || self.controllers[port].strobe {
2609            return self.controllers[port].peek();
2610        }
2611        let idx = self.four_score_idx[port];
2612        if idx < 8 {
2613            self.controllers[port].peek()
2614        } else if idx < 16 {
2615            self.controllers34[port].peek()
2616        } else if idx < 24 {
2617            self.four_score_sig[port] & 1
2618        } else {
2619            1
2620        }
2621    }
2622
2623    /// Bus-side bookkeeping snapshot used by `bus_snapshot::encode_bus`.
2624    #[must_use]
2625    pub const fn bus_misc_state(&self) -> crate::bus_snapshot::BusMiscState {
2626        crate::bus_snapshot::BusMiscState {
2627            dma_pending: self.dma_pending,
2628            dma_cycles_owed: self.dma_cycles_owed,
2629            dma_byte: self.dma_byte,
2630            dma_idx: self.dma_idx,
2631            dma_page: self.dma_page,
2632            dma_halt_addr: self.dma_halt_addr,
2633            last_nmi_level: self.last_nmi_level,
2634            nmi_edge_latch: self.nmi_edge_latch,
2635            open_bus: self.open_bus,
2636            last_read_addr: self.last_read_addr,
2637            deferred_dma_replay_addr: self.deferred_dma_replay_addr,
2638            in_dmc_dma: self.in_dmc_dma,
2639            controller_write_pending: self.controller_write_pending,
2640            controller_write_value: self.controller_write_value,
2641            four_score: self.four_score,
2642            four_score_idx: self.four_score_idx,
2643            four_score_sig: self.four_score_sig,
2644            // W3-Stage-4 (2026-06-10): the unified-engine OAM state + the
2645            // DMC halt latch. Always present in the ferry struct (zeros when
2646            // the engine feature is off) so the BUS section layout is
2647            // identical across feature builds.
2648            dmc_halt: self.dmc_halt,
2649            uni_oam_active: self.uni_oam_active,
2650            uni_oam_halt: self.uni_oam_halt,
2651            uni_oam_aligned: self.uni_oam_aligned,
2652            uni_oam_addr: self.uni_oam_addr,
2653            ppu_clock: self.ppu_clock,
2654            dma_mc_consumed: self.dma_mc_consumed,
2655        }
2656    }
2657
2658    /// Apply a previously-snapshotted bus bookkeeping state.
2659    pub const fn set_bus_misc_state(&mut self, s: crate::bus_snapshot::BusMiscState) {
2660        self.dma_pending = s.dma_pending;
2661        self.dma_cycles_owed = s.dma_cycles_owed;
2662        self.dma_byte = s.dma_byte;
2663        self.dma_idx = s.dma_idx;
2664        self.dma_page = s.dma_page;
2665        self.dma_halt_addr = s.dma_halt_addr;
2666        self.last_nmi_level = s.last_nmi_level;
2667        self.nmi_edge_latch = s.nmi_edge_latch;
2668        self.open_bus = s.open_bus;
2669        self.last_read_addr = s.last_read_addr;
2670        self.deferred_dma_replay_addr = s.deferred_dma_replay_addr;
2671        self.in_dmc_dma = s.in_dmc_dma;
2672        self.controller_write_pending = s.controller_write_pending;
2673        self.controller_write_value = s.controller_write_value;
2674        self.four_score = s.four_score;
2675        self.four_score_idx = s.four_score_idx;
2676        self.four_score_sig = s.four_score_sig;
2677        // W3-Stage-4 (2026-06-10): the unified engine's OAM state + the DMC
2678        // halt latch are now serialized (trailing-default-zero in the BUS
2679        // section), replacing the Stage-1 clear-on-restore. Snapshots are
2680        // taken at instruction boundaries where the engine is idle, so for
2681        // every legitimately produced blob these decode to the same inactive
2682        // state the clear imposed -- but a restored blob now reproduces them
2683        // EXACTLY instead of by assumption.
2684        {
2685            self.dmc_halt = s.dmc_halt;
2686        }
2687        {
2688            self.uni_oam_active = s.uni_oam_active;
2689            self.uni_oam_halt = s.uni_oam_halt;
2690            self.uni_oam_aligned = s.uni_oam_aligned;
2691            self.uni_oam_addr = s.uni_oam_addr;
2692        }
2693        // The R1 substrate master-clock pair (see `BusMiscState::ppu_clock`):
2694        // pre-Stage-4 blobs decode these as 0, which together with the CPU
2695        // v1-blob `master_clock` upconvert keeps the pair coherent.
2696        {
2697            self.ppu_clock = s.ppu_clock;
2698            self.dma_mc_consumed = s.dma_mc_consumed;
2699        }
2700    }
2701
2702    /// Set the cumulative CPU cycle counter (used by save-state restore).
2703    pub const fn set_cycle(&mut self, cycle: u64) {
2704        self.cycle = cycle;
2705    }
2706
2707    /// Overwrite the 2 KiB CPU RAM.
2708    ///
2709    /// # Errors
2710    ///
2711    /// Returns [`SnapshotError::SectionInvalid`] if `bytes.len() != 2048`.
2712    pub fn set_ram_bytes(&mut self, bytes: &[u8]) -> Result<(), SnapshotError> {
2713        if bytes.len() != self.ram.len() {
2714            return Err(SnapshotError::SectionInvalid {
2715                tag: "BUS ".into(),
2716                reason: format!("ram length {} != {}", bytes.len(), self.ram.len()),
2717            });
2718        }
2719        self.ram.copy_from_slice(bytes);
2720        Ok(())
2721    }
2722
2723    /// Overwrite both controllers' state.
2724    pub const fn set_controllers(&mut self, controllers: [Controller; 2]) {
2725        self.controllers = controllers;
2726    }
2727
2728    /// Overwrite the Four Score players 3 & 4 (save-state restore).
2729    pub const fn set_controllers34(&mut self, controllers: [Controller; 2]) {
2730        self.controllers34 = controllers;
2731    }
2732
2733    /// Write a byte directly into CPU work RAM (`$0000-$1FFF`, mirrored every
2734    /// `$800`). Used by the frontend's raw RAM cheats (GameShark-style),
2735    /// applied caller-side *after* [`crate::Nes::run_frame`] so the core run
2736    /// loop stays pure (the determinism contract is unperturbed for the
2737    /// no-cheat path). No-op for addresses outside system RAM.
2738    pub fn poke_ram(&mut self, addr: u16, value: u8) {
2739        if addr < 0x2000 {
2740            self.ram[(addr & 0x07FF) as usize] = value;
2741        }
2742    }
2743
2744    /// Encode the entire bus + chip state into a `.rns` snapshot.
2745    ///
2746    /// Returns the bytes the caller should persist via
2747    /// `frontend::save_state` (or feed into the rewind ring).
2748    ///
2749    /// The output is bit-deterministic: same `(seed, ROM, input sequence)`
2750    /// produces identical bytes.
2751    #[must_use]
2752    pub fn snapshot(&self, rom_hash_tag: [u8; save_state::ROM_HASH_TAG_LEN]) -> Vec<u8> {
2753        let mut out = Vec::with_capacity(0x4_0000);
2754        self.snapshot_into(&mut out, rom_hash_tag);
2755        out
2756    }
2757
2758    /// v2.8.0 Phase 3 — [`Self::snapshot`] into a caller-owned buffer
2759    /// (cleared first; capacity reused across calls). The per-call
2760    /// allocation of the ~250 KiB blob matters to per-frame consumers
2761    /// (run-ahead, the netplay save-state ring, rewind).
2762    pub fn snapshot_into(
2763        &self,
2764        out: &mut Vec<u8>,
2765        rom_hash_tag: [u8; save_state::ROM_HASH_TAG_LEN],
2766    ) {
2767        self.snapshot_into_with(out, rom_hash_tag, false);
2768    }
2769
2770    /// v2.3.3 — [`Self::snapshot_into`] with the PPU encoded slim (no
2771    /// framebuffer). See `rustynes_ppu::PPU_SNAPSHOT_SLIM_FLAG`.
2772    pub fn snapshot_into_slim(
2773        &self,
2774        out: &mut Vec<u8>,
2775        rom_hash_tag: [u8; save_state::ROM_HASH_TAG_LEN],
2776    ) {
2777        self.snapshot_into_with(out, rom_hash_tag, true);
2778    }
2779
2780    fn snapshot_into_with(
2781        &self,
2782        out: &mut Vec<u8>,
2783        rom_hash_tag: [u8; save_state::ROM_HASH_TAG_LEN],
2784        slim: bool,
2785    ) {
2786        out.clear();
2787        save_state::write_header(out, rom_hash_tag);
2788
2789        // BUS section.
2790        let bus_body = crate::bus_snapshot::encode_bus(self);
2791        save_state::write_section(
2792            out,
2793            save_state::tag::BUS,
2794            crate::bus_snapshot::BUS_SECTION_VERSION,
2795            &bus_body,
2796        );
2797
2798        // CPU is owned by the surrounding `Nes` facade — but the bus is
2799        // the canonical owner of the persistable state, so the public
2800        // `snapshot` lives there. The CPU section is appended by
2801        // `Nes::snapshot` because the CPU isn't reachable from inside
2802        // the bus without violating the dependency graph. We stub
2803        // section emission here; `Nes::snapshot` will re-call this and
2804        // splice the CPU bytes in.
2805
2806        // PPU section.
2807        let ppu_body = if slim {
2808            self.ppu.snapshot_slim()
2809        } else {
2810            self.ppu.snapshot()
2811        };
2812        save_state::write_section(
2813            out,
2814            save_state::tag::PPU,
2815            rustynes_ppu::PPU_SNAPSHOT_VERSION,
2816            &ppu_body,
2817        );
2818
2819        // APU section.
2820        let apu_body = self.apu.snapshot();
2821        save_state::write_section(
2822            out,
2823            save_state::tag::APU,
2824            rustynes_apu::APU_SNAPSHOT_VERSION,
2825            &apu_body,
2826        );
2827
2828        // MAP section (mapper-resident state).
2829        let map_body = self.mapper.save_state();
2830        save_state::write_section(out, save_state::tag::MAP, 1, &map_body);
2831    }
2832
2833    /// Apply a previously snapshotted blob *to the bus and chips*. The CPU
2834    /// is restored separately by [`crate::Nes::restore`].
2835    ///
2836    /// # Errors
2837    ///
2838    /// Returns [`SnapshotError`] for unknown sections, version mismatches,
2839    /// or malformed bodies.
2840    pub fn restore(&mut self, data: &[u8]) -> Result<(), SnapshotError> {
2841        let (header, body_off) = save_state::parse_header(data)?;
2842        let _ = header; // currently informational
2843        let mut saw_bus = false;
2844        let mut saw_ppu = false;
2845        let mut saw_apu = false;
2846        let mut saw_map = false;
2847        for s in save_state::SectionIter::new(&data[body_off..]) {
2848            let s = s?;
2849            match s.tag {
2850                save_state::tag::BUS => {
2851                    if s.version != crate::bus_snapshot::BUS_SECTION_VERSION {
2852                        return Err(SnapshotError::VersionMismatch {
2853                            tag: save_state::tag_string(s.tag),
2854                            file_version: s.version,
2855                            chip_supports: crate::bus_snapshot::BUS_SECTION_VERSION,
2856                        });
2857                    }
2858                    crate::bus_snapshot::decode_bus(self, s.body)?;
2859                    saw_bus = true;
2860                }
2861                save_state::tag::PPU => {
2862                    if s.version != rustynes_ppu::PPU_SNAPSHOT_VERSION {
2863                        return Err(SnapshotError::VersionMismatch {
2864                            tag: save_state::tag_string(s.tag),
2865                            file_version: s.version,
2866                            chip_supports: rustynes_ppu::PPU_SNAPSHOT_VERSION,
2867                        });
2868                    }
2869                    self.ppu.restore(s.body).map_err(|e: PpuSnapshotError| {
2870                        SnapshotError::SectionInvalid {
2871                            tag: save_state::tag_string(s.tag),
2872                            reason: format!("{e}"),
2873                        }
2874                    })?;
2875                    saw_ppu = true;
2876                }
2877                save_state::tag::APU => {
2878                    if s.version != rustynes_apu::APU_SNAPSHOT_VERSION {
2879                        return Err(SnapshotError::VersionMismatch {
2880                            tag: save_state::tag_string(s.tag),
2881                            file_version: s.version,
2882                            chip_supports: rustynes_apu::APU_SNAPSHOT_VERSION,
2883                        });
2884                    }
2885                    self.apu.restore(s.body).map_err(|e: ApuSnapshotError| {
2886                        SnapshotError::SectionInvalid {
2887                            tag: save_state::tag_string(s.tag),
2888                            reason: format!("{e}"),
2889                        }
2890                    })?;
2891                    saw_apu = true;
2892                }
2893                save_state::tag::MAP => {
2894                    self.mapper.load_state(s.body).map_err(|e: MapperError| {
2895                        SnapshotError::SectionInvalid {
2896                            tag: save_state::tag_string(s.tag),
2897                            reason: format!("{e}"),
2898                        }
2899                    })?;
2900                    saw_map = true;
2901                }
2902                save_state::tag::CPU => {
2903                    // Skipped — restored by the surrounding `Nes` facade.
2904                }
2905                _other => {
2906                    // Unknown tags are forward-compatible: skip silently
2907                    // so cross-version files load when they include
2908                    // sections this build doesn't know about (e.g. a
2909                    // future "DBG " debugger section).
2910                }
2911            }
2912        }
2913        // BUS is mandatory; chip sections are mandatory too because
2914        // they round-trip the entire emulator state.
2915        if !saw_bus {
2916            return Err(SnapshotError::MissingSection("BUS ".into()));
2917        }
2918        if !saw_ppu {
2919            return Err(SnapshotError::MissingSection("PPU ".into()));
2920        }
2921        if !saw_apu {
2922            return Err(SnapshotError::MissingSection("APU ".into()));
2923        }
2924        if !saw_map {
2925            return Err(SnapshotError::MissingSection("MAP ".into()));
2926        }
2927        // RW-0 fix: under R1, `dmc_driven_externally` is NOT serialized (it is
2928        // build configuration, not emulated state), so after `apu.restore` it
2929        // reverts to the `Apu::new` default (`false`), which STOPS `put_cycle`
2930        // toggling and disables the interleaved DMC DMA service — a latent R1
2931        // save-state correctness bug. Re-apply the R1 drive here exactly as
2932        // `new`/`reset`/`power_cycle` do.
2933        //
2934        // W3-Stage-4 (2026-06-10, the RW-3 follow-through): the APU snapshot
2935        // now DOES carry the exact `put_cycle` / `parity_seed` phase in its
2936        // Stage-4 tail, so re-seed the boot alignment ONLY for pre-Stage-4
2937        // blobs that lack the tail (`snapshot_restored_parity` is false) —
2938        // otherwise the boot seed would overwrite the restored mid-state
2939        // parity that the counter-collapse end-flip reads at the next access
2940        // point.
2941        {
2942            self.apu.set_dmc_driven_externally(true);
2943            if !self.apu.snapshot_restored_parity() {
2944                self.apu.seed_apu_alignment(0);
2945            }
2946        }
2947        Ok(())
2948    }
2949
2950    const fn ppu_region(&self) -> PpuRegion {
2951        match self.cart.region {
2952            rustynes_mappers::Region::Pal => PpuRegion::Pal,
2953            rustynes_mappers::Region::Dendy => PpuRegion::Dendy,
2954            _ => PpuRegion::Ntsc,
2955        }
2956    }
2957
2958    const fn apu_region(&self) -> ApuRegion {
2959        match self.cart.region {
2960            rustynes_mappers::Region::Pal => ApuRegion::Pal,
2961            rustynes_mappers::Region::Dendy => ApuRegion::Dendy,
2962            _ => ApuRegion::Ntsc,
2963        }
2964    }
2965
2966    /// Drive the PPU forward 3 dots and account for one CPU cycle of
2967    /// bookkeeping (mapper-cycle hook, DMA progress, NMI edge sample,
2968    /// APU tick).
2969    #[allow(clippy::too_many_lines)] // Session-21 added per-cycle DMC + bus-access snapshots; splitting the trace push into a helper would force the bus to recompute `trace_*_pre_tick` values across function boundaries.
2970    pub(crate) fn tick_one_cpu_cycle(&mut self) {
2971        // Tick PPU 3 dots in NTSC.  PAL would be 3.2 (5 dots per 16 PPU dots);
2972        // we approximate as 3 for now and gate region accuracy behind a
2973        // future Phase 2 follow-up.
2974        //
2975        // Sample the PPU /NMI line state *between every dot* so a glitched
2976        // edge that goes low->high then back to low within a single CPU
2977        // cycle (e.g. PPUCTRL.7 set during pre-render dot 0, then VBL
2978        // cleared at dot 1 within the same CPU cycle) is still latched.
2979        //
2980        // When the `irq-timing-trace` cargo feature is enabled, capture
2981        // per-cycle (cpu_cycle, ppu_scanline, ppu_dot, a12_events, IRQ
2982        // lines sampled at TWO points within the cycle, NMI line) into
2983        // the bus's trace buffer.
2984        //
2985        // Phase A of the C1 plan (`docs/adr/0002-irq-timing-coordination.md`)
2986        // takes TWO IRQ snapshots per CPU cycle so the M2-low → M2-high
2987        // asymmetry the coordinated change is designed to model is
2988        // observable in the trace data:
2989        //
2990        //   * M2-low snapshot:  taken AFTER PPU sub-dot 0 has ticked.
2991        //     This catches any A12 transition / APU IRQ assertion that
2992        //     happened on the cycle's first PPU dot, but before sub-dots
2993        //     1 and 2 have run.
2994        //   * M2-high snapshot: taken AFTER PPU sub-dot 2 has ticked,
2995        //     i.e. at the end-of-3-PPU-dots boundary, BEFORE
2996        //     `notify_cpu_cycle` / `tick_with_external` run.  This is
2997        //     the historical query point the pre-Phase-B2
2998        //     `Bus::poll_irq` impl used when called from
2999        //     `Cpu::idle_tick` after `bus.on_cpu_cycle()` returned.
3000        //
3001        // The conventional names map to silicon's φ1 / φ2 halves of the
3002        // 6502 cycle.  The exact sub-dot placement is conventional, not
3003        // canonical — what matters is that the bus records IRQ state at
3004        // TWO distinct points within the cycle so downstream phases can
3005        // diff them.
3006        //
3007        // Phase B2 of the C1 IRQ-timing rework: the M2-low and M2-high
3008        // snapshots are now stored on `self` unconditionally (not gated
3009        // on the `irq-timing-trace` feature).  The trace fixture's
3010        // `_at_low` / `_at_high` columns read from these snapshots
3011        // rather than re-querying the mapper / APU, removing the
3012        // duplicate `mapper.irq_pending()` call that Phase A introduced
3013        // inside the cycle.  The production `Bus::poll_irq` /
3014        // `Bus::poll_irq_at_phase` paths on `LockstepBus` also read
3015        // from these snapshots — see the `impl Bus for LockstepBus`
3016        // block below.
3017        // Session-24 / Phase 3 (Controller Strobing): commit any
3018        // pending controller-strobe write at the START of this CPU
3019        // cycle (M2-low boundary).  Mirrors Mesen2's
3020        // `NesConsole::ProcessCpuClock` → `NesControlManager::ProcessWrites`
3021        // call site (`Core/NES/NesConsole.cpp` line 72).  See
3022        // `docs/audit/session-24-phase3-controller-strobing-2026-05-23.md`.
3023        if self.controller_write_pending > 0 {
3024            self.controller_write_pending -= 1;
3025            if self.controller_write_pending == 0 {
3026                let value = self.controller_write_value;
3027                // The strobe line is shared between both controllers.
3028                self.commit_controller_strobe(value);
3029            }
3030        }
3031        #[cfg(feature = "irq-timing-trace")]
3032        let (trace_scanline_start, trace_dot_start, trace_frame_start) =
3033            (self.ppu.scanline(), self.ppu.dot(), self.ppu.frame());
3034        // Session-21 (Sprint 1 iteration 2 prereq): snapshot the DMC
3035        // scheduler's "pre-tick" state (mirrors `_at_low` for the IRQ
3036        // columns).  These read BEFORE `apu.tick_with_external` runs at
3037        // the bottom of this method.
3038        #[cfg(feature = "irq-timing-trace")]
3039        let trace_dmc_dma_pending_pre = self.apu.dmc_dma_pending();
3040        // M2-phase tracking (Phase B1 of the C1 IRQ-timing rework):
3041        // each CPU cycle begins in `M2Phase::Low`, transitions to
3042        // `M2Phase::High` after sub-dot 1 has ticked (the M2-rising
3043        // boundary), and resets to `Low` at end-of-cycle.
3044        self.m2_phase = M2Phase::Low;
3045        #[cfg(not(feature = "irq-timing-trace"))]
3046        for sub_dot in 0..3u8 {
3047            let mut adapter = PpuBusAdapter {
3048                mapper: self.mapper.as_mut(),
3049                nt_override: self.nt_mirroring_override,
3050                sub_dot,
3051            };
3052            self.ppu.tick(&mut adapter);
3053            self.sample_nmi_edge();
3054            if sub_dot == 0 {
3055                // M2-low IRQ snapshot.
3056                self.irq_snapshot_mapper_at_low = self.mapper.irq_pending();
3057                self.irq_snapshot_apu_at_low = self.apu.irq_line();
3058            }
3059            if sub_dot == 1 {
3060                self.m2_phase = M2Phase::High;
3061            }
3062        }
3063        #[cfg(feature = "irq-timing-trace")]
3064        for sub_dot in 0..3u8 {
3065            let mut adapter = PpuBusAdapter {
3066                mapper: self.mapper.as_mut(),
3067                nt_override: self.nt_mirroring_override,
3068                sub_dot,
3069                trace_a12_latest: if self.irq_trace.is_some() {
3070                    Some(&mut self.trace_a12_latest)
3071                } else {
3072                    None
3073                },
3074            };
3075            self.ppu.tick(&mut adapter);
3076            self.sample_nmi_edge();
3077            // The `is_some()` guard is load-bearing beyond the borrow below:
3078            // it keeps `take()` -- which CLEARS `trace_a12_latest` -- from
3079            // running when tracing is off.  Short-circuit evaluation in the
3080            // let-chain preserves that exactly.
3081            if self.irq_trace.is_some()
3082                && let Some(level) = self.trace_a12_latest.take()
3083            {
3084                if let Some(t) = self.irq_trace.as_mut() {
3085                    t.notify_a12_count = t.notify_a12_count.saturating_add(1);
3086                }
3087                // The PPU already filters to transitions only; every
3088                // `notify_a12` call IS a level change.  Record it.
3089                self.trace_a12_scratch.push(A12Event { sub_dot, level });
3090                self.trace_last_a12 = level;
3091            }
3092            if sub_dot == 0 {
3093                // M2-low IRQ snapshot: taken AFTER sub-dot 0 has ticked
3094                // so it reflects the dot's mapper-side effects (e.g. an
3095                // A12 rise on sub-dot 0 that just clocked the MMC3 IRQ
3096                // counter).  Sub-dots 1 and 2 have not yet run.
3097                self.irq_snapshot_mapper_at_low = self.mapper.irq_pending();
3098                self.irq_snapshot_apu_at_low = self.apu.irq_line();
3099            }
3100            if sub_dot == 1 {
3101                self.m2_phase = M2Phase::High;
3102            }
3103        }
3104
3105        // Phase-A-compatible end-of-3-PPU-dots snapshot — taken BEFORE
3106        // `notify_cpu_cycle` / `tick_with_external` advance the mapper
3107        // and APU.  Only used by the trace fixture's `_at_high` column
3108        // so the Phase A baseline CSV files stay byte-identical across
3109        // Phases B2+.  The production `Bus::poll_irq{,_at_phase}` path
3110        // reads from `irq_snapshot_*_at_high` below, taken AFTER those
3111        // advance, so the CPU's IRQ sample point is unchanged.
3112        #[cfg(feature = "irq-timing-trace")]
3113        let trace_mapper_at_high_pre_tick = self.mapper.irq_pending();
3114        #[cfg(feature = "irq-timing-trace")]
3115        let trace_apu_at_high_pre_tick = self.apu.irq_line();
3116
3117        // End-of-cycle: the bus advances to the next CPU cycle, which
3118        // (re)starts in `M2Phase::Low`.  Reset BEFORE the cycle counter
3119        // increment so any future read of `current_m2_phase()` from
3120        // inside `notify_cpu_cycle` / `tick_with_external` sees the new
3121        // cycle's phase rather than the previous cycle's tail.
3122        self.m2_phase = M2Phase::Low;
3123        self.cycle = self.cycle.wrapping_add(1);
3124        self.ppu.on_cpu_cycle();
3125        self.mapper.notify_cpu_cycle();
3126        // Sample the mapper's audio extension AFTER notify_cpu_cycle has
3127        // advanced its oscillators. `Mapper::mix_audio` returns i32 (widened
3128        // from i16 in v2.2.3 so the Sunsoft 5B's ~3.6x full-volume level is
3129        // representable); we scale to approximately the same [-0.5, 0.5] range
3130        // as the APU mixer's own output. Mappers without on-cart audio return
3131        // 0, which scales to 0.0 -- a no-op for the standard cartridges.
3132        //
3133        // `as f32` rather than `f32::from`: there is no lossless From<i32> for
3134        // f32. The cast is exact for every value any board actually produces
3135        // (|sample| well under 2^24, where f32 is still integer-exact); the
3136        // widening exists to raise a ~32k ceiling to ~16.7M, not to use it.
3137        #[allow(clippy::cast_precision_loss)]
3138        let mapper_sample = self.mapper.mix_audio() as f32 / 65536.0;
3139        self.apu.tick_with_external(mapper_sample);
3140        // Fan-out the APU frame-counter events to any on-cart audio
3141        // extension that shares the 2A03 frame-counter cadence (MMC5).
3142        // Default no-op for all other mappers.
3143        let ev = self.apu.last_frame_events();
3144        self.mapper.notify_frame_event(MapperFrameEvents {
3145            quarter: ev.quarter,
3146            half: ev.half,
3147        });
3148
3149        // M2-high IRQ snapshot: at the VERY END of `tick_one_cpu_cycle`,
3150        // AFTER `notify_cpu_cycle` / `tick_with_external` /
3151        // `notify_frame_event` have run.  This matches the historical
3152        // `mapper.irq_pending() || apu.irq_line()` query point that
3153        // `Cpu::idle_tick` saw when it called `bus.poll_irq()` after
3154        // `bus.on_cpu_cycle()` returned — so the production
3155        // `Bus::poll_irq` / `poll_irq_at_phase(M2Phase::High)` paths
3156        // stay semantically identical to the pre-Phase-B2 direct query
3157        // of `mapper.irq_pending() || apu.irq_line()`.
3158        self.irq_snapshot_mapper_at_high = self.mapper.irq_pending();
3159        self.irq_snapshot_apu_at_high = self.apu.irq_line();
3160
3161        #[cfg(feature = "irq-timing-trace")]
3162        if self.irq_trace.is_some() {
3163            let events = core::mem::take(&mut self.trace_a12_scratch);
3164            let events_len = events.len();
3165            // Session-21: snapshot the DMC scheduler "post-tick" state
3166            // and consume the per-cycle bus-access tracker.  These are
3167            // taken AFTER `apu.tick_with_external` has run for this
3168            // cycle, so they reflect the end-of-cycle scheduler shape
3169            // that the next CPU cycle's bus access will observe.
3170            let bus_access = core::mem::replace(&mut self.trace_bus_access, BusAccess::Idle);
3171            let bus_addr = core::mem::take(&mut self.trace_bus_addr);
3172            let bus_data = core::mem::take(&mut self.trace_bus_data);
3173            let rec = CycleRecord {
3174                // `cpu_cycle` here refers to the cycle we JUST ticked.
3175                // `self.cycle` was incremented above, so subtract 1.
3176                cpu_cycle: self.cycle.wrapping_sub(1),
3177                pc: self.trace_last_pc,
3178                ppu_scanline: trace_scanline_start,
3179                ppu_dot: trace_dot_start,
3180                ppu_frame: trace_frame_start,
3181                irq_pending_mapper_at_low: self.irq_snapshot_mapper_at_low,
3182                irq_pending_apu_at_low: self.irq_snapshot_apu_at_low,
3183                // Trace's `_at_high` columns retain the Phase A
3184                // pre-tick_with_external semantics so the committed
3185                // baseline CSVs in
3186                // `crates/rustynes-test-harness/golden/irq_trace/` stay
3187                // byte-identical.  Production `poll_irq` reads from
3188                // the post-tick `irq_snapshot_*_at_high` fields above
3189                // instead.
3190                irq_pending_mapper_at_high: trace_mapper_at_high_pre_tick,
3191                irq_pending_apu_at_high: trace_apu_at_high_pre_tick,
3192                nmi_line: self.ppu.nmi_line(),
3193                a12_events: events,
3194                // --- Session-21 DMC + bus-access columns ---
3195                dmc_dma_pending_pre: trace_dmc_dma_pending_pre,
3196                dmc_dma_pending_post: self.apu.dmc_dma_pending(),
3197                dmc_dma_short_post: self.apu.dmc_dma_short(),
3198                dmc_abort_pending_post: self.apu.dmc_abort_pending(),
3199                dmc_abort_delay_post: self.apu.dmc_abort_delay(),
3200                dmc_dma_cooldown_post: self.apu.dmc_dma_cooldown(),
3201                dmc_dma_delay_post: self.apu.dmc_dma_delay(),
3202                apu_phase_post: self.apu.apu_phase(),
3203                in_dmc_dma: self.in_dmc_dma,
3204                dma_cycles_owed: self.dma_cycles_owed,
3205                bus_access,
3206                bus_addr,
3207                bus_data,
3208                put_cycle_post: self.apu.put_cycle(),
3209                dmc_timer_post: self.apu.dmc_timer(),
3210                dmc_bits_remaining_post: self.apu.dmc_bits_remaining(),
3211                dmc_silence_post: self.apu.dmc_silence(),
3212                dmc_buffer_full_post: self.apu.dmc_buffer_full(),
3213            };
3214            if let Some(t) = self.irq_trace.as_mut() {
3215                if events_len > 0 {
3216                    t.records_with_a12_count = t.records_with_a12_count.saturating_add(1);
3217                }
3218                t.push(rec);
3219            }
3220        }
3221        // v2.0 R1 DMA-coherence (Phase 3): under `mc-r1-substrate` this fn is
3222        // reached ONLY from the bus-side DMA path — the normal R1 cycle runs
3223        // the PPU via `run_ppu_to` + does its per-cycle work in `cpu_clock`,
3224        // which does NOT call this. Each DMA cycle ticked the real PPU by 3
3225        // dots without advancing `master_clock`/`ppu_clock`. Bump `ppu_clock`
3226        // so the next `run_ppu_to` does not RE-tick those dots, and
3227        // `dma_mc_consumed` so `Cpu::end_cycle` folds the DMA span into
3228        // `master_clock` — keeping the CPU<->PPU phase coherent across DMA
3229        // (the v2.0-R1 regression this prevents). Mirrors `dma_tick_one_cycle`
3230        // on `refactor/v2.0-master-clock`.
3231        {
3232            let (cpu_div, ppu_div) = self.region_dividers();
3233            // The PPU was physically ticked 3 dots by this DMA cycle, so
3234            // `ppu_clock` advances by exactly `3 * ppu_divider` mc — keeping the
3235            // boundary check in `run_ppu_to` from re-ticking those dots.
3236            self.ppu_clock = self.ppu_clock.wrapping_add(u64::from(ppu_div) * 3);
3237            // `master_clock` (via `dma_mc_consumed`) advances by the region's
3238            // true CPU-cycle span (`cpu_divider`). On NTSC/Dendy this equals
3239            // `3 * ppu_divider` (12/15), so the path is byte-identical; on PAL
3240            // (16 vs 15) the 1-mc/cycle deficit accumulates and the next
3241            // `run_ppu_to` ticks the catch-up dot, yielding the correct 3.2:1
3242            // average across the DMA span.
3243            self.dma_mc_consumed = self.dma_mc_consumed.wrapping_add(u64::from(cpu_div));
3244        }
3245    }
3246
3247    /// Capture the PPU /NMI line transition (false → true) into the edge
3248    /// latch consumed by [`Bus::poll_nmi`].  Idempotent within a "still
3249    /// asserted" window: only the rising edge latches.
3250    const fn sample_nmi_edge(&mut self) {
3251        let level = self.ppu.nmi_line();
3252        if level && !self.last_nmi_level {
3253            self.nmi_edge_latch = true;
3254        }
3255        self.last_nmi_level = level;
3256    }
3257
3258    /// OAM-DMA source fetch (Session-26 / Sprint 2 iter 4).
3259    ///
3260    /// The 2A03 has three internal address buses (6502, OAM DMA, DMC
3261    /// DMA), but only the 6502 bus asserts the APU/controller chip
3262    /// select. During OAM DMA the 6502 is halted, so its bus is parked
3263    /// at `self.dma_halt_addr` (last CPU read address). The OAM DMA
3264    /// engine drives the EXTERNAL address bus with `src_addr`, but the
3265    /// APU registers' `CHIP_SELECT` is gated on `6502_addr ∈ $4000-$401F`,
3266    /// not on the DMA's source page.
3267    ///
3268    /// Consequence: if the 6502 bus is parked outside `$4000-$401F`
3269    /// and the OAM DMA reads a source address inside that range, the
3270    /// APU/controllers are silent — the read returns the open-bus
3271    /// latch and triggers no register side-effects (no `apu.read_status()`,
3272    /// no controller shift, etc.). The DMC DMA helper already implements
3273    /// the equivalent gate (`dmc_dma_read` lines 1329-1356).
3274    ///
3275    /// `AccuracyCoin` `APU Register Activation` Test 4 (asm:8091-8109)
3276    /// exercises this: `LDA #$40; STA $4014` runs an OAM DMA from page
3277    /// `$40` while CPU code lives in PRG ROM. Without this gate, the
3278    /// DMA's `$4015` read clears the frame-counter IRQ flag, failing
3279    /// the subsequent `LDA $4015 / AND #$40 / BEQ FAIL` check.
3280    ///
3281    /// The Test 5/6 conflict-path semantics (where the 6502 bus IS in
3282    /// `$4000-$401F` because the test uses `JSR $3FFE` + the BRK trick)
3283    /// need additional modelling — deferred. Two components, established by
3284    /// the 2026-06-05 investigation (`docs/audit/`):
3285    /// 1. **Active-window mirror decode.** When the 6502 bus is parked in
3286    ///    `$4000-$401F`, an OAM DMA reading page `$40` reads the readable
3287    ///    registers (`$4015`/`$4016`/`$4017`) AND their `$20`-byte mirrors:
3288    ///    the 2A03 decodes on the low 5 address bits, so `$4020-$40FF` mirror
3289    ///    `$4000-$401F` (`$4035` -> `$4015`) with side-effects (`$4015` clears
3290    ///    the frame IRQ flag; `$4016`/`$4017` advance the controller shift).
3291    ///    The fix is to mask `src` to `0x4000 | (src & 0x1F)` here when active.
3292    /// 2. **Upstream coupling (the actual blocker).** This is NOT independently
3293    ///    reachable: Test 6's OAM-copy is all-zeros in this emulator because the
3294    ///    page-`$40` register-read OAM DMA does not fire as the test intends — it
3295    ///    depends on Test 5's `[DMC DMA! Overwrite data bus with $40]` trick
3296    ///    landing cycle-exactly so `STA $4014` reads `$40` and the 6502 bus is
3297    ///    parked in `$40xx` during the DMA. That is the deferred DMC-DMA-timing /
3298    ///    data-bus axis. So component 1 is correct hardware behavior but inert
3299    ///    until that axis lands — do NOT add it speculatively (it touches the
3300    ///    default build and cannot be verified against the test in isolation).
3301    fn raw_oam_dma_read(&mut self, src_addr: u16) -> u8 {
3302        if (self.dma_halt_addr & 0xFFE0) != 0x4000 && (src_addr & 0xFFE0) == 0x4000 {
3303            // APU/controllers inactive: return the floating-bus latch
3304            // without firing any register side-effects. The latch
3305            // itself is NOT updated — DMA reads of the inactive
3306            // register window don't drive the external data bus
3307            // (the chip is silent).
3308            return self.open_bus;
3309        }
3310        // W3-Stage-4 (`mc-r1-oam-dma-reg-window`): the ACTIVE-window arm —
3311        // the 6502 bus is parked in `$4000-$401F`, so the APU/controller
3312        // chip select is asserted for EVERY OAM-DMA source read and the
3313        // readable registers decode at `$4000 | (src & $1F)` (the `$20`-byte
3314        // mirrors AccuracyCoin `APU Register Activation` Tests 5-7 bracket).
3315        if (self.dma_halt_addr & 0xFFE0) == 0x4000 {
3316            return self.oam_dma_read_reg_active(src_addr);
3317        }
3318        self.raw_cpu_read(src_addr)
3319    }
3320
3321    /// W3-Stage-4 (`mc-r1-oam-dma-reg-window`): one OAM-DMA source read with
3322    /// the 2A03 register window ACTIVE (the halted 6502 address bus is parked
3323    /// in `$4000-$401F`, e.g. the `AccuracyCoin` `APU Register Activation`
3324    /// Test 5/7 `JSR $3FFE` + BRK choreography parks it at `$4001`).
3325    ///
3326    /// Direct port of the `TriCNES` `Fetch` addressBus-window block
3327    /// (`Emulator.cs:9252-9311`):
3328    ///
3329    /// * The normal external decode of `src_addr` runs first (RAM / PPU /
3330    ///   cartridge / floating), tracking whether the region DRIVES the data
3331    ///   pins (`dataPinsAreNotFloating`).
3332    /// * `Reg == $15` (`$4015` mirror): returns the APU status on the
3333    ///   INTERNAL bus — the frame-IRQ flag is cleared (the side effect Test 4
3334    ///   brackets from the inactive side), bit 5 comes from the internal-bus
3335    ///   latch (Test 7's `$24` = triangle + bit 5 of the previous page-2
3336    ///   fetch), and the data bus / open-bus latch is NOT driven ("reading
3337    ///   from `$4015` can not affect the databus"). The status value still
3338    ///   reaches OAM because the DMA PUT half writes (and drives the bus
3339    ///   with) the byte — see [`Self::oam_dma_put`].
3340    /// * `Reg == $16`/`$17` (`$4016`/`$4017` mirrors): the controller shift
3341    ///   register is clocked; the value is `bit | (open_bus & $E0)` when the
3342    ///   source region floats (Test 5's page-`$50` chain: `$41`, `$40`, then
3343    ///   `$01`/`$00` after the `$4015` value decays bit 6 off the bus), but
3344    ///   when the source DRIVES the pins the external byte wins the bus
3345    ///   conflict and the controller bits are invisible (Test 7's page-`$02`
3346    ///   variant — "it does not appear to have read the controllers...
3347    ///   but they are still getting clocked").
3348    /// * Everything else: the external fetch value (floating sources return
3349    ///   the open-bus latch untouched).
3350    ///
3351    /// The end of the Test-5 chain leaves `$00` on the bus, so the resumed
3352    /// opcode fetch at `$4001` (open bus) executes BRK — the value path is
3353    /// load-bearing for the test's own control flow: any divergence here is
3354    /// what wedged the Stage-3 attempt (runaway execution instead of BRK).
3355    fn oam_dma_read_reg_active(&mut self, src_addr: u16) -> u8 {
3356        // Does the external decode of `src_addr` drive the data pins?
3357        // (TriCNES `dataPinsAreNotFloating` after the normal decode.)
3358        let drives = match src_addr {
3359            // RAM and the PPU registers always drive (write-only PPU regs
3360            // return the PPU-bus latch — still driven).
3361            0x0000..=0x3FFF => true,
3362            // The `$4000-$401F` window itself: the APU drives the INTERNAL
3363            // bus only; the external pins float. (The register overlay
3364            // below is the single decode — skip the external fetch so the
3365            // readable registers don't double-fire.)
3366            0x4000..=0x401F => false,
3367            // Cartridge space: mapper-dependent.
3368            _ => !self.mapper.cpu_read_unmapped(src_addr),
3369        };
3370        let external = if (src_addr & 0xFFE0) == 0x4000 {
3371            self.last_read_addr = src_addr;
3372            self.open_bus
3373        } else {
3374            // Normal external fetch (side effects included — a PPU-register
3375            // source behaves exactly as TriCNES's normal decode does).
3376            // Floating sources early-return the open-bus latch untouched.
3377            self.raw_cpu_read(src_addr)
3378        };
3379        match src_addr & 0x1F {
3380            0x15 => {
3381                // `$4015` mirror: internal-bus read, external bus untouched.
3382                // Mirrors the normal-CPU `$4015` composition in
3383                // `raw_cpu_read` (status bits + internal-bus bit 5).
3384                let status = self.apu.read_status();
3385                (status & 0xDF) | (self.internal_data_bus & 0x20)
3386            }
3387            reg @ (0x16 | 0x17) => {
3388                let port = usize::from(reg - 0x16);
3389                let bit = self.read_port(port);
3390                if drives {
3391                    // Bus conflict: the externally-driven byte wins; the
3392                    // controller was still clocked (`read_port` above).
3393                    external
3394                } else {
3395                    let v = (self.open_bus & 0xE0) | bit;
3396                    self.open_bus = v;
3397                    v
3398                }
3399            }
3400            _ => external,
3401        }
3402    }
3403
3404    /// OAM-DMA PUT half: write the latched byte to OAM.
3405    ///
3406    /// W3-Stage-4 (`mc-r1-oam-dma-reg-window`): when the halted 6502 bus is
3407    /// parked in `$4000-$401F`, the put (a `$2004` write) DRIVES the external
3408    /// data bus with the byte — `TriCNES` `OAMDMA_Put` ->
3409    /// `Store(OAM_InternalBus, 0x2004)`, where every `Store` puts the value
3410    /// on `dataBus`. This is how the `$4015`-mirror value (which cannot drive
3411    /// the bus on its read) reaches the open-bus latch for the NEXT mirror
3412    /// read's `& $E0` merge, and how the Test-5 chain decays to `$00` so the
3413    /// resumed `$4001` fetch executes BRK. On real silicon every OAM put
3414    /// drives the bus; the model is deliberately scoped to the parked-window
3415    /// case so all other OAM DMAs stay byte-identical to the floor.
3416    fn oam_dma_put(&mut self) {
3417        self.ppu.oam_dma_write(self.dma_byte);
3418        if (self.dma_halt_addr & 0xFFE0) == 0x4000 {
3419            self.open_bus = self.dma_byte;
3420        }
3421    }
3422
3423    /// Session-21: set the bus-access tracker for an upcoming DMA cycle.
3424    /// `tick_one_cpu_cycle` consumes this when it pushes the record.
3425    /// No-op (no field even exists) when the trace feature is disabled.
3426    #[cfg(feature = "irq-timing-trace")]
3427    const fn set_trace_dma_access(&mut self, access: BusAccess, addr: u16, data: u8) {
3428        self.trace_bus_access = access;
3429        self.trace_bus_addr = addr;
3430        self.trace_bus_data = data;
3431    }
3432
3433    const fn capture_deferred_dma_replay(&mut self) {
3434        self.deferred_dma_replay_addr = match self.open_bus {
3435            0x02 => 0x2002,
3436            0x07 => 0x2007,
3437            0x15 => 0x4015,
3438            0x16 => 0x4016,
3439            0x17 => 0x4017,
3440            _ => 0,
3441        };
3442    }
3443
3444    /// Re-execute the side-effect of the most recent CPU read for the
3445    /// 2A03 DMC-DMA readout bug. Replays side effects of reads from
3446    /// `$2002`, `$2007`, `$4015`, `$4016` and `$4017`. Per `AccuracyCoin`
3447    /// "APU Registers and DMA tests" — sub-tests check that the DMC
3448    /// DMA halt cycles re-trigger the cached read's side effects on
3449    /// real silicon.
3450    fn replay_dma_noop_read(&mut self, addr: u16) {
3451        if matches!(self.apu_region(), ApuRegion::Pal) {
3452            return;
3453        }
3454        match addr {
3455            0x2002 => {
3456                let mut adapter = PpuBusAdapter {
3457                    mapper: self.mapper.as_mut(),
3458                    nt_override: self.nt_mirroring_override,
3459                    sub_dot: 2,
3460                    #[cfg(feature = "irq-timing-trace")]
3461                    trace_a12_latest: None,
3462                };
3463                let _ = self.ppu.cpu_read_register(2, &mut adapter);
3464            }
3465            0x2007 => {
3466                let mut adapter = PpuBusAdapter {
3467                    mapper: self.mapper.as_mut(),
3468                    nt_override: self.nt_mirroring_override,
3469                    // CPU register replay (e.g. $2007 read-bug): treated as
3470                    // M2-high (sub_dot 2) since the 6502 drives its bus
3471                    // during φ2.
3472                    sub_dot: 2,
3473                    #[cfg(feature = "irq-timing-trace")]
3474                    trace_a12_latest: None,
3475                };
3476                let _ = self.ppu.cpu_read_register(7, &mut adapter);
3477            }
3478            0x4015 => {
3479                let _ = self.apu.read_status();
3480                self.apu.clear_frame_irq_immediate_for_dma();
3481            }
3482            0x4016 => {
3483                let _ = self.controllers[0].read();
3484            }
3485            0x4017 => {
3486                let _ = self.controllers[1].read();
3487            }
3488            _ => {}
3489        }
3490    }
3491
3492    /// Read the DMC sample byte and model the 2A03 register-conflict path
3493    /// where 6502 core address bits 15..=5 remain from the halted CPU read
3494    /// while DMA supplies address bits 4..=0.
3495    fn dmc_dma_read(&mut self, addr: u16, halted_addr: u16) -> u8 {
3496        let sample = self.raw_cpu_read(addr);
3497        if matches!(self.apu_region(), ApuRegion::Pal) || (halted_addr & 0xFFE0) != 0x4000 {
3498            return sample;
3499        }
3500
3501        let conflict_addr = 0x4000 | (addr & 0x001F);
3502        match conflict_addr {
3503            0x4015 => {
3504                let _ = self.apu.read_status();
3505                sample
3506            }
3507            0x4016 => {
3508                // Keep the DMC-conflict $4016 composition consistent with the
3509                // normal controller read (line ~3890): D2 carries the Famicom
3510                // built-in microphone. Default-off (mic released) leaves `mic`
3511                // = 0, so the returned byte is byte-identical to prior releases.
3512                let mic = u8::from(self.famicom_mic) << 2;
3513                let v = (sample & 0xE0) | self.controllers[0].read() | mic;
3514                self.open_bus = v;
3515                v
3516            }
3517            0x4017 => {
3518                let v = (sample & 0xE0) | self.controllers[1].read();
3519                self.open_bus = v;
3520                v
3521            }
3522            _ => sample,
3523        }
3524    }
3525
3526    /// v2.0 interleaved-DMA Phase B: perform ONE cycle of an interleaved DMC
3527    /// DMA (`TriCNES` `_6502` DMC-only path: `DMCDMA_Halted`/`Put`/`Get`). Called
3528    /// once per R1 cycle from `Cpu::read1` while `apu.dmc_dma_pending()`, at the
3529    /// access-point of the cycle (after `start_cycle`, before `end_cycle`). The
3530    /// CPU drives the cycle timing; this does only the DMA bus access + advances
3531    /// the halt/get state. The GET always lands on a get cycle (`!put_cycle`),
3532    /// so the 3-vs-4-cycle span is EMERGENT from the `put_cycle` parity at arm
3533    /// time (divergence-A self-consistency), not main's fixed `short?2:3`.
3534    #[allow(clippy::too_many_lines)]
3535    fn dmc_dma_step_impl(&mut self, halted_addr: u16) {
3536        if !self.in_dmc_dma {
3537            // First cycle of this DMA span: latch halt + the open-bus replay.
3538            self.in_dmc_dma = true;
3539            self.dmc_halt = true;
3540            self.capture_deferred_dma_replay();
3541        }
3542        // get = read cycle (TriCNES `!APU_PutCycle`); put = write cycle.
3543        let get_cycle = !self.apu.put_cycle();
3544        if get_cycle && !self.dmc_halt {
3545            // The GET: fetch the sample (with the `$4000` open-bus conflict the
3546            // DMA cluster brackets) + deliver to the DMC.
3547            let addr = self.apu.dmc_dma_addr();
3548            let byte = self.dmc_dma_read(addr, halted_addr);
3549            #[cfg(feature = "irq-timing-trace")]
3550            self.set_trace_dma_access(BusAccess::DmaRead, addr, byte);
3551            // v1.4.0 Workstream D (D2) — DMC-DMA event-breakpoint tap (the GET
3552            // cycle that fetches a sample). Output-only.
3553            #[cfg(feature = "debug-hooks")]
3554            self.record_event_break(EventBpKind::DmcDma, addr);
3555            self.apu.complete_dmc_dma(byte);
3556            self.in_dmc_dma = false;
3557            // Program M (M-2): this step performed the GET (steals an OAM slot).
3558            {
3559                self.dmc_step_was_get = true;
3560            }
3561        } else {
3562            // Program M (M-2): this step was a halt/dummy/align (overlaps OAM).
3563            {
3564                self.dmc_step_was_get = false;
3565            }
3566            // Halt / alignment / put cycle: re-read the halted CPU address bus
3567            // (TriCNES `Fetch(addressBus)`).
3568            self.replay_dma_noop_read(halted_addr);
3569            // Tag the halt re-read so the trace shows the DMC DMA's $4015
3570            // (etc.) re-read landing — the side-effect cycle the $4015
3571            // frame-IRQ-clear diagnostic correlates against.
3572            #[cfg(feature = "irq-timing-trace")]
3573            self.set_trace_dma_access(BusAccess::DmaRead, halted_addr, self.open_bus);
3574            if get_cycle {
3575                // A get cycle clears the halt ("halts clear after a get cycle").
3576                self.dmc_halt = false;
3577            }
3578        }
3579    }
3580
3581    /// W3-Stage-1 (`mc-r1-dma-unified`): clear the unified engine's transient
3582    /// OAM-DMA state (reset / power-cycle / snapshot-restore).
3583    const fn unified_dma_clear(&mut self) {
3584        self.uni_oam_active = false;
3585        self.uni_oam_halt = false;
3586        self.uni_oam_aligned = false;
3587        self.uni_oam_addr = 0;
3588    }
3589
3590    /// W3-Stage-1 (`mc-r1-dma-unified`): ONE cycle of the unified DMC/OAM DMA
3591    /// engine — a direct port of the `TriCNES` `_6502` per-cycle DMA dispatch
3592    /// table (`crates/rustynes-test-harness/golden/tricnes/tricnes-harness-src/
3593    /// Emulator.cs` ~4233-4357), the SINGLE driver that standalone DMC,
3594    /// standalone OAM, and the DMC-during-OAM overlap all ride — AT FLOOR
3595    /// PARITY for this stage (the structural-equivalence proof; Stage 2 flips
3596    /// the one engine to the breakthrough parity).
3597    ///
3598    /// Floor-parity mapping (the structural truth Stage 2 collapses): the
3599    /// floor's two drivers run on OPPOSITE halves of the shared cycle counter
3600    /// (`put_cycle == (self.cycle & 1 == 0)` at the access point):
3601    ///
3602    /// * the DMC engine's GET half is `!put_cycle` (ODD bus cycles) — the
3603    ///   emergent `dmc_dma_step_impl` span (halt latched on entry, cleared at
3604    ///   the end of the first odd cycle, GET on the next odd) is preserved
3605    ///   exactly: entry-on-even = span 4, entry-on-odd = span 3;
3606    /// * the OAM engine's READ half is `put_cycle` (EVEN bus cycles) — the
3607    ///   floor's `oam_dma_step` latches 514 (halt + align + 512) when its
3608    ///   first serviced cycle is even (`self.cycle & 1 == 0`) and its reads
3609    ///   always land on even cycles; the emergent `uni_oam_halt` (`TriCNES`
3610    ///   `OAMDMA_Halt`, set only when the first serviced cycle is the read
3611    ///   half) reproduces the same 514/513 split with no owed-cycle counter.
3612    ///
3613    /// Each engine's halt clears at the end of ITS OWN get half — `TriCNES`
3614    /// "both halt cycles get cleared after a get cycle", split across the
3615    /// floor's two parities (Stage 2 merges them onto one). The post-GET
3616    /// realign is EMERGENT: a DMC GET stalls OAM for the slot AND forces
3617    /// `uni_oam_aligned = false` (`TriCNES` `DMCDMA_Get` ->
3618    /// `OAMDMA_Aligned = false`), so the in-flight byte is re-read.
3619    ///
3620    /// ONE bus slot per cycle. When a halted DMC overlaps an advancing OAM
3621    /// cycle, the held CPU read's side-effect replay still fires alongside —
3622    /// the lockstep `service_dmc_dma_during_oam` noop-body model (the in-tree
3623    /// overlap spec that passes the whole abort cluster on the default build).
3624    #[allow(clippy::too_many_lines)] // the cfg-split floor + merged dispatches
3625    fn unified_dma_cycle_impl(&mut self, halted_addr: u16) {
3626        // Cycle-half label at the access point (post `cpu_clock`, the APU
3627        // counter has flipped): even bus cycle == `put_cycle` at floor parity.
3628        // W3-Stage-2 (`mc-r1-dma-unified-collapse`): under the put_cycle
3629        // END-flip (the counter-collapse breakthrough parity) the access-point
3630        // read is the references' in-cycle `APU_PutCycle` label DIRECTLY —
3631        // TriCNES also flips at end-of-cycle — so the dispatch runs the single
3632        // TriCNES labeling: `get = !APU_PutCycle`. The floor's split halves
3633        // (DMC GET = odd / OAM READ = even) merge onto this one label.
3634        let get = !self.apu.put_cycle();
3635
3636        // The two activation-time roles, derived per parity model:
3637        // * `oam_halt_on_first` — TriCNES `FirstCycleOfOAMDMA`: halt when the
3638        //   first serviced cycle lands on the OAM READ half (floor: even; the
3639        //   merged labeling: the GET half). Half-swap x parity-flip = the SAME
3640        //   absolute cycles, so standalone OAM timing is invariant.
3641        // * `dmc_noop_half` — the half a LOAD may not ENTER on (the span-3
3642        //   load-get-entry rule: a load enters on its get half).
3643        let (oam_halt_on_first, dmc_noop_half) = (get, !get);
3644
3645        // --- OAM activation (TriCNES `$4014` -> FirstCycleOfOAMDMA) ---
3646        // The first serviced cycle after the `$4014` write latches the page +
3647        // the parked CPU address; `uni_oam_halt` is set only when this first
3648        // cycle lands on the OAM read half (floor: even -> the 514 case).
3649        // Latching here, regardless of any in-flight DMC, natively absorbs the
3650        // Stage-0 `$4014`-write-to-first-OAM-cycle gap (lockstep `drain_dma`
3651        // latches OAM BEFORE its DMC-pending check).
3652        if let Some(page) = self.dma_pending.take() {
3653            self.dma_page = page;
3654            self.uni_oam_addr = 0;
3655            self.uni_oam_aligned = false;
3656            self.uni_oam_active = true;
3657            self.uni_oam_halt = oam_halt_on_first;
3658            self.dma_halt_addr = halted_addr;
3659        }
3660
3661        // --- DMC activation (the floor `dmc_dma_step_impl` first-cycle latch)
3662        // A LOAD may not ENTER on the DMC noop half: the floor's
3663        // `dmc_dma_defer_load_entry` while-gate defers exactly the entries
3664        // whose access-point parity is the noop half, so a load enters on its
3665        // get half = span 3 (`mc-r1-dmc-load-get-entry`). The same defer is
3666        // re-derived here for cycles the loop runs anyway because OAM is
3667        // active.
3668        // W3-Stage-3 (`mc-r1-dmc-delayed-4015`): a pending DMC whose APPLIED
3669        // status is false may not ACTIVATE either (the loop can still be
3670        // running for an active OAM; TriCNES's stale `DoDMCDMA` similarly
3671        // never re-enters the halt-latch path — `DMCDMA_Halt` was latched at
3672        // the original activation).
3673        let dmc_serviceable = self.apu.dmc_dma_serviceable();
3674        if self.apu.dmc_dma_pending() && dmc_serviceable && !self.in_dmc_dma {
3675            let defer_load = self.apu.dmc_dma_is_load() && dmc_noop_half;
3676            if !defer_load {
3677                self.in_dmc_dma = true;
3678                self.dmc_halt = true;
3679                self.capture_deferred_dma_replay();
3680            }
3681        }
3682
3683        // --- Dispatch: ONE bus slot per cycle (floor parity: split halves) ---
3684
3685        // --- Dispatch: ONE bus slot per cycle (W3-Stage-2: the references'
3686        // single get/put labeling — the literal TriCNES `_6502` table) ---
3687        if get {
3688            // GET half: DMC GET (priority) > OAM READ > halted reads.
3689            if self.in_dmc_dma && !self.dmc_halt {
3690                // THE DMC GET: owns the bus slot (with the `$4000` open-bus
3691                // conflict the DMA cluster brackets); a sharing OAM is STALLED
3692                // for the slot AND loses alignment (TriCNES `DMCDMA_Get` ->
3693                // `OAMDMA_Aligned = false`, the emergent post-GET realign).
3694                let addr = self.apu.dmc_dma_addr();
3695                let byte = self.dmc_dma_read(addr, halted_addr);
3696                #[cfg(feature = "irq-timing-trace")]
3697                self.set_trace_dma_access(BusAccess::DmaRead, addr, byte);
3698                self.apu.complete_dmc_dma(byte);
3699                self.in_dmc_dma = false;
3700                if self.uni_oam_active {
3701                    self.uni_oam_aligned = false;
3702                }
3703            } else if self.uni_oam_active && !self.uni_oam_halt {
3704                // A halted DMC shares this OAM-READ cycle: on `Rp2A03G` the held
3705                // CPU read's side-effect replay fires first (lockstep noop-body
3706                // order: `replay_dma_noop_read` THEN the OAM slot). This extra
3707                // parked-address re-read — a *halted* DMC squeezing a side-effect
3708                // into an OAM-owned read cycle — is v2.1.7's "unexpected DMA"
3709                // extra read, and it is revision-gated: `Rp2A03G` (default)
3710                // performs it, `Rp2A03H` OMITS it (opt-in later-die model —
3711                // unverified direction; see ADR 0033). Suppression is
3712                // deterministic and cannot desync the transfer:
3713                // `replay_dma_noop_read` only re-triggers a *register's*
3714                // side-effect (a `$2007` buffer advance / `$4016`-`$4017` shift /
3715                // `$4015` IRQ-clear); it ticks no time and advances no DMA
3716                // counter, so the OAM/DMC data path and cycle length are
3717                // identical on both arms.
3718                //
3719                // HONEST RESIDUAL (ADR 0033): on this ported engine the branch
3720                // FIRES (measured ~75× in a synthetic DMC+OAM+`$2007`-loop probe)
3721                // but `replay_dma_noop_read(halted_addr)` is a no-op every time,
3722                // because `halted_addr` during a DMC+OAM overlap is always the
3723                // post-`$4014` *instruction fetch* in PRG (OAM DMA drains on the
3724                // next opcode read, not on a register operand read), never a
3725                // `$2002/$2007/$4015/$4016/$4017` address. So `Rp2A03G` and
3726                // `Rp2A03H` are, in practice, byte-identical on every public
3727                // oracle and every constructible scenario — the die-revision
3728                // extra read is unobservable here, not merely unverified. The
3729                // gate is kept at its mechanism-correct location so it becomes
3730                // live immediately if the parked-address model ever exposes a
3731                // register during the overlap; it never perturbs the default
3732                // (`Rp2A03G`) path.
3733                if self.in_dmc_dma && self.cpu_2a03_revision.has_unexpected_dma_extra_read() {
3734                    self.replay_dma_noop_read(halted_addr);
3735                }
3736                // OAM GET: the OAM engine owns the bus slot.
3737                let src = (u16::from(self.dma_page) << 8) | self.uni_oam_addr;
3738                self.dma_byte = self.raw_oam_dma_read(src);
3739                self.uni_oam_aligned = true;
3740                #[cfg(feature = "irq-timing-trace")]
3741                self.set_trace_dma_access(BusAccess::DmaRead, src, self.dma_byte);
3742            } else if self.in_dmc_dma {
3743                // DMC halted get: re-read the parked CPU address (TriCNES
3744                // `Fetch(addressBus)`). Covers the both-halted shared cycle
3745                // too (ONE re-read — TriCNES `DMCDMA_Halted`).
3746                self.replay_dma_noop_read(halted_addr);
3747                #[cfg(feature = "irq-timing-trace")]
3748                self.set_trace_dma_access(BusAccess::DmaRead, halted_addr, self.open_bus);
3749            } else {
3750                // OAM halt cycle alone: the parked address stays on the bus
3751                // (the floor `oam_dma_step` halt branch — no side-effect
3752                // replay).
3753                #[cfg(feature = "irq-timing-trace")]
3754                self.set_trace_dma_access(BusAccess::DmaRead, self.dma_halt_addr, self.open_bus);
3755            }
3756            // TriCNES: BOTH halt cycles get cleared after a get cycle.
3757            self.dmc_halt = false;
3758            self.uni_oam_halt = false;
3759        } else {
3760            // PUT half: OAM WRITE/align; a waiting/halted DMC replays the held
3761            // CPU read's side-effect alongside (TriCNES `DMCDMA_Put` /
3762            // `DMCDMA_Halted` — both `Fetch(addressBus)`).
3763            if self.in_dmc_dma {
3764                self.replay_dma_noop_read(halted_addr);
3765                #[cfg(feature = "irq-timing-trace")]
3766                self.set_trace_dma_access(BusAccess::DmaRead, halted_addr, self.open_bus);
3767            }
3768            if self.uni_oam_active && !self.uni_oam_halt {
3769                if self.uni_oam_aligned {
3770                    // OAM PUT: write the latched byte to OAM ($2004).
3771                    // `uni_oam_aligned` stays set through the transfer
3772                    // (TriCNES: only `DMCDMA_Get` and completion clear it).
3773                    self.oam_dma_put();
3774                    #[cfg(feature = "irq-timing-trace")]
3775                    self.set_trace_dma_access(BusAccess::DmaWrite, 0x2004, self.dma_byte);
3776                    self.uni_oam_addr += 1;
3777                    if self.uni_oam_addr == 256 {
3778                        // The DMA completes on the 256th write.
3779                        self.uni_oam_active = false;
3780                        self.uni_oam_aligned = false;
3781                    }
3782                } else {
3783                    // OAM alignment dummy: the parked address stays on the
3784                    // bus (the floor `oam_dma_step` align branch — no
3785                    // side-effect replay).
3786                    #[cfg(feature = "irq-timing-trace")]
3787                    if !self.in_dmc_dma {
3788                        self.set_trace_dma_access(
3789                            BusAccess::DmaRead,
3790                            self.dma_halt_addr,
3791                            self.open_bus,
3792                        );
3793                    }
3794                }
3795            }
3796            // (An OAM halt can never land on the PUT half under the merged
3797            // labeling — `uni_oam_halt` is set only on a GET first cycle and
3798            // clears at the end of that same GET half.)
3799        }
3800    }
3801
3802    /// Raw CPU read that does **not** advance time — used by the OAM DMA
3803    /// engine and DMC DMA fetches.  Time was already advanced by the
3804    /// surrounding `tick_one_cpu_cycle` (or the DMA stall).
3805    pub(crate) fn raw_cpu_read(&mut self, addr: u16) -> u8 {
3806        // $4015 special case: reading from the APU status port reads
3807        // 2A03 internal state but does NOT drive the data bus (per
3808        // nesdev "Open bus behavior" + AccuracyCoin `CPU Behavior ::
3809        // Open Bus` Test 7). The CPU still receives the APU status,
3810        // but the open-bus latch stays at its prior value, so a
3811        // subsequent open-bus-region read returns the *previous*
3812        // floating-bus value rather than the APU status.
3813        if addr == 0x4015 {
3814            // $4015 read returns the APU status (internal silicon
3815            // state) and does NOT drive the external data bus, so
3816            // `self.open_bus` stays at its prior value (per nesdev
3817            // "Open bus behavior" + AccuracyCoin `CPU Behavior ::
3818            // Open Bus` Test 7).
3819            //
3820            // Bit 5 of $4015 is documented as open-bus on silicon.
3821            // With the Phase 1a internal-vs-external bus split, we
3822            // expose this from the INTERNAL data bus (CPU-only, NOT
3823            // polluted by DMC DMA fetches).  This satisfies BOTH:
3824            //   * Open Bus Test 9 — bit 5 returns the bus latch value
3825            //   * Internal Data Bus Test 2 — DMC DMA does NOT change
3826            //     bit 5 because DMC drives only the external bus.
3827            //
3828            // The pre-2026-05-23 conflated `open_bus` model could
3829            // not honour both tests simultaneously: empirically (per
3830            // CLAUDE.md Phase D3 audit), OR-ing `open_bus & 0x20`
3831            // into the read flipped Test 9 PASS but tripped Test 2
3832            // to FAIL — net-zero swap. With the internal-bus
3833            // separation, the trade-off is resolved.
3834            let status = self.apu.read_status();
3835            let v = (status & 0xDF) | (self.internal_data_bus & 0x20);
3836            self.last_read_addr = addr;
3837            return v;
3838        }
3839        let v = match addr {
3840            0x0000..=0x1FFF => self.ram[(addr & 0x07FF) as usize],
3841            0x2000..=0x3FFF => self.ppu_register_read(addr),
3842            0x4000..=0x4014 | 0x4018..=0x401F => self.open_bus,
3843            0x4015 => unreachable!("handled above"),
3844            // Controllers drive D0 (and D1 on Famicom expansion port,
3845            // unused here). Bits 5-7 are open bus — the bus latch's
3846            // upper 3 bits show through. Bit 4 is the secondary
3847            // controller D1 (also open bus on stock NES). Per nesdev
3848            // "Standard controller" + AccuracyCoin `CPU Behavior ::
3849            // Open Bus` Test 6.
3850            0x4016 => {
3851                let mic = u8::from(self.famicom_mic) << 2;
3852                let base = (self.open_bus & 0xE0) | self.read_port(0) | mic;
3853                self.vs_overlay_4016(base)
3854            }
3855            0x4017 => {
3856                let base = (self.open_bus & 0xE0) | self.read_port(1);
3857                self.vs_overlay_4017(base)
3858            }
3859            0x4020..=0xFFFF => {
3860                if self.mapper.cpu_read_unmapped(addr) {
3861                    // Unmapped read: bus stays at the floating-latch
3862                    // value (per nesdev "Open bus behavior"). Don't
3863                    // overwrite `open_bus` — return early.
3864                    self.last_read_addr = addr;
3865                    return self.open_bus;
3866                }
3867                // The Game Genie physically substitutes the byte on the
3868                // cartridge bus, so the (possibly substituted) value is what
3869                // the CPU sees AND what latches onto `open_bus` below.
3870                let raw = self.mapper.cpu_read(addr);
3871                self.apply_genie(addr, raw)
3872            }
3873        };
3874        self.last_read_addr = addr;
3875        self.open_bus = v;
3876        // Mirror the read onto the internal data bus, but ONLY when
3877        // this is a CPU-initiated access.  DMC DMA fetches drive
3878        // only the EXTERNAL (`open_bus`) bus per nesdev's two-bus
3879        // 2A03 model and per AccuracyCoin's `CPU Behavior 2 ::
3880        // Internal Data Bus` Test 2 ("This DMC DMA does not update
3881        // the external data bus.  Only the internal one." — the
3882        // upstream comment treats "internal" as the OPPOSITE of
3883        // what we call internal here; per the test sequence the
3884        // INTERNAL_data_bus is what `$4015` bit-5 returns, and DMC
3885        // DMA must NOT pollute it).  The `in_dmc_dma` guard is set
3886        // by `service_dmc_dma` before invoking `dmc_dma_read` →
3887        // `raw_cpu_read`; we skip the internal-bus mirror in that
3888        // path so the internal latch retains its prior CPU-driven
3889        // value across DMC halts.  Phase 1 of `linked-puzzling-sutherland`.
3890        if !self.in_dmc_dma {
3891            self.internal_data_bus = v;
3892        }
3893        v
3894    }
3895
3896    /// PPU register read with side effects.
3897    fn ppu_register_read(&mut self, addr: u16) -> u8 {
3898        let reg = (addr & 7) as u8;
3899        let mut adapter = PpuBusAdapter {
3900            mapper: self.mapper.as_mut(),
3901            nt_override: self.nt_mirroring_override,
3902            // CPU bus access happens during φ2 → sub_dot 2 (M2-high).
3903            sub_dot: 2,
3904            #[cfg(feature = "irq-timing-trace")]
3905            trace_a12_latest: None,
3906        };
3907        self.ppu.cpu_read_register(reg, &mut adapter)
3908    }
3909
3910    /// PPU register write with side effects.
3911    fn ppu_register_write(&mut self, addr: u16, value: u8) {
3912        let reg = (addr & 7) as u8;
3913        let mut adapter = PpuBusAdapter {
3914            mapper: self.mapper.as_mut(),
3915            nt_override: self.nt_mirroring_override,
3916            // CPU bus access happens during φ2 → sub_dot 2 (M2-high).
3917            sub_dot: 2,
3918            #[cfg(feature = "irq-timing-trace")]
3919            trace_a12_latest: None,
3920        };
3921        self.ppu.cpu_write_register(reg, value, &mut adapter);
3922    }
3923}
3924
3925/// v1.1.0 beta.1 (T-110-B4) — translate a `$2000-$3EFF` PPU address to a
3926/// CIRAM offset under an explicit mirroring (the per-game override path),
3927/// mirroring the `Mapper::nametable_address` default impl.
3928#[allow(clippy::cast_possible_truncation)] // physical_bank is always 0 or 1.
3929const fn override_nt_addr(m: rustynes_mappers::Mirroring, addr: u16) -> u16 {
3930    const NT: u16 = 0x0400;
3931    let table = ((addr.wrapping_sub(0x2000)) / NT) & 0x03;
3932    let local = addr & (NT - 1);
3933    (m.physical_bank(table as u8) as u16) * NT + local
3934}
3935
3936/// Adapter that exposes the [`PpuBus`] interface over a `&mut dyn Mapper`.
3937struct PpuBusAdapter<'a> {
3938    mapper: &'a mut dyn Mapper,
3939    /// v1.1.0 beta.1 (T-110-B4) — the bus's per-game mirroring override, copied
3940    /// in at construction. When `Some`, `nametable_address` uses it instead of
3941    /// the mapper's mirroring.
3942    nt_override: Option<rustynes_mappers::Mirroring>,
3943    /// Current PPU sub-dot of the host CPU cycle (0, 1, or 2).  Set by
3944    /// the bus's tick loop before each `Ppu::tick` call so that
3945    /// `notify_a12_at_sub_dot` (C1 step B4-successor M2-phase plumbing)
3946    /// can forward the sub-dot to the mapper for cycle-precise IRQ
3947    /// propagation modeling.  Sub-dots 0 / 1 are M2-low (φ1) and 2 is
3948    /// M2-high (φ2) per our convention.
3949    sub_dot: u8,
3950    /// When the IRQ-timing trace feature is enabled, the most recent A12
3951    /// level passed through `notify_a12` is mirrored here so the bus's
3952    /// per-sub-dot trace loop can pick it up.  `None` when tracing is
3953    /// off (the standard hot path).
3954    #[cfg(feature = "irq-timing-trace")]
3955    trace_a12_latest: Option<&'a mut Option<bool>>,
3956}
3957
3958impl PpuBus for PpuBusAdapter<'_> {
3959    fn ppu_read(&mut self, addr: u16) -> u8 {
3960        self.mapper.ppu_read(addr & 0x1FFF)
3961    }
3962    fn ppu_read_sprite(&mut self, addr: u16) -> u8 {
3963        self.mapper.ppu_read_sprite(addr & 0x1FFF)
3964    }
3965    fn chr_phys(&self, addr: u16) -> Option<u32> {
3966        self.mapper.chr_phys(addr & 0x1FFF)
3967    }
3968    fn ppu_write(&mut self, addr: u16, value: u8) {
3969        self.mapper.ppu_write(addr & 0x1FFF, value);
3970    }
3971    fn peek_nametable(&mut self, addr: u16) -> Option<u8> {
3972        self.mapper.nametable_fetch(addr)
3973    }
3974    fn write_nametable(&mut self, addr: u16, value: u8) -> bool {
3975        self.mapper.nametable_write(addr, value)
3976    }
3977    fn peek_ex_attribute(&mut self, v: u16) -> Option<PpuExAttribute> {
3978        self.mapper.peek_ex_attribute(v).map(|ex| PpuExAttribute {
3979            palette: ex.palette,
3980            chr_bank: ex.chr_bank,
3981        })
3982    }
3983    fn bg_split_state(&mut self, scanline_y: u16, coarse_x: u16) -> Option<PpuBgSplitState> {
3984        self.mapper
3985            .bg_split_state(scanline_y, coarse_x)
3986            .map(|s| PpuBgSplitState {
3987                nt_addr: s.nt_addr,
3988                at_addr: s.at_addr,
3989                fine_y: s.fine_y,
3990                chr_bank: s.chr_bank,
3991            })
3992    }
3993    fn notify_a12(&mut self, level: bool) {
3994        // C1 step B4 successor: forward the current sub-dot to the
3995        // mapper so MMC3 can apply the M2-phase-aware IRQ-output
3996        // propagation delay required by `mmc3_test_2/4-scanline_timing`
3997        // sub-test #3.  Non-MMC3 mappers' default
3998        // `notify_a12_at_sub_dot` impl falls back to plain `notify_a12`,
3999        // so this thread-through is invisible to NROM / UxROM / etc.
4000        self.mapper.notify_a12_at_sub_dot(level, self.sub_dot);
4001        #[cfg(feature = "irq-timing-trace")]
4002        if let Some(slot) = self.trace_a12_latest.as_deref_mut() {
4003            *slot = Some(level);
4004        }
4005    }
4006    fn notify_scanline_start(&mut self) {
4007        self.mapper.notify_scanline_start();
4008    }
4009    fn notify_vblank(&mut self) {
4010        self.mapper.notify_vblank();
4011    }
4012    fn nametable_address(&self, addr: u16) -> u16 {
4013        resolve_nt_addr(self.nt_override, &*self.mapper, addr)
4014    }
4015}
4016
4017/// Resolve a nametable address to a physical CIRAM offset, honouring the
4018/// per-game mirroring override when one is set.
4019///
4020/// Factored out of [`PpuBusAdapter::nametable_address`] (v2.3.2 "Lucid") so
4021/// [`LockstepBus::resolve_nametable_address`] can answer the same question
4022/// without constructing an adapter. One definition, so the fetch path and the
4023/// provenance panel cannot drift apart on a board with an override.
4024fn resolve_nt_addr(
4025    nt_override: Option<rustynes_mappers::Mirroring>,
4026    mapper: &dyn Mapper,
4027    addr: u16,
4028) -> u16 {
4029    nt_override.map_or_else(
4030        || mapper.nametable_address(addr),
4031        |m| override_nt_addr(m, addr),
4032    )
4033}
4034
4035impl LockstepBus {
4036    /// Read-only nametable-address resolution for the pixel-provenance panel.
4037    ///
4038    /// Shares [`resolve_nt_addr`] with the PPU's own fetch path, so a board with
4039    /// a per-game mirroring override reports the offset its fetches really use.
4040    #[cfg(feature = "debug-hooks")]
4041    pub(crate) fn resolve_nametable_address(&self, addr: u16) -> u16 {
4042        resolve_nt_addr(self.nt_mirroring_override, &*self.mapper, addr)
4043    }
4044}
4045
4046/// v2.0 master-clock R1 substrate helpers (Phase 1). Compiled only under
4047/// `mc-r1-substrate`; used by the clean `Bus` contract overrides below.
4048impl LockstepBus {
4049    /// `(cpu_divider, ppu_divider)` in master clocks for the cartridge region
4050    /// (NTSC 12/4, PAL 16/5, Dendy 15/5). Drives the R1 `run_ppu_to` dot loop.
4051    /// Reads the values cached at construction (region is immutable after
4052    /// parse), so the hot R1 paths avoid a per-cycle `match`.
4053    const fn region_dividers(&self) -> (u8, u8) {
4054        (self.cpu_div_cached, self.ppu_div_cached)
4055    }
4056
4057    /// Tick the APU + frame counter once and fan frame events out to on-cart
4058    /// audio (the per-CPU-cycle APU advance extracted from
4059    /// `tick_one_cpu_cycle`, for the R1 `cpu_clock`).
4060    ///
4061    /// v2.8.0 Phase 4 — the mapper dispatches are gated on the cached
4062    /// capability flags: boards without on-cart audio would return 0 from
4063    /// the default `mix_audio` (0.0 after the f32 conversion — identical),
4064    /// and boards without the frame hook have the default no-op. Skipping
4065    /// both saves two virtual calls + an f32 divide per CPU cycle.
4066    fn apu_advance_one(&mut self) {
4067        #[allow(clippy::cast_precision_loss)] // see `mix_audio`'s call site above
4068        let mapper_sample = if self.mapper_caps.audio {
4069            self.mapper.mix_audio() as f32 / 65536.0
4070        } else {
4071            0.0
4072        };
4073        // v2.0.0 beta.1 (A1 one-clock collapse): hand the APU the canonical
4074        // bus cycle counter (incremented earlier in this same `cpu_clock`)
4075        // instead of letting it keep an independent `+= 1` mirror (the
4076        // one-clock collapse, promoted to the only path in v2.0.0 beta.4).
4077        self.apu.set_canonical_cycle(self.cycle);
4078        self.apu.tick_with_external(mapper_sample);
4079        if self.mapper_caps.frame_event_hook {
4080            let ev = self.apu.last_frame_events();
4081            self.mapper.notify_frame_event(MapperFrameEvents {
4082                quarter: ev.quarter,
4083                half: ev.half,
4084            });
4085        }
4086    }
4087}
4088
4089impl Bus for LockstepBus {
4090    fn cpu_read(&mut self, addr: u16) -> u8 {
4091        if self.deferred_dma_replay_addr != 0
4092            && self.open_bus == (self.deferred_dma_replay_addr >> 8) as u8
4093        {
4094            if self.deferred_dma_replay_addr == addr {
4095                self.replay_dma_noop_read(addr);
4096            }
4097            self.deferred_dma_replay_addr = 0;
4098        }
4099        let value = self.raw_cpu_read(addr);
4100        // v1.1.0 beta.3 (T-110-E2) — Lua onRead access tap. Output-only, gated.
4101        #[cfg(feature = "debug-hooks")]
4102        if self.access_logging && self.accesses.len() < ACCESS_CAP {
4103            self.accesses.push(AccessRec {
4104                write: false,
4105                addr,
4106                value,
4107            });
4108        }
4109        // v1.5.0 Workstream A2 — event-viewer read tap: the graphical PPU Event
4110        // Viewer needs PPU-register READS (`$2002` status polls, `$2007` data
4111        // fetches) plotted alongside writes. Only the `$2000-$3FFF` PPU window is
4112        // captured (the dense APU/RAM/PRG read stream would swamp the timeline);
4113        // writes across PPU/APU/mapper are captured in `cpu_write`. Output-only,
4114        // gated, bounded by `EVENT_CAP` — determinism-neutral.
4115        #[cfg(feature = "debug-hooks")]
4116        if self.event_logging && matches!(addr, 0x2000..=0x3FFF) && self.events.len() < EVENT_CAP {
4117            self.events.push(EventRec {
4118                kind: EventKind::PpuRead,
4119                scanline: self.ppu.scanline(),
4120                dot: self.ppu.dot(),
4121                addr,
4122                value,
4123            });
4124        }
4125        // v1.4.0 Workstream D (D2) — event-breakpoint read taps. Output-only.
4126        // The `mask == 0` early-out in `record_event_break` keeps the default
4127        // path cheap; the sprite-0-hit category is observed where games detect
4128        // it: a `$2002` read returning bit 6 set.
4129        #[cfg(feature = "debug-hooks")]
4130        if self.event_bp_mask != 0 {
4131            match addr {
4132                0x2002 if value & 0x40 != 0 => {
4133                    self.record_event_break(EventBpKind::Sprite0Hit, addr);
4134                }
4135                0x2000..=0x3FFF => self.record_event_break(EventBpKind::PpuRead, addr),
4136                0x4000..=0x4017 => self.record_event_break(EventBpKind::ApuRead, addr),
4137                0x4020..=0xFFFF => self.record_event_break(EventBpKind::MapperRead, addr),
4138                _ => {}
4139            }
4140        }
4141        #[cfg(feature = "irq-timing-trace")]
4142        {
4143            // Session-21: record the CPU-initiated read at the bus-access
4144            // tracker.  `tick_one_cpu_cycle` was already called by the
4145            // CPU's `read1`/`idle_tick` path (post `bus.on_cpu_cycle()`),
4146            // but the order in `Cpu::read1` is `bus.cpu_read(addr)` then
4147            // `idle_tick(bus)` → `bus.on_cpu_cycle()` → record-push.
4148            // So writing the tracker here populates the record that the
4149            // about-to-fire `tick_one_cpu_cycle` will consume.
4150            self.trace_bus_access = BusAccess::Read;
4151            self.trace_bus_addr = addr;
4152            self.trace_bus_data = value;
4153        }
4154        value
4155    }
4156
4157    fn cpu_write(&mut self, addr: u16, value: u8) {
4158        self.open_bus = value;
4159        // Mirror the CPU-initiated write onto the internal data bus.
4160        // Symmetric with `raw_cpu_read`'s mirror — DMC DMA does not
4161        // perform writes, so internal-vs-external divergence only
4162        // arises across DMC read halts.  (No `in_dmc_dma` guard
4163        // here because DMC DMA never invokes `cpu_write`.)
4164        self.internal_data_bus = value;
4165        // v1.1.0 beta.3 (T-110-E2) — Lua onWrite access tap. Output-only, gated.
4166        #[cfg(feature = "debug-hooks")]
4167        if self.access_logging && self.accesses.len() < ACCESS_CAP {
4168            self.accesses.push(AccessRec {
4169                write: true,
4170                addr,
4171                value,
4172            });
4173        }
4174        // v1.1.0 beta.2 (T-110-C3) — event-viewer tap: classify the write +
4175        // record it with the current PPU position. Output-only, gated.
4176        #[cfg(feature = "debug-hooks")]
4177        if self.event_logging {
4178            let kind = match addr {
4179                0x2000..=0x3FFF => Some(EventKind::PpuWrite),
4180                // The whole `$4000-$4017` APU / I/O window (Copilot #43): this
4181                // now also captures `$4014` OAM DMA and `$4016` controller
4182                // strobe, which the legend's "$4000-4017" already advertises.
4183                0x4000..=0x4017 => Some(EventKind::ApuWrite),
4184                0x4020..=0xFFFF => Some(EventKind::MapperWrite),
4185                _ => None,
4186            };
4187            if let Some(kind) = kind
4188                && self.events.len() < EVENT_CAP
4189            {
4190                self.events.push(EventRec {
4191                    kind,
4192                    scanline: self.ppu.scanline(),
4193                    dot: self.ppu.dot(),
4194                    addr,
4195                    value,
4196                });
4197            }
4198        }
4199        // v1.4.0 Workstream D (D2) — event-breakpoint write taps. Output-only.
4200        // `$4014` is the OAM-DMA trigger; the rest classify by window.
4201        #[cfg(feature = "debug-hooks")]
4202        if self.event_bp_mask != 0 {
4203            match addr {
4204                0x2000..=0x3FFF => self.record_event_break(EventBpKind::PpuWrite, addr),
4205                REG_OAM_DMA => self.record_event_break(EventBpKind::OamDma, addr),
4206                0x4000..=0x4017 => self.record_event_break(EventBpKind::ApuWrite, addr),
4207                0x4020..=0xFFFF => self.record_event_break(EventBpKind::MapperWrite, addr),
4208                _ => {}
4209            }
4210        }
4211        match addr {
4212            0x0000..=0x1FFF => self.ram[(addr & 0x07FF) as usize] = value,
4213            0x2000..=0x3FFF => self.ppu_register_write(addr, value),
4214            REG_OAM_DMA => {
4215                // v2.3.2 "Lucid" — freeze THIS instruction (the `STA $4014`) as
4216                // the cause of the burst before it is armed. The 513/514 DMA
4217                // cycles are stolen from the instructions that follow, so by the
4218                // time the first OAM byte lands the live attribution context has
4219                // moved on to whichever instruction is being halted.
4220                #[cfg(feature = "debug-hooks")]
4221                self.ppu.latch_dma_attrib_context();
4222                // v2.3.7 "Overtone" — `$4014` sits inside the `$4000-$4017`
4223                // window the audio-provenance table reserves a slot for, but the
4224                // arm below routes only `$4000-$4013 | $4015 | $4017` to
4225                // `Apu::write_register`, where attribution is recorded. Record it
4226                // here so the reserved slot is actually populated; nothing is
4227                // dispatched to the APU, so the DMA behaviour is unchanged.
4228                #[cfg(feature = "debug-hooks")]
4229                self.apu
4230                    .record_bus_handled_register_write(REG_OAM_DMA, value);
4231                self.dma_pending = Some(value);
4232            }
4233            0x4000..=0x4013 | 0x4015 | 0x4017 => self.apu.write_register(addr, value),
4234            0x4016 => {
4235                // v2.3.7 "Overtone" — same as `$4014` above: inside the
4236                // provenance window, never routed to `Apu::write_register`, so
4237                // attribute it here. The strobe itself is still buffered and
4238                // committed by the code below; this only records the cause.
4239                #[cfg(feature = "debug-hooks")]
4240                self.apu.record_bus_handled_register_write(0x4016, value);
4241                // Session-24 / Phase 3 (Controller Strobing): the
4242                // controllers' OUT pins are only updated at the start
4243                // of M2-low (PUT) cycles.  Buffer the write and
4244                // commit at the next M2-low boundary inside
4245                // `tick_one_cpu_cycle`.  Mirrors Mesen2's
4246                // `NesControlManager::WriteRam` (Core/NES/
4247                // NesControlManager.cpp lines 252-273).
4248                //
4249                // Parity convention: in `RustyNES` the bus enters each
4250                // CPU cycle at `M2Phase::Low` and transitions to
4251                // `M2Phase::High` after PPU sub-dot 1.  The cycle
4252                // counter advances at end-of-cycle.  So a CPU write
4253                // executed during cycle `self.cycle` lands at the END
4254                // of that cycle's M2-high half.  The NEXT cycle
4255                // (`self.cycle + 1`) starts at M2-low — which is the
4256                // commit boundary.  In Mesen2's master-clock terms,
4257                // odd master clocks mean "one cycle from PUT" and
4258                // even mean "two cycles from PUT"; the corresponding
4259                // `RustyNES` rule is: if `self.cycle` is odd at write
4260                // time, pending = 1 (commit at next cycle); if even,
4261                // pending = 2 (commit at cycle-after-next).  This
4262                // collapses the AccuracyCoin Test 4 1-cycle DEC
4263                // `$4016` strobe pulse (both writes target the SAME
4264                // commit cycle; the second overwrites the first; no
4265                // edge is observed → no latch).  See
4266                // `docs/audit/session-24-phase3-controller-strobing-2026-05-23.md`.
4267                self.controller_write_value = value;
4268                // Parity convention: in `RustyNES` the CPU `cpu_write` runs
4269                // INSIDE `tick_one_cpu_cycle` AFTER `self.cycle` has
4270                // been incremented to the post-cycle value (see
4271                // `tick_one_cpu_cycle` flow).  The committed commit
4272                // cycle MUST land on an M2-low boundary (PUT cycle).
4273                // In `RustyNES` every CPU cycle starts at M2-low and
4274                // transitions to M2-high after sub-dot 1, so every
4275                // cycle has an M2-low half — but only cycles where
4276                // the COMMITTED strobe value is observable AT the
4277                // beginning of the cycle qualify as the deferred-
4278                // write commit target.
4279                //
4280                // The empirical calibration from the Phase 3 oracle:
4281                // Mesen2 PUT cycles correspond to ODD `cpu.cycleCount`
4282                // (per `NesCpu.cpp:400` `bool getCycle = (CycleCount &
4283                // 0x01) == 0;` — get cycles are even, put cycles are
4284                // odd).  Our `self.cycle` parity at the moment of
4285                // `cpu_write` differs from Mesen2's by an offset
4286                // (Mesen2's cycle count includes the boot/reset
4287                // sequence differently); empirically, our EVEN cycles
4288                // correspond to Mesen2's PUT cycles in the
4289                // `controller-strobing.nes` Test 3 vs Test 4
4290                // discrimination.  Hence: even `self.cycle` → pending
4291                // = 1 (commit next cycle); odd `self.cycle` → pending
4292                // = 2 (commit cycle-after-next).
4293                self.controller_write_pending = if (self.cycle & 1) == 0 { 1 } else { 2 };
4294                // Vs. System (mapper 99): the CHR bank select is bit 2 of the
4295                // value written to $4016 (shared with the controller strobe).
4296                // Forward every $4016 write to the mapper; only mapper 99
4297                // consumes it — every other mapper's `cpu_write` ignores the
4298                // $4016 address (their match arms only cover $8000-$FFFF /
4299                // $4020-$7FFF), so this is byte-for-byte a no-op on all
4300                // non-Vs. carts.
4301                self.mapper.cpu_write(0x4016, value);
4302                // v2.0.0 beta.5 (Vs. DualSystem): report the bit-1 (main/sub
4303                // comms signal) LEVEL on EVERY $4016 write for the wrapper
4304                // to poll. Deliberately not edge-filtered: the wrapper seeds
4305                // the reset-time levels itself (Mesen2's
4306                // `UpdateMainSubBit(main ? 0x00 : 0x02)`), so a bus-side
4307                // edge filter starting from a `false` latch would swallow a
4308                // genuine seeded-HIGH → written-LOW transition (Balloon
4309                // Fight's reset writes `$4016 = $00` on both consoles) and
4310                // deadlock the boot handshake. Applying an unchanged level
4311                // is idempotent in the wrapper. The latch is only consulted
4312                // by the DualSystem wrapper; single-console behavior is
4313                // untouched (two dead field writes on non-Vs carts, no
4314                // reads).
4315                self.vs_4016_bit1 = (value & 0x02) != 0;
4316                self.vs_4016_bit1_dirty = true;
4317            }
4318            0x4018..=0x401F => {}
4319            0x4020..=0xFFFF => self.mapper.cpu_write(addr, value),
4320        }
4321        #[cfg(feature = "irq-timing-trace")]
4322        {
4323            // Session-21: record the CPU-initiated write at the bus-access
4324            // tracker for the same reason `cpu_read` does above.
4325            self.trace_bus_access = BusAccess::Write;
4326            self.trace_bus_addr = addr;
4327            self.trace_bus_data = value;
4328        }
4329    }
4330
4331    fn poll_nmi(&mut self) -> bool {
4332        let edge = self.nmi_edge_latch;
4333        self.nmi_edge_latch = false;
4334        edge
4335    }
4336
4337    fn poll_irq(&mut self) -> bool {
4338        // Phase B2 of the C1 IRQ-timing rework: read the M2-high
4339        // snapshot captured at end-of-3-PPU-dots inside
4340        // `tick_one_cpu_cycle`.  Semantically identical to the prior
4341        // `mapper.irq_pending() || apu.irq_line()` query for every
4342        // workspace test ROM (verified: 500 strict + 6 ignored
4343        // unchanged; trace baselines byte-identical).  Phase B4 will
4344        // make the snapshot's value depend on the M2 phase via the
4345        // MMC3 sub_dot-aware A12 filter — this method becomes the
4346        // single point where the production CPU IRQ sample crosses
4347        // into the bus, and from there into the mapper.
4348        self.irq_snapshot_mapper_at_high || self.irq_snapshot_apu_at_high
4349    }
4350
4351    fn poll_irq_at_phase(&mut self, phase: M2Phase) -> bool {
4352        match phase {
4353            M2Phase::Low => self.irq_snapshot_mapper_at_low || self.irq_snapshot_apu_at_low,
4354            M2Phase::High => self.irq_snapshot_mapper_at_high || self.irq_snapshot_apu_at_high,
4355        }
4356    }
4357
4358    fn on_cpu_cycle(&mut self) {
4359        self.tick_one_cpu_cycle();
4360    }
4361
4362    fn internal_data_bus(&self) -> u8 {
4363        // Phase 1 of `linked-puzzling-sutherland` v1.0.0-final brief:
4364        // expose the internal CPU data bus latch separately from the
4365        // external `open_bus`.  Mirrored from every CPU read / write;
4366        // NOT updated by DMC DMA fetches.  See the field documentation
4367        // on [`LockstepBus::internal_data_bus`] and the trait method
4368        // documentation on [`Bus::internal_data_bus`].
4369        self.internal_data_bus
4370    }
4371
4372    fn cycle_count(&self) -> u64 {
4373        // Cumulative bus-side cycle counter, including DMC DMA cycles
4374        // that the CPU's own `Cpu::cycles` field does not count.  Used
4375        // by the SH* unstable-store family to detect DMA interrupting
4376        // their dummy-read cycle per Mesen2's `SyaSxaAxa` algorithm.
4377        self.cycle
4378    }
4379
4380    fn notify_irq_service(&mut self, vector: u16, is_nmi: bool) {
4381        // v1.2.0 (T-110-E1) — Lua onNmi/onIrq interrupt-service tap. This is the
4382        // committed-service commit point (same as the IRQ trace below), NOT the
4383        // speculative poll_nmi/poll_irq sampler. Output-only, gated; no-op when
4384        // `debug-hooks` is off (the log slot only exists feature-gated).
4385        //
4386        // The reliable NMI/IRQ discriminator here is the COMMITTED `vector`
4387        // ($FFFA = NMI, $FFFE = IRQ/BRK), not the `is_nmi` arg: the unified
4388        // dispatch always enters `service_interrupt` with the IRQ vector and
4389        // resolves the NMI *hijack* internally (so the `is_nmi` arg reads
4390        // `false` on a hijacked NMI). Classifying by the vector the CPU actually
4391        // fetched reports exactly the service that committed.
4392        #[cfg(feature = "debug-hooks")]
4393        if self.interrupt_logging && self.interrupts.len() < INTERRUPT_CAP {
4394            let _ = is_nmi;
4395            self.interrupts.push(InterruptRec {
4396                is_nmi: vector == 0xFFFA,
4397                vector,
4398            });
4399        }
4400        // v1.4.0 Workstream D (D2) — NMI/IRQ event-breakpoint tap. Classified by
4401        // the COMMITTED vector (same discriminator the interrupt log uses).
4402        #[cfg(feature = "debug-hooks")]
4403        if self.event_bp_mask != 0 {
4404            let kind = if vector == 0xFFFA {
4405                EventBpKind::Nmi
4406            } else {
4407                EventBpKind::Irq
4408            };
4409            self.record_event_break(kind, vector);
4410        }
4411        // Phase 1.2 of Track C1 attempt 14: emit a [`ServiceEvent`] into
4412        // the IRQ trace if the trace is armed.  Production builds with
4413        // the `irq-timing-trace` feature OFF compile this down to a
4414        // no-op (the trace slot only exists feature-gated).
4415        #[cfg(feature = "irq-timing-trace")]
4416        if let Some(trace) = self.irq_trace.as_mut() {
4417            let frame_start = self.ppu.frame();
4418            let scanline_start = self.ppu.scanline();
4419            let dot_start = self.ppu.dot();
4420            let kind = if is_nmi {
4421                crate::irq_trace::ServiceKind::Nmi
4422            } else {
4423                crate::irq_trace::ServiceKind::Irq
4424            };
4425            // `self.cycle` is the count of cycles already consumed; the
4426            // service-vector fetch is the cycle the CPU is ABOUT to
4427            // emit, so reporting `self.cycle` (== the next cycle index)
4428            // matches Mesen2's `cpu.cycleCount` at the moment its
4429            // `emu.eventType.irq` callback fires (its cycle count is
4430            // sampled at the start of the service cycle).
4431            trace.push_service(crate::irq_trace::ServiceEvent {
4432                cpu_cycle: self.cycle,
4433                ppu_scanline: scanline_start,
4434                ppu_dot: dot_start,
4435                ppu_frame: frame_start,
4436                kind,
4437                vector,
4438            });
4439        } else {
4440            let _ = (vector, is_nmi);
4441        }
4442        // Suppress unused-variable warnings when the feature is off.
4443        #[cfg(not(feature = "irq-timing-trace"))]
4444        {
4445            let _ = (vector, is_nmi);
4446        }
4447    }
4448
4449    // ============================================================
4450    // v2.0 master-clock R1 substrate — production overrides (Phase 1).
4451    // Compiled only under `mc-r1-substrate`; consulted by the R1 CPU loop
4452    // (Phases 2+). NOT exercised on the default build, so default behaviour
4453    // is byte-identical. Ported from refactor/v2.0-master-clock with the
4454    // trace + S1/S2 (mc-apu-subcycle / r4-cpu-dma) wiring stripped.
4455    // ============================================================
4456
4457    /// Pure address-space read under R1 (the DMA drain happens in
4458    /// [`Bus::cpu_clock`]; Phase 3 will split the drain out of `cpu_read`).
4459    /// Phase 1 delegates to the legacy path so the contract compiles.
4460    fn read(&mut self, addr: u16) -> u8 {
4461        self.cpu_read(addr)
4462    }
4463
4464    fn write(&mut self, addr: u16, value: u8) {
4465        self.cpu_write(addr, value);
4466    }
4467
4468    /// R1 master clocks per CPU cycle for the cartridge region (NTSC 12 / PAL
4469    /// 16 / Dendy 15) — the `cpu_divider` half of `region_dividers`.
4470    /// Drives the CPU loop's `master_clock` advance + read/write split so the
4471    /// CPU<->PPU phase is 3:1 NTSC, 3.2:1 PAL, 3:1 Dendy.
4472    fn cpu_divider(&self) -> u64 {
4473        u64::from(self.cpu_div_cached)
4474    }
4475
4476    /// R1 double catch-up: tick whole PPU dots while
4477    /// `ppu_clock + ppu_divider <= target`.
4478    ///
4479    /// R1c-3 (`mmc3-m2-phase-irq`, default-off): when the feature is
4480    /// enabled, `sub_dot` is seeded from the REAL M2-phase of this catch-up
4481    /// call (`0` = pre-access / M2-low, called from `Cpu::start_cycle`
4482    /// before the bus access; `2` = post-access / M2-high, called from
4483    /// `Cpu::end_cycle` after it) instead of always restarting at `0`. Prior
4484    /// to this experiment `sub_dot` was a call-LOCAL counter that reset to
4485    /// zero on every invocation of this function — since `run_ppu_to` is
4486    /// called twice per CPU cycle (once per half) and each half typically
4487    /// ticks at most one PPU dot, the value threaded to
4488    /// `Mapper::notify_a12_at_sub_dot` was almost always `0` regardless of
4489    /// which half of the cycle actually produced the A12 transition. That
4490    /// meant the M2-phase plumbing ADR-0002 describes ("sub-dot 0/1 is
4491    /// M2-low, 2 is M2-high") was never actually true on the live R1
4492    /// (non-DMA) scheduler path — only on the legacy `tick_one_cpu_cycle`
4493    /// DMA-burst path, which genuinely walks all 3 dots of a cycle in one
4494    /// call with a persistent counter. This experiment closes that gap so
4495    /// MMC3's (default-off) M2-phase-aware IRQ-visibility pipeline can be
4496    /// evaluated against real phase data on the promoted core. See
4497    /// `docs/adr/0002-irq-timing-coordination.md` and
4498    /// `docs/audit/r1r2-per-dot-scheduler-attempt-2026-07-02.md`.
4499    ///
4500    /// When the feature is OFF this compiles to the exact prior
4501    /// call-local-counter behavior (`sub_dot` always starts at `0`) —
4502    /// byte-identical default build, per the project's additive/off-by-
4503    /// default convention.
4504    fn run_ppu_to(&mut self, target: u64, is_post_access: bool) {
4505        let ppu_div = u64::from(self.ppu_div_cached);
4506        // Seed the real M2-phase into `sub_dot` (0 = pre-access/M2-low catch-up,
4507        // 2 = post-access/M2-high catch-up) for the `mmc3-m2-phase-irq` deferral
4508        // AND for the v2.1.5 F5.0 `mmc3-a12-phase-probe` observational tally.
4509        // The probe only counts, so the emulated timeline stays byte-identical
4510        // even with its feature on. See ADR 0002.
4511        #[cfg(any(feature = "mmc3-m2-phase-irq", feature = "mmc3-a12-phase-probe"))]
4512        let mut sub_dot = if is_post_access { 2u8 } else { 0u8 };
4513        #[cfg(not(any(feature = "mmc3-m2-phase-irq", feature = "mmc3-a12-phase-probe")))]
4514        let (mut sub_dot, _) = (0u8, is_post_access);
4515        while self.ppu_clock + ppu_div <= target {
4516            let mut adapter = PpuBusAdapter {
4517                mapper: self.mapper.as_mut(),
4518                nt_override: self.nt_mirroring_override,
4519                sub_dot,
4520                #[cfg(feature = "irq-timing-trace")]
4521                trace_a12_latest: None,
4522            };
4523            self.ppu.tick(&mut adapter);
4524            self.sample_nmi_edge();
4525            self.ppu_clock += ppu_div;
4526            sub_dot = sub_dot.wrapping_add(1);
4527        }
4528    }
4529
4530    /// R1: one CPU cycle of bus-side work (NO PPU advance — that lives in
4531    /// [`Bus::run_ppu_to`]). Controller strobe + bus-side DMA drain + cycle
4532    /// counter + per-cycle PPU/mapper hooks + APU tick. DMA stays bus-side
4533    /// (the pivot's working `service_dmc_dma`); Phase 3 wires the
4534    /// `dma_mc_consumed` coherence accounting.
4535    fn cpu_clock(&mut self) {
4536        // Diagnostic: snapshot the APU IRQ line (frame-counter | DMC) BEFORE
4537        // `apu_advance_one` runs the frame counter, so `trace_end_cycle` can
4538        // expose the within-cycle frame-counter SET (low=0 -> high=1) vs the
4539        // DMA `$4015` CLEAR (low=1 -> high=0) ordering. Only meaningful under
4540        // the trace feature; the field is otherwise unused on the R1 path.
4541        #[cfg(feature = "irq-timing-trace")]
4542        {
4543            self.irq_snapshot_apu_at_low = self.apu.irq_line();
4544            self.trace_r1_scanline_start = self.ppu.scanline();
4545            self.trace_r1_dot_start = self.ppu.dot();
4546            self.trace_r1_frame_start = self.ppu.frame();
4547        }
4548        if self.controller_write_pending > 0 {
4549            self.controller_write_pending -= 1;
4550            if self.controller_write_pending == 0 {
4551                let value = self.controller_write_value;
4552                self.commit_controller_strobe(value);
4553            }
4554        }
4555        self.cycle = self.cycle.wrapping_add(1);
4556        self.ppu.on_cpu_cycle();
4557        // v2.8.0 Phase 4 — skip the virtual dispatch on boards whose
4558        // `notify_cpu_cycle` is the default no-op (capability-flag cache).
4559        if self.mapper_caps.cpu_cycle_hook {
4560            self.mapper.notify_cpu_cycle();
4561        }
4562        // F-2: `apu_advance_one` (start) ticks the whole APU EXCEPT the DMC
4563        // byte-timer (gated out by `dmc_driven_externally`); the DMC is ticked
4564        // at end-of-cycle by `cpu_clock_apu_dmc`.
4565        self.apu_advance_one();
4566        // (W2 $2007 Stress) The deferred $2007 render-buffer reload is now
4567        // PPU-dot-scheduled and consumed inside `Ppu::tick` — the prior
4568        // per-CPU-cycle `apply_pending_render_buffer` hook here was quantized
4569        // to 3-dot steps and structurally aliased mod 3 against the test's
4570        // 1-dot-per-iteration clockslide.
4571    }
4572
4573    // RA-1 (mc-r1-apu-unified-clock): the DMC byte-timer is now clocked at cycle
4574    // START (in `Apu::tick_with_external` via `apu_advance_one` in `cpu_clock`),
4575    // unified with the rest of the APU and advancing through the DMC DMA span,
4576    // matching Mesen's `ProcessCpuClock` at `StartCpuCycle`. So the END-of-cycle
4577    // DMC tick is a no-op here.
4578    fn cpu_clock_apu_dmc(&mut self) {
4579        // v2.0 Program M (M-1): clock the DMC byte-timer + arm the reload HERE at
4580        // end-of-cycle (after the CPU's bus access), the references' within-cycle
4581        // order. When the flag is OFF the byte-timer stays at cycle-start (above,
4582        // in `tick_with_external`) and this is a no-op -> floor byte-identical.
4583        // Runs BEFORE `promote_dmc_pending_next` so a reload armed at end-of-cycle
4584        // N latches `_next` and is promoted by this SAME call -> serviced N+1
4585        // (the floor service cadence), the byte-timer position being the only
4586        // shift (vs promote-before, which adds a full +1 service cycle and
4587        // over-shifts every DMA).
4588        self.apu.dmc_tick_end();
4589        // Visibility-delay: promote a reload latched this cycle at END (after the
4590        // CPU's bus access) so the NEXT cycle's DMA loop first-services it (put).
4591        self.apu.promote_dmc_pending_next();
4592    }
4593
4594    fn take_dma_mc_consumed(&mut self) -> u64 {
4595        core::mem::take(&mut self.dma_mc_consumed)
4596    }
4597
4598    fn irq_level(&self) -> bool {
4599        // Bound BEFORE the expression rather than as an inline `#[cfg]` block
4600        // inside it. The two forms compile identically -- the default build
4601        // still emits nothing named `inject_`, which is ADR 0038's structural
4602        // gate -- but a `cfg` block in the middle of a boolean chain is hard to
4603        // read, and this chain is the wire-OR of every /IRQ source.
4604        #[cfg(feature = "cosim-interrupt-inject")]
4605        let injected = self.inject_irq;
4606        #[cfg(not(feature = "cosim-interrupt-inject"))]
4607        let injected = false;
4608
4609        // v2.8.0 Phase 4 — boards without an IRQ source have the default
4610        // `irq_pending() == false`; skip the per-cycle virtual call.
4611        // v2.0.0 beta.5 — `vs_external_irq` is the DualSystem partner
4612        // console's `$4016` bit-1 signal (always `false` on a single
4613        // console, so the default path is unchanged).
4614        (self.mapper_caps.irq_source && self.mapper.irq_pending())
4615            || self.apu.irq_line()
4616            || self.vs_external_irq
4617            // v2.5.1 (ADR 0038). Level-sensitive and OR'd, exactly like
4618            // `vs_external_irq` beside it -- which is the precedent: an external
4619            // IRQ source already joins the wire-OR here, and this is the same
4620            // shape with a different driver.
4621            || injected
4622    }
4623
4624    fn nmi_level(&self) -> bool {
4625        // v2.5.1 (ADR 0038). Injected here and NOT in `poll_nmi`, because
4626        // `poll_nmi` is not the path the production CPU uses: it samples this
4627        // LEVEL every cycle and edge-detects it itself (`nmi_first_tick` ->
4628        // `pending_nmi` -> `armed_nmi`).
4629        //
4630        // The first implementation injected at `poll_nmi`, which looks like the
4631        // right function and is dead for this path. The rung-2 sweep found it on
4632        // its first real run -- the DUT took the injected NMI and the oracle did
4633        // not -- which is exactly the defect class a co-simulation exists to
4634        // catch, arriving in the harness rather than in the RTL.
4635        //
4636        // A LEVEL, not a latch: the CPU does its own edge detection, so
4637        // consuming it here would make an injected NMI behave unlike a PPU one.
4638        #[cfg(feature = "cosim-interrupt-inject")]
4639        if self.inject_nmi {
4640            return true;
4641        }
4642        self.ppu.nmi_line()
4643    }
4644
4645    fn dmc_dma_pending(&self) -> bool {
4646        self.apu.dmc_dma_pending()
4647    }
4648
4649    fn dmc_dma_defer_load_entry(&self) -> bool {
4650        {
4651            // The while-gate runs PRE-cycle (before `start_cycle`'s APU tick).
4652            // Floor: the start-flip means the pre-cycle `!put_cycle` predicts
4653            // an access-point parity on the DMC noop half (defer it).
4654            // W3-Stage-2 (`mc-r1-dma-unified-collapse`): the flip moved to
4655            // end-of-cycle, so the pre-cycle value IS the upcoming
4656            // access-point label — the noop half is now the PUT half, so the
4657            // defer condition INVERTS to `put_cycle` (pre-cycle reads are
4658            // flip-invariant in value; the predicted half changes).
4659            let lands_on_noop_half = self.apu.put_cycle();
4660            self.apu.dmc_dma_pending()
4661                && self.apu.dmc_dma_is_load()
4662                && lands_on_noop_half
4663                && !self.in_dmc_dma
4664        }
4665    }
4666
4667    fn dmc_dma_step(&mut self, halted_addr: u16) {
4668        self.dmc_dma_step_impl(halted_addr);
4669    }
4670
4671    fn dmc_dma_step_idle(&mut self) {
4672        // Internal-cycle DMC halt: re-read the held (last CPU read) address.
4673        let halted = self.last_read_addr;
4674        self.dmc_dma_step_impl(halted);
4675    }
4676
4677    // Stage-D: OAM DMA is pending (a `$4014` write awaits its first read cycle)
4678    // or in flight. The CPU `read1` loop drives it one cycle at a time.
4679    fn oam_dma_pending(&self) -> bool {
4680        self.dma_pending.is_some() || self.dma_cycles_owed > 0
4681    }
4682
4683    // Stage-D: one CPU-driven OAM DMA cycle. First call latches the pending
4684    // `$4014` page + the 513/514 alignment count; subsequent calls run one
4685    // halt/align/read/write cycle. Does NOT advance time — the surrounding
4686    // `start_cycle`/`end_cycle` (and their `cpu_clock`/`run_ppu_to`/φ2 sample)
4687    // do, so each OAM cycle is interrupt-sampled like a normal CPU cycle (the
4688    // surface the bus burst bypassed). Mirrors `clock_oam_dma_cycle` minus the
4689    // `tick_one_cpu_cycle`.
4690    fn oam_dma_step(&mut self, halted_addr: u16) {
4691        if let Some(page) = self.dma_pending.take() {
4692            self.dma_page = page;
4693            self.dma_idx = 0;
4694            self.dma_halt_addr = halted_addr;
4695            let extra: u32 = if self.cycle & 1 == 0 { 514 } else { 513 };
4696            self.dma_cycles_owed = extra;
4697            self.dma_total = extra;
4698        }
4699        if self.dma_cycles_owed == 0 {
4700            return;
4701        }
4702        let total = self.dma_total;
4703        let alignment = if total == 514 { 2 } else { 1 };
4704        let consumed = total - self.dma_cycles_owed;
4705        if consumed < alignment {
4706            // Halt / alignment cycle: the held CPU address stays on the bus.
4707            #[cfg(feature = "irq-timing-trace")]
4708            self.set_trace_dma_access(BusAccess::DmaRead, self.dma_halt_addr, self.open_bus);
4709        } else {
4710            let xfer_idx = consumed - alignment; // 0..512
4711            if xfer_idx & 1 == 0 {
4712                let src_addr =
4713                    (u16::from(self.dma_page) << 8) | u16::try_from(xfer_idx >> 1).unwrap_or(0);
4714                self.dma_byte = self.raw_oam_dma_read(src_addr);
4715            } else {
4716                self.oam_dma_put();
4717            }
4718        }
4719        self.dma_cycles_owed -= 1;
4720        if self.dma_cycles_owed == 0 {
4721            self.dma_total = 0;
4722        }
4723    }
4724
4725    // W3-Stage-1 (`mc-r1-dma-unified`): the unified engine's pending query.
4726    // Folds the floor's load-get-entry defer (the standalone DMC loop's
4727    // pre-flip while-gate: a deferred load alone does NOT hold the CPU — the
4728    // real read runs and the load enters on the next cycle, its get half) with
4729    // the OAM pending/in-flight state. The engine re-derives the same defer at
4730    // the access point for cycles the loop runs anyway because OAM is active.
4731    fn unified_dma_pending(&self) -> bool {
4732        let dmc = self.apu.dmc_dma_pending() && !Bus::dmc_dma_defer_load_entry(self);
4733        // W3-Stage-3 (`mc-r1-dmc-delayed-4015`): the TriCNES `_6502` line-4218
4734        // service gate — `DoDMCDMA && (APU_Status_DMC || implicit-abort)`. A
4735        // pending (or halted in-flight) DMC DMA whose APPLIED status dropped
4736        // is NOT serviced: the loop exits and the CPU resumes mid-DMA — the
4737        // emergent explicit abort. The engine's transient state (`in_dmc_dma`
4738        // / `dmc_halt` / the APU pending flag) persists, like TriCNES's stale
4739        // `DoDMCDMA`/`DMCDMA_Halt`, and resumes if the status re-applies.
4740        let dmc = dmc && self.apu.dmc_dma_serviceable();
4741        dmc || self.dma_pending.is_some() || self.uni_oam_active
4742    }
4743
4744    // W3-Stage-1: one unified-engine cycle at a CPU read (the preempted
4745    // instruction/operand read supplies the parked 6502 address).
4746    fn unified_dma_cycle(&mut self, halted_addr: u16) {
4747        self.unified_dma_cycle_impl(halted_addr);
4748    }
4749
4750    // W3-Stage-1: one unified-engine cycle at a CPU internal cycle — the bus
4751    // supplies its held (last-read) address, like `dmc_dma_step_idle`.
4752    fn unified_dma_cycle_idle(&mut self) {
4753        let halted = self.last_read_addr;
4754        self.unified_dma_cycle_impl(halted);
4755    }
4756
4757    // Program M (M-2): an OAM DMA is started + still owes cycles (in flight),
4758    // distinct from `oam_dma_pending` (which also covers a not-yet-started write).
4759    fn oam_dma_in_flight(&self) -> bool {
4760        self.dma_cycles_owed > 0
4761    }
4762
4763    // W3-Stage-0 (`mc-r1-counter-collapse`): a pending DMC DMA may overlap an OAM
4764    // DMA that is in flight OR still pending its first cycle. The collapse flag's
4765    // end-of-cycle byte-timer shift can surface the DMC arm in the one-iteration
4766    // gap between the `$4014` write and OAM's start-latch; lockstep `drain_dma`
4767    // latches OAM BEFORE its DMC-pending check, so the same arm overlaps OAM's
4768    // halt/alignment cycles there (the traced DMC+OAM Loop1 idx[6]/idx[7] events
4769    // with owed_at_begin == the FULL 514/513). Routing it to the standalone
4770    // `dmc_dma_step` instead pays a full unshared reload span = the idx[7] `03`.
4771    // Without the collapse flag the arm cannot surface in that gap, so the
4772    // original in-flight-only condition is preserved (audit-state invariant).
4773    fn oam_dma_overlap_ready(&self) -> bool {
4774        self.dma_cycles_owed > 0 || self.dma_pending.is_some()
4775    }
4776
4777    // Program M (M-2): whether the most recent `dmc_dma_step` did the GET.
4778    fn dmc_dma_last_was_get(&self) -> bool {
4779        self.dmc_step_was_get
4780    }
4781
4782    // Program M (M-2): advance ONE in-flight OAM cycle shared with a DMC halt
4783    // cycle. Mirrors the transfer/alignment body of `oam_dma_step` MINUS the
4784    // pending-latch (OAM is already in flight) and MINUS the time tick (the
4785    // surrounding start_cycle/end_cycle owns it). This is the per-cycle analogue
4786    // of lockstep `service_dmc_dma_during_oam` calling `clock_oam_dma_cycle` on
4787    // the DMC halt/dummy/align cycles, which is what produces the test's `02/01`
4788    // "DMC appears to take only 2/1 cycles" sweep entries.
4789    fn oam_dma_overlap_cycle(&mut self) {
4790        if self.dma_cycles_owed == 0 {
4791            return;
4792        }
4793        let total = self.dma_total;
4794        let alignment = if total == 514 { 2 } else { 1 };
4795        let consumed = total - self.dma_cycles_owed;
4796        if consumed >= alignment {
4797            let xfer_idx = consumed - alignment; // 0..512
4798            if xfer_idx & 1 == 0 {
4799                let src_addr =
4800                    (u16::from(self.dma_page) << 8) | u16::try_from(xfer_idx >> 1).unwrap_or(0);
4801                self.dma_byte = self.raw_oam_dma_read(src_addr);
4802            } else {
4803                self.oam_dma_put();
4804            }
4805        }
4806        self.dma_cycles_owed -= 1;
4807        if self.dma_cycles_owed == 0 {
4808            self.dma_total = 0;
4809        }
4810    }
4811
4812    // Program M (M-2, exact): begin ONE DMC-DMA-during-OAM event. Direct port of
4813    // lockstep `service_dmc_dma_during_oam`'s prologue (bus.rs ~2067): latch the
4814    // halt + the open-bus replay and return the UNCONDITIONAL halt/dummy/align
4815    // noop count (`dmc_dma_short() ? 2 : 3`) — NOT parity-gated. This replaces the
4816    // prior per-cycle "share-on-every-non-GET" heuristic (which OVER-GLUED the
4817    // looping reloads, reading 2C=44 runaway at the test's `02` positions) with
4818    // lockstep's exact noop/GET/realign accounting bound to ONE DMC DMA.
4819    fn dmc_overlap_begin(&mut self, halted_addr: u16) -> u32 {
4820        // W3-Stage-0 (`mc-r1-counter-collapse` boundary-start): when this event
4821        // STARTS a pending (not-yet-latched) `$4014` OAM DMA, the OAM halt
4822        // address is the CPU read this DMA pair is preempting — the same value
4823        // `oam_dma_step` would have latched. The owed/total latch itself is
4824        // deferred to the first `dmc_overlap_noop_cycle` (inside the cycle's
4825        // `start_cycle`, so the 514/513 `self.cycle & 1` parity matches the
4826        // position `oam_dma_step` would have evaluated it).
4827        if self.dma_pending.is_some() {
4828            self.dma_halt_addr = halted_addr;
4829        }
4830        self.in_dmc_dma = true;
4831        self.dmc_step_was_get = false;
4832        self.capture_deferred_dma_replay();
4833        if self.apu.dmc_dma_short() { 2 } else { 3 }
4834    }
4835
4836    // Program M (M-2, exact): one DMC halt/dummy/align cycle overlapping OAM.
4837    // Mirrors lockstep's noop-loop body: replay the held CPU read's side-effect,
4838    // then (if OAM still owes) advance one OAM slot — the 6502 is RDY-halted but
4839    // the OAM engine keeps its bus slot. The time tick is owned by the CPU's
4840    // surrounding start_cycle/end_cycle.
4841    fn dmc_overlap_noop_cycle(&mut self) {
4842        // W3-Stage-0 (`mc-r1-counter-collapse` boundary-start): latch a pending
4843        // `$4014` OAM DMA on the first shared halt cycle, mirroring
4844        // `oam_dma_step`'s start block at the same within-cycle position (after
4845        // `start_cycle`'s `cpu_clock` increments `self.cycle`, so the 514/513
4846        // parity choice is identical to the no-DMC counterfactual). The latched
4847        // OAM then consumes its halt/alignment/transfer slots through
4848        // `oam_dma_overlap_cycle` below, exactly like lockstep's
4849        // `service_dmc_dma_during_oam` after `drain_dma` started the OAM.
4850        if let Some(page) = self.dma_pending.take() {
4851            self.dma_page = page;
4852            self.dma_idx = 0;
4853            let extra: u32 = if self.cycle & 1 == 0 { 514 } else { 513 };
4854            self.dma_cycles_owed = extra;
4855            self.dma_total = extra;
4856        }
4857        let halted_addr = self.dma_halt_addr;
4858        self.replay_dma_noop_read(halted_addr);
4859        if self.dma_cycles_owed > 0 {
4860            self.oam_dma_overlap_cycle();
4861        } else {
4862            #[cfg(feature = "irq-timing-trace")]
4863            self.set_trace_dma_access(BusAccess::DmaRead, halted_addr, self.open_bus);
4864        }
4865    }
4866
4867    // Program M (M-2, exact): the DMC GET cycle. Mirrors lockstep's get block +
4868    // the R1 `dmc_dma_step` GET (bus.rs ~2530): fetch the sample (with the
4869    // `$4000` open-bus conflict the cluster brackets), deliver it, and clear the
4870    // DMC-DMA pending state. OAM is STALLED — it does NOT advance on the GET.
4871    fn dmc_overlap_get_cycle(&mut self) {
4872        let halted_addr = self.dma_halt_addr;
4873        let addr = self.apu.dmc_dma_addr();
4874        let byte = self.dmc_dma_read(addr, halted_addr);
4875        #[cfg(feature = "irq-timing-trace")]
4876        self.set_trace_dma_access(BusAccess::DmaRead, addr, byte);
4877        self.apu.complete_dmc_dma(byte);
4878        self.in_dmc_dma = false;
4879        self.dmc_step_was_get = true;
4880    }
4881
4882    // Program M (M-2, exact): the post-GET realign stall. Mirrors lockstep's
4883    // `if dma_cycles_owed > 0 { tick }` after the GET — ONE extra OAM-stalled
4884    // cycle (OAM does NOT advance; the parked CPU address stays on the bus) so
4885    // the next OAM read resumes on a later get. The cycle the prior per-cycle
4886    // scaffold was MISSING.
4887    fn dmc_overlap_realign_cycle(&mut self) {
4888        #[cfg(feature = "irq-timing-trace")]
4889        {
4890            let halted_addr = self.dma_halt_addr;
4891            self.set_trace_dma_access(BusAccess::DmaRead, halted_addr, self.open_bus);
4892        }
4893    }
4894
4895    fn dmc_abort_pending(&self) -> bool {
4896        self.apu.dmc_abort_pending()
4897    }
4898
4899    fn dmc_abort_is_get_cycle(&self) -> bool {
4900        // get = read half (TriCNES `!APU_PutCycle`); the 1-cycle abort DMA can
4901        // only land its halt on a get cycle.
4902        !self.apu.put_cycle()
4903    }
4904
4905    fn dmc_abort_halt_step(&mut self, halted_addr: u16) {
4906        // 1-cycle abort DMA (Y=1): one halt re-read of the held CPU address (the
4907        // DMASync `$4000` the spin polls — drives the open-bus conflict), then
4908        // cancel the reload + the abort. The surrounding `read1` start/end_cycle
4909        // advances the clock, so CalculateDMADuration measures exactly 1 cycle.
4910        self.replay_dma_noop_read(halted_addr);
4911        #[cfg(feature = "irq-timing-trace")]
4912        self.set_trace_dma_access(BusAccess::DmaRead, halted_addr, self.open_bus);
4913        self.apu.cancel_dmc_dma();
4914    }
4915
4916    fn dmc_abort_cancel(&mut self) {
4917        // Y=0: the abort matured on a put/write cycle — no DMA occurs. Clear the
4918        // reload + the abort with no halt cycle consumed.
4919        self.apu.cancel_dmc_dma();
4920    }
4921
4922    #[cfg(not(feature = "irq-timing-trace"))]
4923    fn trace_end_cycle(&mut self) {}
4924
4925    /// v2.0 R1c-1 diagnostic: record this instruction's `(pc, cpu_cycle)` into
4926    /// the per-instruction trace ring (default + R1 both; not mc-r1-gated).
4927    #[cfg(feature = "cpu-instr-cycle-trace")]
4928    fn trace_instr(&mut self, pc: u16, cpu_cycle: u64) {
4929        instr_trace::record(pc, cpu_cycle);
4930        // Latch the PC so the per-cycle `CycleRecord` push can stamp every
4931        // cycle (including DMA-insertion cycles, which hold this PC) with the
4932        // instruction currently executing — the TriCNES cross-diff landmark.
4933        #[cfg(feature = "irq-timing-trace")]
4934        {
4935            self.trace_last_pc = pc;
4936        }
4937    }
4938
4939    /// R1-path per-cycle trace push (mirrors the `tick_one_cpu_cycle`
4940    /// `CycleRecord` build for the legacy path). `irq_pending_apu_at_low` was
4941    /// snapshotted at cycle-start in `cpu_clock` (before `apu_advance_one`);
4942    /// `_at_high` is read here at end-of-cycle (after the access + DMC tick), so
4943    /// a record where low=0/high=1 is a frame-counter SET this cycle and
4944    /// low=1/high=0 is a DMA `$4015` CLEAR this cycle — the ordering signal the
4945    /// `DMA + $4015` diagnostic needs.
4946    #[cfg(feature = "irq-timing-trace")]
4947    fn trace_end_cycle(&mut self) {
4948        if self.irq_trace.is_none() {
4949            return;
4950        }
4951        let events = core::mem::take(&mut self.trace_a12_scratch);
4952        let bus_access = core::mem::replace(&mut self.trace_bus_access, BusAccess::Idle);
4953        let bus_addr = core::mem::take(&mut self.trace_bus_addr);
4954        let bus_data = core::mem::take(&mut self.trace_bus_data);
4955        let mapper_irq = self.mapper.irq_pending();
4956        let rec = CycleRecord {
4957            cpu_cycle: self.cycle.wrapping_sub(1),
4958            pc: self.trace_last_pc,
4959            ppu_scanline: self.trace_r1_scanline_start,
4960            ppu_dot: self.trace_r1_dot_start,
4961            ppu_frame: self.trace_r1_frame_start,
4962            irq_pending_mapper_at_low: mapper_irq,
4963            irq_pending_apu_at_low: self.irq_snapshot_apu_at_low,
4964            irq_pending_mapper_at_high: mapper_irq,
4965            irq_pending_apu_at_high: self.apu.irq_line(),
4966            nmi_line: self.ppu.nmi_line(),
4967            a12_events: events,
4968            dmc_dma_pending_pre: false,
4969            dmc_dma_pending_post: self.apu.dmc_dma_pending(),
4970            dmc_dma_short_post: self.apu.dmc_dma_short(),
4971            dmc_abort_pending_post: self.apu.dmc_abort_pending(),
4972            dmc_abort_delay_post: self.apu.dmc_abort_delay(),
4973            dmc_dma_cooldown_post: self.apu.dmc_dma_cooldown(),
4974            dmc_dma_delay_post: self.apu.dmc_dma_delay(),
4975            apu_phase_post: self.apu.apu_phase(),
4976            in_dmc_dma: self.in_dmc_dma,
4977            dma_cycles_owed: self.dma_cycles_owed,
4978            bus_access,
4979            bus_addr,
4980            bus_data,
4981            put_cycle_post: self.apu.put_cycle(),
4982            dmc_timer_post: self.apu.dmc_timer(),
4983            dmc_bits_remaining_post: self.apu.dmc_bits_remaining(),
4984            dmc_silence_post: self.apu.dmc_silence(),
4985            dmc_buffer_full_post: self.apu.dmc_buffer_full(),
4986        };
4987        if let Some(t) = self.irq_trace.as_mut() {
4988            t.push(rec);
4989        }
4990    }
4991}
4992
4993#[cfg(test)]
4994mod four_score_tests {
4995    use super::*;
4996    use crate::controller::Buttons;
4997
4998    /// Minimal NROM (16-byte iNES header + 16 KiB PRG + 8 KiB CHR). Enough to
4999    /// construct a `LockstepBus`; these tests never run the CPU.
5000    fn test_bus() -> LockstepBus {
5001        let mut rom = Vec::with_capacity(16 + 0x4000 + 0x2000);
5002        rom.extend_from_slice(b"NES\x1A");
5003        rom.push(1); // 16 KiB PRG
5004        rom.push(1); // 8 KiB CHR
5005        rom.extend_from_slice(&[0u8; 10]);
5006        rom.extend_from_slice(&[0u8; 0x4000]);
5007        rom.extend_from_slice(&[0u8; 0x2000]);
5008        LockstepBus::new(&rom).expect("synthetic NROM parses")
5009    }
5010
5011    fn strobe(bus: &mut LockstepBus) {
5012        bus.commit_controller_strobe(1);
5013        bus.commit_controller_strobe(0);
5014    }
5015
5016    #[test]
5017    fn famicom_microphone_drives_4016_bit2() {
5018        let mut bus = test_bus();
5019        // Default: mic released -> $4016 bit 2 clear (byte-identical stock read).
5020        assert!(!bus.microphone());
5021        assert_eq!(bus.peek_cpu(0x4016) & 0x04, 0x00, "mic off -> D2 clear");
5022        // Press the mic: $4016 bit 2 reads 1.
5023        bus.set_microphone(true);
5024        assert!(bus.microphone());
5025        assert_eq!(bus.peek_cpu(0x4016) & 0x04, 0x04, "mic on -> D2 set");
5026        // $4017 is unaffected (the Famicom mic is a $4016-only signal).
5027        assert_eq!(bus.peek_cpu(0x4017) & 0x04, 0x00, "mic never touches $4017");
5028        // Release restores the stock read.
5029        bus.set_microphone(false);
5030        assert_eq!(
5031            bus.peek_cpu(0x4016) & 0x04,
5032            0x00,
5033            "mic released -> D2 clear"
5034        );
5035    }
5036
5037    #[test]
5038    fn four_score_off_reads_like_standard_controller() {
5039        let mut bus = test_bus();
5040        assert!(!bus.four_score());
5041        bus.set_buttons(0, Buttons::A);
5042        strobe(&mut bus);
5043        // A, then 7 zeros, then 1s — exactly the standard pad.
5044        assert_eq!(bus.read_port(0), 1);
5045        for _ in 0..7 {
5046            assert_eq!(bus.read_port(0), 0);
5047        }
5048        for _ in 0..3 {
5049            assert_eq!(bus.read_port(0), 1);
5050        }
5051    }
5052
5053    #[test]
5054    fn four_score_multiplexes_four_pads_and_signature() {
5055        let mut bus = test_bus();
5056        bus.set_four_score(true);
5057        bus.set_buttons(0, Buttons::A); // pad 1
5058        bus.set_buttons(2, Buttons::B); // pad 3
5059        bus.set_buttons(1, Buttons::SELECT); // pad 2
5060        bus.set_buttons(3, Buttons::START); // pad 4
5061        strobe(&mut bus);
5062
5063        // Port 0 ($4016): pad1 (A) | pad3 (B) | signature 0x08 (LSB-first) | 1.
5064        let p0: Vec<u8> = (0..25).map(|_| bus.read_port(0)).collect();
5065        assert_eq!(&p0[0..8], &[1, 0, 0, 0, 0, 0, 0, 0], "pad 1: A");
5066        assert_eq!(&p0[8..16], &[0, 1, 0, 0, 0, 0, 0, 0], "pad 3: B");
5067        assert_eq!(&p0[16..24], &[0, 0, 0, 1, 0, 0, 0, 0], "signature 0x08");
5068        assert_eq!(p0[24], 1, "past 24 reads -> 1");
5069
5070        // Port 1 ($4017): pad2 (Select) | pad4 (Start) | signature 0x04 | 1.
5071        let p1: Vec<u8> = (0..25).map(|_| bus.read_port(1)).collect();
5072        assert_eq!(&p1[0..8], &[0, 0, 1, 0, 0, 0, 0, 0], "pad 2: Select");
5073        assert_eq!(&p1[8..16], &[0, 0, 0, 1, 0, 0, 0, 0], "pad 4: Start");
5074        assert_eq!(&p1[16..24], &[0, 0, 1, 0, 0, 0, 0, 0], "signature 0x04");
5075        assert_eq!(p1[24], 1);
5076    }
5077
5078    #[test]
5079    fn four_score_state_round_trips_through_save_state() {
5080        let mut bus = test_bus();
5081        bus.set_four_score(true);
5082        bus.set_buttons(2, Buttons::B | Buttons::A); // pad 3
5083        bus.set_buttons(3, Buttons::START); // pad 4
5084        strobe(&mut bus);
5085        let _ = bus.read_port(0); // advance idx[0] off zero
5086        let blob = crate::bus_snapshot::encode_bus(&bus);
5087
5088        let mut restored = test_bus();
5089        crate::bus_snapshot::decode_bus(&mut restored, &blob).unwrap();
5090        assert!(restored.four_score());
5091        assert_eq!(restored.controller(2).buttons(), Buttons::B | Buttons::A);
5092        assert_eq!(restored.controller(3).buttons(), Buttons::START);
5093    }
5094
5095    #[test]
5096    fn override_nt_addr_maps_per_mirroring() {
5097        use rustynes_mappers::Mirroring;
5098        // Logical tables $2000/$2400/$2800/$2C00, offset 0.
5099        // Horizontal: tables 0/1 -> bank 0, 2/3 -> bank 1.
5100        assert_eq!(override_nt_addr(Mirroring::Horizontal, 0x2000), 0x000);
5101        assert_eq!(override_nt_addr(Mirroring::Horizontal, 0x2400), 0x000);
5102        assert_eq!(override_nt_addr(Mirroring::Horizontal, 0x2800), 0x400);
5103        assert_eq!(override_nt_addr(Mirroring::Horizontal, 0x2C00), 0x400);
5104        // Vertical: tables 0/2 -> bank 0, 1/3 -> bank 1.
5105        assert_eq!(override_nt_addr(Mirroring::Vertical, 0x2000), 0x000);
5106        assert_eq!(override_nt_addr(Mirroring::Vertical, 0x2400), 0x400);
5107        assert_eq!(override_nt_addr(Mirroring::Vertical, 0x2800), 0x000);
5108        assert_eq!(override_nt_addr(Mirroring::Vertical, 0x2C00), 0x400);
5109        // Local offset preserved.
5110        assert_eq!(override_nt_addr(Mirroring::Vertical, 0x2456), 0x456);
5111    }
5112
5113    #[test]
5114    fn mirroring_override_round_trips_through_save_state() {
5115        use rustynes_mappers::Mirroring;
5116        let mut bus = test_bus();
5117        assert_eq!(bus.mirroring_override(), None, "default is no override");
5118        bus.set_mirroring_override(Some(Mirroring::Vertical));
5119        let blob = crate::bus_snapshot::encode_bus(&bus);
5120        let mut restored = test_bus();
5121        crate::bus_snapshot::decode_bus(&mut restored, &blob).unwrap();
5122        assert_eq!(restored.mirroring_override(), Some(Mirroring::Vertical));
5123    }
5124
5125    #[test]
5126    fn pre_v1_7_0_save_state_decodes_with_four_score_off() {
5127        // A v1.7.0 blob carries 11 trailing Four Score bytes (1 flag + 6
5128        // controllers34 + 2 idx + 2 sig); the W3-Stage-4 tail appends 22
5129        // more (dmc_halt + 3 uni_oam flags + uni_oam_addr u16 + ppu_clock
5130        // u64 + dma_mc_consumed u64); the v2.1.0 tail appends 2 more (one
5131        // expansion-device tag byte per port, both `None`); the v1.1.0 beta.1
5132        // tail appends 1 more (the nametable mirroring-override tag, `None`).
5133        // Truncating all 36 simulates a pre-v1.7.0 save, which must still load
5134        // with the adapter off (and no expansion device / override).
5135        let mut bus = test_bus();
5136        bus.set_four_score(true);
5137        let blob = crate::bus_snapshot::encode_bus(&bus);
5138        let old = &blob[..blob.len() - 36];
5139        let mut restored = test_bus();
5140        restored.set_four_score(true); // prove decode actively turns it off
5141        crate::bus_snapshot::decode_bus(&mut restored, old).unwrap();
5142        assert!(!restored.four_score());
5143    }
5144
5145    #[test]
5146    fn expansion_device_state_round_trips_through_save_state() {
5147        use crate::input_device::{InputDevice, VausState, ZapperState};
5148        let mut bus = test_bus();
5149        // Vaus on port 0, Zapper on port 1, with distinctive non-default state.
5150        bus.set_expansion_device(0, Some(InputDevice::Vaus(VausState::new())));
5151        bus.set_paddle(0, 0x3C, true);
5152        bus.set_expansion_device(1, Some(InputDevice::Zapper(ZapperState::new())));
5153        bus.set_zapper(1, 100, 50, true);
5154        let blob = crate::bus_snapshot::encode_bus(&bus);
5155
5156        let mut restored = test_bus();
5157        crate::bus_snapshot::decode_bus(&mut restored, &blob).unwrap();
5158        match restored.expansion_device(0) {
5159            Some(InputDevice::Vaus(v)) => {
5160                assert_eq!(v.position_raw(), 0x3C);
5161                assert!(v.fire_raw());
5162            }
5163            other => panic!("port 0 should be a Vaus, got {other:?}"),
5164        }
5165        match restored.expansion_device(1) {
5166            Some(InputDevice::Zapper(z)) => {
5167                assert_eq!(z.x_raw(), 100);
5168                assert_eq!(z.y_raw(), 50);
5169                assert!(z.trigger_raw());
5170            }
5171            other => panic!("port 1 should be a Zapper, got {other:?}"),
5172        }
5173    }
5174
5175    /// With the beam-relative Zapper model on, a debugger peek of `$4017` must
5176    /// return the SAME light contribution the CPU read produces — at the
5177    /// pre-render line and at a visible line — and must not advance device
5178    /// state.
5179    ///
5180    /// Regression pin for the `peek_port` parity fix: before it, `peek_port`
5181    /// fell through to the overlay's frame-granular `peek()` and could report a
5182    /// different light bit than `read_port` at the same instant. (This is the
5183    /// real defect the fix addressed; the separate `read_before_visible`
5184    /// conversion fallback is defensive, since `scanline()` is non-negative on
5185    /// every current region — pre-render is line 261 NTSC / 311 PAL, not -1.)
5186    #[test]
5187    fn temporal_zapper_debugger_peek_matches_cpu_read() {
5188        use crate::input_device::{InputDevice, ZapperState};
5189
5190        // $4017 bit 3 is the (inverted) light bit; the open-bus upper bits differ
5191        // between the read and peek paths, so compare only the device bit.
5192        const LIGHT: u8 = 0b0000_1000;
5193
5194        // The two models are constructed to DISAGREE, so the test fails if
5195        // `peek_port` does not mirror `read_port`'s temporal branch:
5196        //   * frame model (`peek()` -> `ZapperState::read()`) reads `light_seen`,
5197        //     which we force TRUE via `from_parts` -> reports light;
5198        //   * temporal model (`read_at_scanline`) reads the current scanline. A
5199        //     fresh bus sits on the pre-render line (261 NTSC), past the
5200        //     photodiode hold window -> reports NO light.
5201        // So a peek that (wrongly) fell through to the frame `peek()` would
5202        // return light while the CPU read returns none. No framebuffer or
5203        // scanline poke is needed — the injected `light_seen` supplies the
5204        // divergence, and the default dark framebuffer keeps the temporal path
5205        // at no-light on every line anyway.
5206        let mut bus = test_bus();
5207        // from_parts(x, y, trigger, light_seen): trigger + light_seen both true.
5208        let zapper = ZapperState::from_parts(128, 12, true, true);
5209        bus.set_expansion_device(1, Some(InputDevice::Zapper(zapper)));
5210        bus.set_zapper_temporal_light(true);
5211
5212        assert!(
5213            bus.ppu.scanline() > 239,
5214            "fresh PPU is on the pre-render line"
5215        );
5216        let cpu = bus.read_port(1) & LIGHT;
5217        let peek = bus.peek_port(1) & LIGHT;
5218        assert_eq!(cpu, LIGHT, "temporal read at pre-render reports NO light");
5219        assert_eq!(
5220            peek, cpu,
5221            "debugger peek must match the CPU read, not the frame `peek()` \
5222             (which would report light from the injected light_seen)",
5223        );
5224
5225        // The peek must be side-effect-free: repeating it does not change the
5226        // answer (guards a regression where a peek routes through mutating state).
5227        assert_eq!(bus.peek_port(1) & LIGHT, peek);
5228        assert_eq!(bus.peek_port(1) & LIGHT, peek);
5229    }
5230
5231    #[test]
5232    fn pre_v2_1_0_save_state_decodes_with_no_expansion_device() {
5233        // A pre-v2.1.0 blob lacks the 2 trailing device-tag bytes (one None
5234        // tag per port); a pre-v1.1.0 blob also lacks the mirroring-override
5235        // tag. With nothing attached the encoder writes `[0, 0]` + `[0]`, so
5236        // truncating those 3 trailing bytes reproduces an older save — which
5237        // must still load with both ports unplugged and no override.
5238        let bus = test_bus();
5239        let blob = crate::bus_snapshot::encode_bus(&bus);
5240        let old = &blob[..blob.len() - 3];
5241        let mut restored = test_bus();
5242        crate::bus_snapshot::decode_bus(&mut restored, old).unwrap();
5243        assert!(restored.expansion_device(0).is_none());
5244        assert!(restored.expansion_device(1).is_none());
5245        assert_eq!(restored.mirroring_override(), None);
5246    }
5247
5248    #[test]
5249    fn power_pad_state_round_trips_through_save_state() {
5250        use crate::input_device::{InputDevice, PowerPadState};
5251        let mut bus = test_bus();
5252        bus.set_expansion_device(1, Some(InputDevice::PowerPad(PowerPadState::new())));
5253        bus.set_power_pad(1, 0b1010_0101_0011);
5254        let blob = crate::bus_snapshot::encode_bus(&bus);
5255        let mut restored = test_bus();
5256        crate::bus_snapshot::decode_bus(&mut restored, &blob).unwrap();
5257        match restored.expansion_device(1) {
5258            Some(InputDevice::PowerPad(p)) => {
5259                assert_eq!(p.buttons_raw(), 0b1010_0101_0011);
5260            }
5261            other => panic!("expected a Power Pad on port 1, got {other:?}"),
5262        }
5263    }
5264
5265    #[test]
5266    fn snes_mouse_state_round_trips_through_save_state() {
5267        use crate::input_device::{InputDevice, SnesMouseState};
5268        let mut bus = test_bus();
5269        bus.set_expansion_device(0, Some(InputDevice::SnesMouse(SnesMouseState::new())));
5270        bus.set_snes_mouse(0, -7, 9, true, false, 2);
5271        let blob = crate::bus_snapshot::encode_bus(&bus);
5272        let mut restored = test_bus();
5273        crate::bus_snapshot::decode_bus(&mut restored, &blob).unwrap();
5274        match restored.expansion_device(0) {
5275            Some(InputDevice::SnesMouse(m)) => {
5276                assert_eq!(m.dx_raw(), -7);
5277                assert_eq!(m.dy_raw(), 9);
5278                assert!(m.left_raw());
5279                assert!(!m.right_raw());
5280                assert_eq!(m.sensitivity_raw(), 2);
5281            }
5282            other => panic!("expected a SNES mouse on port 0, got {other:?}"),
5283        }
5284    }
5285
5286    #[test]
5287    fn family_keyboard_state_round_trips_through_save_state() {
5288        use crate::input_device::{FamilyKeyboardState, InputDevice};
5289        let mut bus = test_bus();
5290        bus.set_expansion_device(
5291            1,
5292            Some(InputDevice::FamilyKeyboard(FamilyKeyboardState::new())),
5293        );
5294        let keys = [0x01, 0x10, 0x00, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00];
5295        bus.set_family_keyboard(1, keys);
5296        let blob = crate::bus_snapshot::encode_bus(&bus);
5297        let mut restored = test_bus();
5298        crate::bus_snapshot::decode_bus(&mut restored, &blob).unwrap();
5299        match restored.expansion_device(1) {
5300            Some(InputDevice::FamilyKeyboard(k)) => {
5301                assert_eq!(k.keys_raw(), keys);
5302            }
5303            other => panic!("expected a Family BASIC keyboard on port 1, got {other:?}"),
5304        }
5305    }
5306}