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