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}