rustysnes_cart/coproc/superfx.rs
1//! The Super FX board — the GSU wired into a LoROM cartridge.
2//!
3//! Super FX carts (GSU-1: Star Fox, Stunt Race FX, Vortex; GSU-2: Yoshi's Island, Doom) carry an
4//! Argonaut GSU ([`crate::coproc::gsu::Gsu`]) plus dedicated Game Pak RAM that holds the plotted
5//! bitmap. The board owns the ROM (shared, read-only) and the Game Pak RAM, intercepts the GSU
6//! register window, and arbitrates the shared ROM/RAM bus between the SNES CPU and the GSU. There
7//! is **no chip-ROM dump** — the GSU program lives in the cartridge ROM — so the board is
8//! functional the moment a Super FX cart loads (`docs/cart.md`, `docs/adr/0003`).
9//!
10//! ## CPU-side memory map (LoROM Super FX, the de-facto board the cartridge DB encodes)
11//!
12//! | Region (banks : addr) | Target |
13//! |------------------------------------|-------------------------------------------|
14//! | `$00-$3F,$80-$BF : $3000-$32FF` | GSU registers + opcode cache window |
15//! | `$00-$3F,$80-$BF : $8000-$FFFF` | Game Pak ROM (LoROM windows) |
16//! | `$40-$5F,$C0-$DF : $0000-$FFFF` | Game Pak ROM (linear) |
17//! | `$70-$71,$F0-$F1 : $0000-$FFFF` | Game Pak RAM (the GSU plot bitmap) |
18//! | `$00-$3F,$80-$BF : $6000-$7FFF` | Game Pak RAM low window (8 KiB) |
19//!
20//! ## Bus arbitration (`docs/cart.md` edge case #3)
21//!
22//! While the GSU runs with Go set it owns whichever of ROM/RAM its SCMR `RON`/`RAN` bits grant;
23//! the CPU cannot read them. A CPU ROM read during GSU ROM ownership returns the hardware "snooze
24//! vector" (ares `CPUROM::read`); a CPU RAM read during GSU RAM ownership returns open bus. With
25//! true CPU/GSU interleaving (see below) the CPU genuinely can execute its own instructions
26//! while a `Go` burst is in flight, so this arbitration is a live path, not just a narrow corner
27//! case around the instant Go is set.
28//!
29//! ## Host-synchronization ([`Board::coprocessor_tick`])
30//!
31//! Setting Go (a CPU write to `$301F`, R15's high byte) only arms the GSU — it does **not** run
32//! it to completion inline, and does not even run one whole instruction inline. The Bus
33//! (`rustysnes-core`) advances it by exactly one master clock per [`Board::coprocessor_tick`]
34//! call, from inside its own per-master-tick loop (`advance_master`) — the same place the PPU
35//! dot and the APU's SMP-cycle release happen, called unconditionally every tick regardless of
36//! whether a coprocessor is even present. This mirrors ares's `SuperFX : Thread` cothread, which
37//! the scheduler interleaves with the main CPU at native master-clock granularity
38//! (`sfc/coprocessor/superfx/superfx.cpp`'s `Thread::create` + `timing.cpp`'s
39//! `Thread::synchronize` after every access) — the CPU can do unrelated work, or even service a
40//! *second* `Go` burst, in between two ticks of the first one, instead of only ever observing
41//! the coprocessor's result after an entire burst (or an entire multi-`Go` render split across a
42//! left half and a right half) drains "atomically" inside the one bus write that armed it
43//! (`Gsu::tick` doc has the detail on what is, and isn't, deferred).
44
45// Chip-name jargon (GSU, Super FX, SCMR, …) is not Rust code; the rom/ram-mask pairs are
46// deliberately parallel names; ROM/RAM sizes narrow from usize at the bus boundary.
47#![allow(
48 clippy::doc_markdown,
49 clippy::similar_names,
50 clippy::cast_possible_truncation
51)]
52
53use alloc::boxed::Box;
54use alloc::vec;
55
56use rustysnes_savestate::{SaveReader, SaveStateError, SaveWriter};
57
58use crate::board::{Board, Coprocessor, MappedAddr};
59use crate::coproc::gsu::{Gsu, GsuMem};
60use crate::header::MapMode;
61
62/// The hardware "snooze vector" the CPU reads from ROM while the GSU owns the ROM bus
63/// (ares `SuperFX::CPUROM::read`).
64const SNOOZE_VECTOR: [u8; 16] = [
65 0x00, 0x01, 0x00, 0x01, 0x04, 0x01, 0x00, 0x01, 0x00, 0x01, 0x08, 0x01, 0x00, 0x01, 0x0c, 0x01,
66];
67
68/// Round `n` up to a power of two (ares `romSizeRound`); `0` maps to `0`.
69const fn round_pow2(n: u32) -> u32 {
70 if n == 0 {
71 return 0;
72 }
73 if n.is_power_of_two() {
74 return n;
75 }
76 n.next_power_of_two()
77}
78
79/// A LoROM cartridge carrying a Super FX GSU + its Game Pak RAM.
80pub struct SuperFxBoard {
81 gsu: Gsu,
82 rom: Box<[u8]>,
83 ram: Box<[u8]>,
84 rom_mask: u32,
85 ram_mask: u32,
86 /// Host accesses to the GSU register window (debugger / liveness diagnostics).
87 host_accesses: u64,
88}
89
90impl core::fmt::Debug for SuperFxBoard {
91 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
92 f.debug_struct("SuperFxBoard")
93 .field("rom_len", &self.rom.len())
94 .field("ram_len", &self.ram.len())
95 .field("gsu", &self.gsu)
96 .field("host_accesses", &self.host_accesses)
97 .finish_non_exhaustive()
98 }
99}
100
101impl SuperFxBoard {
102 /// Game Pak RAM minimum (64 KiB) — covers the homebrew plot suites; commercial carts override
103 /// from the header. Star Fox is 32 KiB, Yoshi's Island 128 KiB; a generous default never
104 /// under-allocates the GSU plot target when the header omits the size.
105 const RAM_MIN: usize = 0x1_0000;
106
107 /// Build a Super FX board over `rom`, sizing the Game Pak RAM from the header (`sram_size`)
108 /// but falling back to the `RAM_MIN` 64 KiB minimum if 0, and rounding to a power of two.
109 #[must_use]
110 pub fn new(rom: Box<[u8]>, sram_size: usize) -> Self {
111 let rom_len = rom.len();
112 let rom_mask = round_pow2(rom_len as u32).wrapping_sub(1);
113
114 let actual_sram_size = if sram_size == 0 {
115 Self::RAM_MIN
116 } else {
117 sram_size
118 };
119 let ram_len = round_pow2(actual_sram_size as u32) as usize;
120 let ram = vec![0u8; ram_len].into_boxed_slice();
121 let ram_mask = (ram_len as u32).wrapping_sub(1);
122
123 Self {
124 gsu: Gsu::new(),
125 rom,
126 ram,
127 rom_mask,
128 ram_mask,
129 host_accesses: 0,
130 }
131 }
132
133 /// Classify a 24-bit CPU address into one of the board's regions.
134 fn classify(addr24: u32) -> Region {
135 let bank = (addr24 >> 16) & 0xFF;
136 let addr = addr24 & 0xFFFF;
137 let lo = bank & 0x7F; // fold the $80-$FF mirror half onto $00-$7F
138
139 // GSU registers + cache window: $00-$3F,$80-$BF : $3000-$32FF.
140 if lo <= 0x3F && (0x3000..=0x32FF).contains(&addr) {
141 return Region::Register(addr as u16);
142 }
143 // Game Pak RAM: $70-$71,$F0-$F1 : $0000-$FFFF.
144 if (0x70..=0x71).contains(&lo) {
145 return Region::Ram(((lo & 1) << 16) | addr);
146 }
147 // Game Pak RAM low window: $00-$3F,$80-$BF : $6000-$7FFF.
148 if lo <= 0x3F && (0x6000..=0x7FFF).contains(&addr) {
149 return Region::Ram(addr - 0x6000);
150 }
151 // Game Pak ROM (linear): $40-$5F,$C0-$DF : $0000-$FFFF.
152 if (0x40..=0x5F).contains(&lo) {
153 return Region::Rom((bank << 16) | addr);
154 }
155 // Game Pak ROM (LoROM windows): $00-$3F,$80-$BF : $8000-$FFFF.
156 if lo <= 0x3F && addr >= 0x8000 {
157 return Region::Rom((lo << 15) | (addr & 0x7FFF));
158 }
159 Region::Open
160 }
161}
162
163/// What a CPU address decodes to on a Super FX board.
164enum Region {
165 /// GSU register window; carries the `$3000-$32FF` address.
166 Register(u16),
167 /// Game Pak ROM at the given GSU-internal 24-bit address (the same view the GSU reads).
168 Rom(u32),
169 /// Game Pak RAM at the given linear offset (pre-mask).
170 Ram(u32),
171 /// Unmapped / open bus.
172 Open,
173}
174
175impl Board for SuperFxBoard {
176 fn name(&self) -> &'static str {
177 "LoROM+SuperFX"
178 }
179
180 fn coprocessor(&self) -> Coprocessor {
181 Coprocessor::SuperFx
182 }
183
184 fn map(&self, addr24: u32) -> MappedAddr {
185 match Self::classify(addr24) {
186 Region::Register(_) => MappedAddr::Coprocessor,
187 Region::Rom(off) => MappedAddr::Rom(off & self.rom_mask),
188 // `v1.1.0`: a CPU read while the GSU owns Game Pak RAM is genuinely open bus (see
189 // this module's own "Bus arbitration" doc above) — classifying it as `Open` here
190 // (rather than `Sram`) lets `Cart::read24`'s existing generic open-bus fallback
191 // thread the real last-driven bus byte through, the same fix the SPC7110
192 // investigation applied generically (`docs/audit/spc7110-boot-crash-2026-07-08.md`).
193 // Writes are unaffected: `Cart::write24` never consults `map()`, so `write24`'s own
194 // Ram arm (which posts unconditionally, even while the GSU owns RAM) is untouched.
195 Region::Ram(off) => {
196 if self.gsu.owns_ram() {
197 MappedAddr::Open
198 } else {
199 MappedAddr::Sram(off & self.ram_mask)
200 }
201 }
202 Region::Open => MappedAddr::Open,
203 }
204 }
205
206 fn read24(&mut self, addr24: u32) -> u8 {
207 match Self::classify(addr24) {
208 Region::Register(addr) => {
209 self.host_accesses = self.host_accesses.wrapping_add(1);
210 self.gsu.read_register(addr)
211 }
212 Region::Rom(off) => {
213 if self.gsu.owns_rom() {
214 return SNOOZE_VECTOR[(off & 15) as usize];
215 }
216 self.rom
217 .get((off & self.rom_mask) as usize)
218 .copied()
219 .unwrap_or(0)
220 }
221 Region::Ram(off) => {
222 if self.gsu.owns_ram() {
223 // Genuinely reachable only via a direct `Board::read24` call bypassing
224 // `Cart::read24`'s `map()`-based open-bus fallback (e.g. this module's own
225 // unit tests) — `map()` now classifies this case as `MappedAddr::Open`, so
226 // the normal Cart-mediated path never reaches this arm; kept as a sane
227 // standalone default for direct callers.
228 return 0;
229 }
230 self.ram
231 .get((off & self.ram_mask) as usize)
232 .copied()
233 .unwrap_or(0)
234 }
235 Region::Open => 0,
236 }
237 }
238
239 fn write24(&mut self, addr24: u32, val: u8) {
240 match Self::classify(addr24) {
241 Region::Register(addr) => {
242 self.host_accesses = self.host_accesses.wrapping_add(1);
243 // Setting Go here only arms the GSU; the Bus drives it to completion one
244 // instruction at a time via `coprocessor_step` so the master clock advances
245 // in step with the GSU instead of jumping by the whole burst at once
246 // (`Board::coprocessor_step` doc — ares `SuperFX::step`/`Thread::synchronize`).
247 let _went_go = self.gsu.write_register(addr, val);
248 }
249 Region::Ram(off) => {
250 // The S-CPU write lands in Game Pak RAM unconditionally, even while the GSU owns
251 // the RAM bus (RAN) — matching ares `SuperFX::CPURAM::write`, which never gates the
252 // write. (Reads DO return open bus while the GSU owns RAM — see `read24` — but
253 // writes do not.) Gating the write here silently dropped CPU-authored data whenever
254 // it was posted during a GSU-active window.
255 if let Some(slot) = self.ram.get_mut((off & self.ram_mask) as usize) {
256 *slot = val;
257 }
258 }
259 Region::Rom(_) | Region::Open => {}
260 }
261 }
262
263 fn rom(&self) -> &[u8] {
264 &self.rom
265 }
266
267 fn sram(&self) -> &[u8] {
268 &self.ram
269 }
270
271 fn sram_mut(&mut self) -> &mut [u8] {
272 &mut self.ram
273 }
274
275 fn irq_pending(&self) -> bool {
276 self.gsu.irq_pending()
277 }
278
279 fn debug_gsu_state(&self) -> Option<([u16; 16], u16, u8)> {
280 Some((self.gsu.registers(), self.gsu.sfr(), self.gsu.pbr()))
281 }
282
283 fn coprocessor_host_accesses(&self) -> u64 {
284 // Surface the GSU instruction count when the chip has run, else the register-access count.
285 // Either is a non-zero liveness signal only if the bus window is mapped right and the GSU
286 // actually executed.
287 self.host_accesses.wrapping_add(self.gsu.instructions())
288 }
289
290 fn coprocessor_tick(&mut self) {
291 let mut mem = GsuMem {
292 rom: &self.rom,
293 rom_mask: self.rom_mask,
294 ram: &mut self.ram,
295 ram_mask: self.ram_mask,
296 };
297 self.gsu.tick(&mut mem);
298 }
299
300 // ROM/RAM are NOT written here — `System::save_state` captures them separately
301 // (`Board::save_state`'s doc contract); `rom_mask`/`ram_mask` are re-derived from those
302 // buffers' lengths at construction time, not saved state. `host_accesses` (a debugger
303 // counter) is also excluded.
304 fn save_state(&self, w: &mut SaveWriter) {
305 self.gsu.save_state(w);
306 }
307
308 fn load_state(&mut self, r: &mut SaveReader) -> Result<(), SaveStateError> {
309 self.gsu.load_state(r)
310 }
311}
312
313/// Select a Super FX board for `rom`. The base `map_mode` is informational (Super FX carts are
314/// LoROM-mapped); the GSU RAM is sized from `sram_size`.
315#[must_use]
316pub fn select(_map_mode: MapMode, rom: Box<[u8]>, sram_size: usize) -> SuperFxBoard {
317 SuperFxBoard::new(rom, sram_size)
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 fn board() -> SuperFxBoard {
325 // 256 KiB ROM, default RAM.
326 SuperFxBoard::new(vec![0u8; 0x4_0000].into_boxed_slice(), 0)
327 }
328
329 #[test]
330 fn detects_superfx_and_default_ram() {
331 let b = board();
332 assert_eq!(b.coprocessor(), Coprocessor::SuperFx);
333 assert_eq!(b.ram.len(), SuperFxBoard::RAM_MIN);
334 assert_eq!(b.rom_mask, 0x3_FFFF);
335 }
336
337 #[test]
338 fn register_window_maps_to_coprocessor() {
339 let b = board();
340 assert!(matches!(b.map(0x00_3030), MappedAddr::Coprocessor));
341 assert!(matches!(b.map(0x80_3000), MappedAddr::Coprocessor));
342 // ROM windows + RAM windows.
343 assert!(matches!(b.map(0x00_8000), MappedAddr::Rom(0)));
344 assert!(matches!(b.map(0x40_0000), MappedAddr::Rom(0)));
345 assert!(matches!(b.map(0x70_0000), MappedAddr::Sram(0)));
346 assert!(matches!(b.map(0x00_6000), MappedAddr::Sram(0)));
347 }
348
349 #[test]
350 fn ram_roundtrip_via_cpu() {
351 let mut b = board();
352 b.write24(0x70_0010, 0x5A);
353 assert_eq!(b.read24(0x70_0010), 0x5A);
354 // Low window aliases the same RAM base.
355 b.write24(0x00_6004, 0x33);
356 assert_eq!(b.read24(0x00_6004), 0x33);
357 }
358
359 #[test]
360 fn rom_window_reads_image() {
361 let mut rom = vec![0u8; 0x4_0000];
362 rom[0x0000] = 0xAA; // $00:$8000 -> ROM 0
363 rom[0x8000] = 0xBB; // $01:$8000 -> ROM 0x8000
364 let mut b = SuperFxBoard::new(rom.into_boxed_slice(), 0);
365 assert_eq!(b.read24(0x00_8000), 0xAA);
366 assert_eq!(b.read24(0x01_8000), 0xBB);
367 // Linear $40:$8000 windows the same ROM 0x8000.
368 assert_eq!(b.read24(0x40_8000), 0xBB);
369 }
370
371 /// A hand-assembled GSU program: IBT R0,#0x11 ; STOP. Proves decode + host-sync run.
372 #[test]
373 fn gsu_runs_a_tiny_program_via_host_sync() {
374 // Place the program at ROM offset 0 (bank $00). The GSU starts at (PBR=0:R15).
375 // Program bytes: a0 11 (ibt r0,#$11), 00 (stop).
376 let mut rom = vec![0u8; 0x1_0000];
377 rom[0] = 0xa0; // ibt r0
378 rom[1] = 0x11; // #$11
379 rom[2] = 0x00; // stop
380 let mut b = SuperFxBoard::new(rom.into_boxed_slice(), 0);
381
382 // Set PBR=0 ($3034), R15=0 (already 0), then write R15 high byte ($301F) to set Go.
383 b.write24(0x00_3034, 0x00); // PBR = 0
384 b.write24(0x00_301E, 0x00); // R15 low = 0
385 b.write24(0x00_301F, 0x00); // R15 high = 0 -> sets Go
386
387 // Go no longer runs synchronously inside `write24` (the host-sync model moved to
388 // `Board::coprocessor_tick`, driven one master clock at a time by the Bus's
389 // `advance_master` loop in `rustysnes-core` so the GSU interleaves with the CPU's own
390 // instructions instead of draining to completion atomically). Drive it the same way.
391 while b.gsu.go() {
392 b.coprocessor_tick();
393 }
394
395 // After the run Go is clear (STOP executed) and the GSU made progress.
396 assert!(b.gsu.instructions() > 0, "GSU did not execute");
397 // SFR low byte: Go (bit 5) clear.
398 let sfr_lo = b.read24(0x00_3030);
399 assert_eq!(sfr_lo & 0x20, 0, "Go should be clear after STOP");
400 }
401
402 #[test]
403 fn gsu_state_round_trips_through_save_state() {
404 let mut rom = vec![0u8; 0x1_0000];
405 rom[0] = 0xa0; // ibt r0
406 rom[1] = 0x11; // #$11
407 rom[2] = 0x00; // stop
408 let mut b = SuperFxBoard::new(rom.into_boxed_slice(), 0);
409 b.write24(0x00_3034, 0x00);
410 b.write24(0x00_301E, 0x00);
411 b.write24(0x00_301F, 0x00);
412 while b.gsu.go() {
413 b.coprocessor_tick();
414 }
415 let instructions_before = b.gsu.instructions();
416 let r0_before = b.gsu.read_register(0x00);
417
418 let mut w = SaveWriter::new();
419 b.save_state(&mut w);
420 let bytes = w.into_bytes();
421
422 let mut fresh = SuperFxBoard::new(vec![0u8; 0x1_0000].into_boxed_slice(), 0);
423 let mut r = SaveReader::new(&bytes);
424 fresh.load_state(&mut r).unwrap();
425
426 assert_eq!(fresh.gsu.instructions(), instructions_before);
427 assert_eq!(fresh.gsu.read_register(0x00), r0_before);
428 assert!(!fresh.gsu.go());
429 assert_eq!(r.remaining(), 0);
430 }
431}