Skip to main content

rusty2600_core/
bus.rs

1//! The Bus owns everything mutable.
2
3use alloc::vec::Vec;
4
5use rusty2600_cart::Board;
6use rusty2600_cpu::CpuBus;
7use rusty2600_riot::Riot;
8use rusty2600_tia::Tia;
9
10/// A single CPU write observed by the debugger's optional [`WriteLog`],
11/// tagged with the TIA beam position at the moment it happened.
12#[derive(Debug, Clone, Copy)]
13pub struct WriteEvent {
14    /// The scanline the write landed on.
15    pub scanline: u16,
16    /// The color clock within that scanline.
17    pub color_clock: u16,
18    /// The 13-bit CPU address written to.
19    pub addr: u16,
20    /// The byte written.
21    pub value: u8,
22}
23
24/// The debugger's optional per-write log (`rusty2600-frontend`'s
25/// `debug-hooks` feature enables it while the debugger overlay is open).
26///
27/// Disabled by default — near-zero cost when off (one `bool` check per
28/// write). Deliberately `#[serde(skip)]` on [`Bus`]: this is debug-tooling
29/// state, not part of the emulator's real state, and must never end up in a
30/// save-state (see `docs/adr/0007-save-state-versioning.md`).
31#[derive(Debug, Default, Clone)]
32pub struct WriteLog {
33    /// Whether writes are currently being recorded.
34    pub enabled: bool,
35    events: Vec<WriteEvent>,
36}
37
38impl WriteLog {
39    /// Caps memory even if a caller forgets to [`Self::clear`] between
40    /// frames — the oldest event is dropped once this many are held.
41    const MAX_EVENTS: usize = 4096;
42
43    /// The events recorded since the last [`Self::clear`], oldest first.
44    #[must_use]
45    pub fn events(&self) -> &[WriteEvent] {
46        &self.events
47    }
48
49    /// Drops all recorded events (called once per frame by the debugger).
50    pub fn clear(&mut self) {
51        self.events.clear();
52    }
53
54    fn record(&mut self, ev: WriteEvent) {
55        if !self.enabled {
56            return;
57        }
58        if self.events.len() >= Self::MAX_EVENTS {
59            self.events.remove(0);
60        }
61        self.events.push(ev);
62    }
63}
64
65#[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
66/// The main system bus for Rusty2600, holding the chips.
67pub struct Bus {
68    /// The TIA video/audio chip.
69    pub tia: Tia,
70    /// The RIOT RAM/Timer/IO chip.
71    pub riot: Riot,
72    /// The cartridge board (mapper).
73    pub board: Option<rusty2600_cart::Cartridge>,
74    /// Open bus value (last driven value).
75    pub open_bus: u8,
76    /// The debugger's optional write log — see [`WriteLog`].
77    #[serde(skip)]
78    pub write_log: WriteLog,
79}
80
81impl core::fmt::Debug for Bus {
82    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
83        f.debug_struct("Bus")
84            .field("tia", &self.tia)
85            .field("riot", &self.riot)
86            .field("board", &self.board.as_ref().map(|_| "<Cartridge>"))
87            .field("open_bus", &self.open_bus)
88            .finish()
89    }
90}
91
92impl Bus {
93    #[must_use]
94    pub fn new() -> Self {
95        Self::default()
96    }
97
98    /// Side-effect-free read, for debugger/tooling use only: a real
99    /// `cpu_read` can trigger bankswitch hotspots, RIOT's INTIM
100    /// read-clears-underflow-flag behavior, and cart `snoop_read` side
101    /// effects, none of which a memory-viewer peek should ever cause. Reads
102    /// via a full clone of `self` (cheap relative to a UI refresh cadence,
103    /// and correctly avoids the `unsafe`-free crate's inability to alias a
104    /// `&mut` for a "no-op" read) so the real system state is untouched.
105    ///
106    /// For more than a byte or two, prefer [`Self::peek_range`] — it clones
107    /// `self` ONCE and reads every byte from that one clone, instead of
108    /// paying a full `Bus` clone (TIA + RIOT + the cart's ROM/RAM) per byte.
109    #[must_use]
110    pub fn peek(&self, addr: u16) -> u8 {
111        self.clone().cpu_read(addr)
112    }
113
114    /// Side-effect-free read of `len` consecutive addresses starting at
115    /// `base` (wrapping at 16 bits), for a debugger memory viewer or a
116    /// disassembly window. Clones `self` once, then reads every byte from
117    /// that single clone — any bankswitch hotspot triggered by reading byte
118    /// N is visible to byte N+1's read (an honest reflection of "whatever
119    /// the bank state currently is," same caveat any bank-switched-system
120    /// memory viewer has), but the REAL system is never touched.
121    #[must_use]
122    pub fn peek_range(&self, base: u16, len: u16) -> alloc::vec::Vec<u8> {
123        let mut clone = self.clone();
124        (0..len)
125            .map(|i| clone.cpu_read(base.wrapping_add(i)))
126            .collect()
127    }
128
129    pub fn cpu_read(&mut self, addr: u16) -> u8 {
130        let addr = addr & 0x1FFF;
131
132        // 6502 open bus behavior: typically the last value on the data bus is returned.
133        // We will read the mapped component and if it's open bus, we return self.open_bus.
134
135        let val = if addr & 0x1000 != 0 {
136            // A12 = 1 -> Cartridge
137            if let Some(board) = &mut self.board {
138                let val = board.cpu_read(addr);
139                self.apply_oob_pokes();
140                val
141            } else {
142                self.open_bus
143            }
144        } else {
145            // A12 = 0 -> Console
146            let val = if addr & 0x0080 == 0 {
147                // A7 = 0 -> TIA
148                self.tia.cpu_read(addr)
149            } else if addr & 0x0200 == 0 {
150                // A7 = 1, A9 = 0 -> RIOT RAM
151                self.riot.cpu_read(addr)
152            } else {
153                // A7 = 1, A9 = 1 -> RIOT I/O and Timers
154                self.riot.cpu_read(addr)
155            };
156            // See snoop_write's rationale: UA/0840/FE bankswitch on reads
157            // the console routes to TIA/RIOT, observing the resulting value
158            // (never redirecting it).
159            if let Some(board) = &mut self.board {
160                board.snoop_read(addr, val);
161            }
162            val
163        };
164
165        self.open_bus = val;
166        val
167    }
168
169    pub fn cpu_write(&mut self, addr: u16, val: u8) {
170        let addr = addr & 0x1FFF;
171        self.open_bus = val;
172
173        self.write_log.record(WriteEvent {
174            scanline: self.tia.scanline,
175            color_clock: self.tia.color_clock,
176            addr,
177            value: val,
178        });
179
180        if addr & 0x1000 != 0 {
181            // A12 = 1 -> Cartridge
182            if let Some(board) = &mut self.board {
183                board.cpu_write(addr, val);
184                self.apply_oob_pokes();
185            }
186        } else {
187            // A12 = 0 -> Console. Real cart edge connectors are wired to every
188            // address line, not just A12: 3F/3E/UA/0840/FE all bankswitch on
189            // writes the console routes to TIA/RIOT, so the board gets a
190            // look too (default no-op for the overwhelming majority of boards
191            // that only care about their own $1000+ window).
192            if let Some(board) = &mut self.board {
193                board.snoop_write(addr, val);
194            }
195            if addr & 0x0080 == 0 {
196                // A7 = 0 -> TIA
197                self.tia.cpu_write(addr, val);
198            } else if addr & 0x0200 == 0 {
199                // A7 = 1, A9 = 0 -> RIOT RAM
200                self.riot.cpu_write(addr, val);
201            } else {
202                // A7 = 1, A9 = 1 -> RIOT I/O and Timers
203                self.riot.cpu_write(addr, val);
204            }
205        }
206    }
207
208    /// Apply any out-of-band RIOT-RAM pokes the board staged this access
209    /// (see `rusty2600_cart::Board::take_oob_pokes`'s doc comment — used by
210    /// `BankAr`'s dummy-BIOS load handoff). Bypasses `Riot::cpu_write`
211    /// deliberately: these are direct RAM patches with no console-visible
212    /// bus cycle of their own, mirroring Stella's `System::pokeOob`.
213    fn apply_oob_pokes(&mut self) {
214        if let Some(board) = &mut self.board {
215            for (addr, val) in board.take_oob_pokes() {
216                self.riot.ram[(addr & 0x7F) as usize] = val;
217            }
218        }
219    }
220}
221
222impl CpuBus for Bus {
223    fn read(&mut self, addr: u16) -> u8 {
224        self.cpu_read(addr)
225    }
226
227    fn write(&mut self, addr: u16, val: u8) {
228        self.cpu_write(addr, val)
229    }
230}
231
232pub trait VideoBus {
233    fn video_read(&mut self, addr: u16) -> u8;
234}
235
236pub trait AudioBus {
237    fn audio_sample(&self) -> u8;
238}
239
240impl AudioBus for Bus {
241    fn audio_sample(&self) -> u8 {
242        self.tia.audio.sample()
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn peek_does_not_mutate_riot_ram() {
252        let mut bus = Bus::new();
253        bus.cpu_write(0x0080, 0x42);
254        let before = bus.riot.ram;
255        assert_eq!(bus.peek(0x0080), 0x42);
256        assert_eq!(bus.riot.ram, before, "peek must not mutate RIOT RAM");
257    }
258
259    #[test]
260    fn peek_does_not_advance_cart_bank_state() {
261        // Plain F8 (8 KiB, 2x4K banks); default bank is 1 (the last bank).
262        let mut rom = [0u8; 0x2000];
263        rom[0x0000] = 0x11; // bank 0, offset 0
264        rom[0x1000] = 0x22; // bank 1, offset 0
265        let mut bus = Bus::new();
266        bus.board = rusty2600_cart::detect(&rom);
267        assert_eq!(bus.peek(0x1000), 0x22, "starts on bank 1 (the default)");
268        bus.peek(0x1FF8); // would select bank 0 if peek had side effects
269        assert_eq!(
270            bus.peek(0x1000),
271            0x22,
272            "peek must not trigger bankswitch hotspots"
273        );
274    }
275
276    #[test]
277    fn write_log_disabled_by_default_records_nothing() {
278        let mut bus = Bus::new();
279        bus.cpu_write(0x00, 0x02); // VSYNC
280        assert!(bus.write_log.events().is_empty());
281    }
282
283    #[test]
284    fn write_log_records_when_enabled() {
285        let mut bus = Bus::new();
286        bus.write_log.enabled = true;
287        bus.cpu_write(0x06, 0xAB); // COLUP0
288        let events = bus.write_log.events();
289        assert_eq!(events.len(), 1);
290        assert_eq!(events[0].addr, 0x06);
291        assert_eq!(events[0].value, 0xAB);
292    }
293
294    #[test]
295    fn write_log_clear_drops_all_events() {
296        let mut bus = Bus::new();
297        bus.write_log.enabled = true;
298        bus.cpu_write(0x06, 0xAB);
299        bus.cpu_write(0x07, 0xCD);
300        bus.write_log.clear();
301        assert!(bus.write_log.events().is_empty());
302    }
303
304    #[test]
305    fn write_log_caps_at_max_events() {
306        let mut bus = Bus::new();
307        bus.write_log.enabled = true;
308        for i in 0..5000u16 {
309            bus.cpu_write(0x06, u8::try_from(i % 256).unwrap_or(0));
310        }
311        assert_eq!(bus.write_log.events().len(), WriteLog::MAX_EVENTS);
312    }
313}