Skip to main content

rustysnes_cart/coproc/
necdsp_variant.rs

1//! Single-game NEC DSP variant boards — DSP-2, DSP-4, ST010.
2//!
3//! Riding the shared [`crate::coproc::upd77c25::Upd77c25`] engine
4//! ([`Dsp1Board`](super::dsp1::Dsp1Board) covers DSP-1 itself, which uses a different,
5//! board-specific window split). DR/SR splits differ per chip — ares' generic `NECDSP::read`/`write`
6//! (`sfc/coprocessor/necdsp/memory.cpp`) suggests a uniform low-address-bit split (even=DR,
7//! odd=SR) for the whole non-DSP-1 family, and that's what DSP-2/ST010 use — but DSP-4 (Top Gear
8//! 3000) does NOT: it uses the SAME half-window boundary split DSP-1 does (`docs/cart.md`
9//! §DSP-1), confirmed empirically against its own boot-time hardware-presence check (a 16-bit
10//! compare of the masked window's first two bytes against `$FFFF`, which only passes if both
11//! bytes read the same port). Board attributions from `ares` `System/Super Famicom/boards.bml`:
12//!
13//! | Chip (game)                    | Board          | Register window (bank:addr)         | DR/SR split | Revision  |
14//! |---------------------------------|----------------|--------------------------------------|-------------|-----------|
15//! | DSP-2 (Dungeon Master)          | SHVC-1B5B-02   | `$20–3F,$A0–BF:$8000–FFFF` mask `$3FFF` | low bit (even=DR) | `Upd7725`  |
16//! | DSP-4 (Top Gear 3000)           | SHVC-1B0N-03   | `$30–3F,$B0–BF:$8000–FFFF` mask `$3FFF` | half-window boundary at `$2000` (below=DR) | `Upd7725`  |
17//! | ST010 (F1 ROC II)               | SHVC-1DS0B-20  | `$60–67,$E0–E7:$0000–3FFF` (registers) + `$68–6F,$E8–EF:$0000–7FFF` (battery data RAM, direct [`Upd77c25::read_dp`]/[`write_dp`](Upd77c25::write_dp) port) | low bit (even=DR) | `Upd96050` |
18//!
19//! DSP-3 and ST011 are NOT wired here: neither has a verified board/window entry (no game ROM in
20//! this project's local corpus to validate against), so guessing a window would be an unverified,
21//! untestable claim — `docs/adr/0003`'s honesty gate means the cart simply runs as its base board
22//! (unmapped coprocessor window) until one can be pinned against a real cart, exactly like every
23//! other not-yet-implemented coprocessor.
24//!
25//! There is no header-byte signal that distinguishes DSP-1 from DSP-2/4/ST010 (the chipset byte
26//! only flags "has an NEC DSP" generically) — real emulators resolve this via a cartridge
27//! database; lacking one, `detect` matches the 21-byte internal title against each chip's one
28//! known game, the same single-game-chip approach ares' own database reduces to for these titles.
29
30// Chip-name jargon (DSP-1..4, ST010, uPD7725, ...) is not Rust code.
31#![allow(clippy::doc_markdown)]
32
33use alloc::boxed::Box;
34
35use rustysnes_savestate::{SaveReader, SaveStateError, SaveWriter};
36
37use crate::board::{Board, Coprocessor, MappedAddr};
38use crate::coproc::upd77c25::{Revision, Upd77c25};
39
40/// Which single-game NEC DSP variant a cart carries.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Variant {
43    /// DSP-2 — Dungeon Master.
44    Dsp2,
45    /// DSP-4 — Top Gear 3000.
46    Dsp4,
47    /// ST010 — F1 ROC II: Race of Champions.
48    St010,
49}
50
51impl Variant {
52    /// Detect the variant from the cart's 21-byte internal title (uppercased), if it matches one
53    /// of the three known single-game carts. `None` for every other ROM (including plain DSP-1).
54    #[must_use]
55    pub fn detect(title_upper: &str) -> Option<Self> {
56        if title_upper.contains("DUNGEON MASTER") {
57            Some(Self::Dsp2)
58        } else if title_upper.contains("TOP GEAR 3000") {
59            Some(Self::Dsp4)
60        } else if title_upper.contains("F1 ROC") {
61            Some(Self::St010)
62        } else {
63            None
64        }
65    }
66
67    const fn revision(self) -> Revision {
68        match self {
69            Self::Dsp2 | Self::Dsp4 => Revision::Upd7725,
70            Self::St010 => Revision::Upd96050,
71        }
72    }
73
74    /// `(register-bank-lo, register-bank-hi, register-mirror-bank-lo, register-mirror-bank-hi)`.
75    const fn reg_banks(self) -> (u8, u8, u8, u8) {
76        match self {
77            Self::Dsp2 => (0x20, 0x3F, 0xA0, 0xBF),
78            Self::Dsp4 => (0x30, 0x3F, 0xB0, 0xBF),
79            Self::St010 => (0x60, 0x67, 0xE0, 0xE7),
80        }
81    }
82
83    /// `Some((lo, hi, mirror-lo, mirror-hi))` battery data-RAM banks (ST010/011 only — the other
84    /// two chips have no separate directly-mapped data-RAM window).
85    const fn dp_banks(self) -> Option<(u8, u8, u8, u8)> {
86        match self {
87            Self::St010 => Some((0x68, 0x6F, 0xE8, 0xEF)),
88            Self::Dsp2 | Self::Dsp4 => None,
89        }
90    }
91
92    /// Firmware file name this project's `firmware_candidates` convention expects.
93    #[must_use]
94    pub const fn firmware_name(self) -> &'static str {
95        match self {
96            Self::Dsp2 => "dsp2.rom",
97            Self::Dsp4 => "dsp4.rom",
98            Self::St010 => "st010.rom",
99        }
100    }
101}
102
103fn in_bank(bank: u32, lo: u8, hi: u8, mlo: u8, mhi: u8) -> bool {
104    (u32::from(lo)..=u32::from(hi)).contains(&bank)
105        || (u32::from(mlo)..=u32::from(mhi)).contains(&bank)
106}
107
108/// Classification of a bus address against a variant's windows.
109enum Hit {
110    Dr,
111    Sr,
112    Dp(u16),
113}
114
115fn classify(variant: Variant, addr24: u32) -> Option<Hit> {
116    let bank = (addr24 >> 16) & 0xFF;
117    let addr = addr24 & 0xFFFF;
118
119    let (lo, hi, mlo, mhi) = variant.reg_banks();
120    if in_bank(bank, lo, hi, mlo, mhi) && addr >= 0x8000 {
121        // DSP-4 (Top Gear 3000) splits DR/SR the SAME way DSP-1 does — a half-window boundary,
122        // not the low-address-bit alternation ares' generic `NECDSP` component uses for DSP-2/
123        // ST010 — confirmed empirically: the boot-time hardware check at $308000/$308001 (a
124        // 16-bit compare against `$FFFF`) only succeeds when BOTH bytes read the SAME port (DR),
125        // which only holds if they're on the SAME side of a half-window split, not alternating.
126        // The window is masked to `$3FFF` (a 0x4000 address space), so the natural boundary sits
127        // at its midpoint, `$2000`.
128        return Some(if variant == Variant::Dsp4 {
129            if addr & 0x3FFF < 0x2000 {
130                Hit::Dr
131            } else {
132                Hit::Sr
133            }
134        } else if addr & 1 != 0 {
135            Hit::Sr
136        } else {
137            Hit::Dr
138        });
139    }
140    // ST010's registers sit in `$0000-$3FFF` (no `>= 0x8000` gate, unlike DSP-2/4).
141    if variant == Variant::St010 && in_bank(bank, lo, hi, mlo, mhi) && addr <= 0x3FFF {
142        return Some(if addr & 1 != 0 { Hit::Sr } else { Hit::Dr });
143    }
144    if let Some((dlo, dhi, dmlo, dmhi)) = variant.dp_banks()
145        && in_bank(bank, dlo, dhi, dmlo, dmhi)
146        && addr <= 0x7FFF
147    {
148        return Some(Hit::Dp(addr as u16));
149    }
150    None
151}
152
153/// A LoROM cartridge carrying a single-game NEC DSP variant (see the module doc's table).
154pub struct NecDspVariantBoard {
155    inner: Box<dyn Board>,
156    dsp: Upd77c25,
157    variant: Variant,
158}
159
160impl core::fmt::Debug for NecDspVariantBoard {
161    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
162        f.debug_struct("NecDspVariantBoard")
163            .field("variant", &self.variant)
164            .field("inner", &self.inner.name())
165            .field("firmware_loaded", &self.dsp.firmware_loaded())
166            .finish()
167    }
168}
169
170impl NecDspVariantBoard {
171    /// Wrap a base board (`inner`) with the detected NEC DSP `variant`. Inert until
172    /// [`Board::load_firmware`] supplies the chip dump (`docs/adr/0003`).
173    #[must_use]
174    pub fn new(inner: Box<dyn Board>, variant: Variant) -> Self {
175        Self {
176            inner,
177            dsp: Upd77c25::new(variant.revision()),
178            variant,
179        }
180    }
181}
182
183impl Board for NecDspVariantBoard {
184    fn name(&self) -> &'static str {
185        match self.variant {
186            Variant::Dsp2 => "LoROM+DSP-2",
187            Variant::Dsp4 => "LoROM+DSP-4",
188            Variant::St010 => "LoROM+ST010",
189        }
190    }
191
192    fn coprocessor(&self) -> Coprocessor {
193        Coprocessor::Dsp
194    }
195
196    fn map(&self, addr24: u32) -> MappedAddr {
197        if classify(self.variant, addr24).is_some() {
198            MappedAddr::Coprocessor
199        } else {
200            self.inner.map(addr24)
201        }
202    }
203
204    fn read24(&mut self, addr24: u32) -> u8 {
205        match classify(self.variant, addr24) {
206            Some(Hit::Dr) => self.dsp.read_dr(),
207            Some(Hit::Sr) => self.dsp.read_sr(),
208            Some(Hit::Dp(a)) => self.dsp.read_dp(a),
209            None => self.inner.read24(addr24),
210        }
211    }
212
213    fn write24(&mut self, addr24: u32, val: u8) {
214        match classify(self.variant, addr24) {
215            Some(Hit::Dr) => self.dsp.write_dr(val),
216            Some(Hit::Sr) => self.dsp.write_sr(val),
217            Some(Hit::Dp(a)) => self.dsp.write_dp(a, val),
218            None => self.inner.write24(addr24, val),
219        }
220    }
221
222    fn rom(&self) -> &[u8] {
223        self.inner.rom()
224    }
225
226    fn sram(&self) -> &[u8] {
227        self.inner.sram()
228    }
229
230    fn sram_mut(&mut self) -> &mut [u8] {
231        self.inner.sram_mut()
232    }
233
234    fn load_firmware(&mut self, bytes: &[u8]) -> bool {
235        self.dsp.load_firmware(bytes)
236    }
237
238    fn coprocessor_host_accesses(&self) -> u64 {
239        self.dsp.host_accesses()
240    }
241
242    fn firmware_hint(&self) -> Option<&'static str> {
243        Some(self.variant.firmware_name())
244    }
245
246    // `variant` is fixed at construction (title-detected once, never mutated), so it needs no
247    // save-state entry — only the engine's own mutable register/RAM state does.
248    fn save_state(&self, w: &mut SaveWriter) {
249        self.dsp.save_state(w);
250        self.inner.save_state(w);
251    }
252
253    fn load_state(&mut self, r: &mut SaveReader) -> Result<(), SaveStateError> {
254        self.dsp.load_state(r)?;
255        self.inner.load_state(r)
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use crate::board::LoRom;
263    use alloc::vec;
264
265    fn board(variant: Variant) -> NecDspVariantBoard {
266        let inner = Box::new(LoRom::new(
267            vec![0u8; 0x8_0000].into_boxed_slice(),
268            vec![].into_boxed_slice(),
269        ));
270        NecDspVariantBoard::new(inner, variant)
271    }
272
273    #[test]
274    fn detect_by_title() {
275        assert_eq!(
276            Variant::detect("DUNGEON MASTER       "),
277            Some(Variant::Dsp2)
278        );
279        assert_eq!(
280            Variant::detect("TOP GEAR 3000        "),
281            Some(Variant::Dsp4)
282        );
283        assert_eq!(
284            Variant::detect("F1 ROC II            "),
285            Some(Variant::St010)
286        );
287        assert_eq!(Variant::detect("SUPER MARIO KART     "), None);
288    }
289
290    #[test]
291    fn dsp2_window_split() {
292        let b = board(Variant::Dsp2);
293        assert!(matches!(classify(b.variant, 0x20_8000), Some(Hit::Dr)));
294        assert!(matches!(classify(b.variant, 0x20_8001), Some(Hit::Sr)));
295        assert!(matches!(classify(b.variant, 0xA0_8000), Some(Hit::Dr))); // mirror bank
296        assert!(classify(b.variant, 0x00_8000).is_none()); // ROM, not DSP-2
297    }
298
299    #[test]
300    fn dsp4_window_uses_half_boundary_split_not_bit0() {
301        // Confirmed empirically against Top Gear 3000's boot-time hardware check (a 16-bit
302        // compare of $308000/$308001 against $FFFF, which only an emulator running the ares
303        // NECDSP-style bit0 split gets wrong): both bytes of a masked-address pair below the
304        // $2000 half-window boundary read the SAME port (DR), unlike DSP-2/ST010.
305        let b = board(Variant::Dsp4);
306        assert!(matches!(classify(b.variant, 0x30_8000), Some(Hit::Dr)));
307        assert!(matches!(classify(b.variant, 0x30_8001), Some(Hit::Dr)));
308        assert!(matches!(classify(b.variant, 0x30_9FFF), Some(Hit::Dr)));
309        assert!(matches!(classify(b.variant, 0x30_A000), Some(Hit::Sr)));
310        assert!(matches!(classify(b.variant, 0x30_BFFF), Some(Hit::Sr)));
311        // The mask folds the mirror at $C000 back onto the same $2000 boundary.
312        assert!(matches!(classify(b.variant, 0x30_C000), Some(Hit::Dr)));
313        assert!(matches!(classify(b.variant, 0x30_E000), Some(Hit::Sr)));
314    }
315
316    #[test]
317    fn st010_register_and_dp_windows() {
318        let b = board(Variant::St010);
319        assert!(matches!(classify(b.variant, 0x60_0000), Some(Hit::Dr)));
320        assert!(matches!(classify(b.variant, 0x60_0001), Some(Hit::Sr)));
321        assert!(matches!(classify(b.variant, 0xE0_0000), Some(Hit::Dr))); // mirror bank
322        assert!(matches!(classify(b.variant, 0x68_0000), Some(Hit::Dp(0)))); // battery data RAM
323        assert!(matches!(
324            classify(b.variant, 0xE8_0010),
325            Some(Hit::Dp(0x10))
326        ));
327        assert!(classify(b.variant, 0x00_8000).is_none()); // ROM, not ST010
328    }
329
330    #[test]
331    fn inert_without_firmware() {
332        let mut b = board(Variant::Dsp2);
333        assert!(!b.dsp.firmware_loaded());
334        assert_eq!(b.read24(0x20_8000), 0);
335    }
336
337    #[test]
338    fn engine_state_round_trips_through_save_state() {
339        let mut b = board(Variant::Dsp2); // Upd7725 revision, 8192-byte firmware
340        assert!(b.load_firmware(&[0u8; 8192]));
341        b.dsp.write_dp(0x20, 0x5A);
342        let before = b.dsp.data_ram_word(0x20 >> 1);
343        assert_ne!(before, 0);
344
345        let mut w = SaveWriter::new();
346        b.save_state(&mut w);
347        let bytes = w.into_bytes();
348
349        let mut fresh = board(Variant::Dsp2);
350        assert!(fresh.load_firmware(&[0u8; 8192]));
351        let mut r = SaveReader::new(&bytes);
352        fresh.load_state(&mut r).unwrap();
353
354        assert_eq!(fresh.dsp.data_ram_word(0x20 >> 1), before);
355        assert_eq!(r.remaining(), 0);
356    }
357}