Skip to main content

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}