Skip to main content

rustysnes_cart/coproc/
sdd1.rs

1//! The S-DD1 board — Nintendo's lossless decompression ASIC (Star Ocean, Street Fighter Alpha 2).
2//!
3//! Clean-room port of ares' `SDD1` component (ISC, `sfc/coprocessor/sdd1/`): a Ricoh-style
4//! Golomb-code + adaptive-binary-arithmetic entropy decoder that streams decompressed bytes
5//! DURING a DMA transfer, not on a plain memory read. There is no chip-ROM dump — the algorithm
6//! runs entirely against the cart's own (compressed) ROM data — so the board is functional the
7//! moment the cart loads (`docs/adr/0003`).
8//!
9//! S-DD1 owns its ROM mapping directly (like Super FX / SA-1, not wrapped over a base board):
10//! its bank-fold formula differs from plain LoROM/HiROM, and its DMA-decompression hook needs to
11//! see the FULL `$00-3F,$80-BF:$8000-FFFF` + `$C0-FF:$0000-FFFF` read path uninterrupted.
12//!
13//! Bus window (bank:addr):
14//!
15//! | Region | Target |
16//! |---|---|
17//! | `$00-3F,$80-BF:$4800,$4801,$4804-$4807` | control registers (`$4802,$4803` fall through to ROM) |
18//! | `$00-3F,$80-BF:$8000-FFFF` | banked ROM via the `$4804-$4807` MMC registers (plain read, `$20-3F`/`$A0-BF` A21 fold when `r4805`/`r4807` bit 7 set) |
19//! | `$C0-FF:$0000-FFFF` | banked ROM via MMC, OR a live decompression stream if a DMA channel matching this address is armed ([`Board::notify_dma_channel`] is the DMA-address/size snoop; `rustysnes-core` calls it on every `$43n2-$43n6` write) |
20//!
21//! S-DD1 always uses DMA **fixed-address transfer mode**, so the source address never advances —
22//! every byte of a transfer reads the SAME address, which really means "keep streaming from
23//! here"; `Decompressor::init` is called once per DMA (on the first matching read), then
24//! `Decompressor::read` once per subsequent byte.
25
26// Chip-name jargon (S-DD1, MMC, Golomb, ...) is not Rust code; the entropy-coder state is
27// naturally dense with small bitfields ported verbatim from ares' constant tables.
28#![allow(
29    clippy::doc_markdown,
30    clippy::similar_names,
31    clippy::cast_possible_truncation
32)]
33
34use alloc::boxed::Box;
35use alloc::vec;
36
37use rustysnes_savestate::{SaveReader, SaveStateError, SaveWriter};
38
39use crate::board::{Board, Coprocessor, MappedAddr};
40use crate::header::MapMode;
41
42mod decompressor;
43use decompressor::Decompressor;
44
45/// One DMA channel's snooped source address + remaining byte count (`Board::notify_dma_channel`).
46#[derive(Debug, Clone, Copy, Default)]
47struct DmaSnoop {
48    address: u32,
49    size: u16,
50}
51
52/// A cartridge carrying an S-DD1 (owns its ROM mapping directly — see the module doc).
53pub struct Sdd1Board {
54    rom: Box<[u8]>,
55    sram: Box<[u8]>,
56    r4800: u8, // hard enable (per-channel bitmask)
57    r4801: u8, // soft enable (per-channel bitmask)
58    r4804: u8, // MMC bank for $C0-CF
59    r4805: u8, // MMC bank for $D0-DF (+ bit7: fold $20-3F:8000-FFFF onto $00-1F)
60    r4806: u8, // MMC bank for $E0-EF
61    r4807: u8, // MMC bank for $F0-FF (+ bit7: fold $A0-BF:8000-FFFF onto $80-9F)
62    dma: [DmaSnoop; 8],
63    dma_ready: bool,
64    decompressor: Decompressor,
65}
66
67impl core::fmt::Debug for Sdd1Board {
68    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
69        f.debug_struct("Sdd1Board")
70            .field("rom_len", &self.rom.len())
71            .field("r4800", &self.r4800)
72            .field("r4801", &self.r4801)
73            .finish_non_exhaustive()
74    }
75}
76
77impl Sdd1Board {
78    /// Build an S-DD1 board directly from the cart's raw ROM bytes + header SRAM size.
79    #[must_use]
80    pub fn new(rom: Box<[u8]>, sram_size: usize) -> Self {
81        Self {
82            rom,
83            sram: vec![0u8; sram_size].into_boxed_slice(),
84            r4800: 0,
85            r4801: 0,
86            r4804: 0x00,
87            r4805: 0x01,
88            r4806: 0x02,
89            r4807: 0x03,
90            dma: [DmaSnoop::default(); 8],
91            dma_ready: false,
92            decompressor: Decompressor::new(),
93        }
94    }
95
96    /// The banked-ROM read every non-decompressing access resolves to (ares `mmcRead`): which of
97    /// the four `$4804-$4807` MMC registers backs this address depends on bits 20-21.
98    fn mmc_read(&self, address: u32) -> u8 {
99        let bank_reg = match (address >> 20) & 0x3 {
100            0 => self.r4804,
101            1 => self.r4805,
102            2 => self.r4806,
103            _ => self.r4807,
104        };
105        let off = (u32::from(bank_reg & 0xF) << 20) | (address & 0x0F_FFFF);
106        self.rom
107            .get((off as usize) % self.rom.len().max(1))
108            .copied()
109            .unwrap_or(0)
110    }
111
112    /// The full S-CPU-facing ROM read (ares `mcuRead`): banks `$00-3F,$80-BF:$8000-FFFF` fold
113    /// like LoROM (with the `r4805`/`r4807` bit-7 A21 quirk for the upper half); banks `$C0-FF`
114    /// either stream decompression (if an armed DMA channel's snooped address matches) or fall
115    /// through to `mmc_read`.
116    fn mcu_read(&mut self, address: u32) -> u8 {
117        let a = address & 0xFF_FFFF;
118        if a & 0x40_0000 == 0 {
119            // $00-3F,$80-BF:$8000-FFFF
120            let mut a = a;
121            if a & 0x80_0000 == 0 && a & 0x20_0000 != 0 && self.r4805 & 0x80 != 0 {
122                a &= !0x20_0000; // 20-3f:8000-ffff fold
123            }
124            if a & 0x80_0000 != 0 && a & 0x20_0000 != 0 && self.r4807 & 0x80 != 0 {
125                a &= !0x20_0000; // a0-bf:8000-ffff fold
126            }
127            let off = ((a >> 16) & 0x3F) << 15 | (a & 0x7FFF);
128            return self
129                .rom
130                .get((off as usize) % self.rom.len().max(1))
131                .copied()
132                .unwrap_or(0);
133        }
134
135        // $C0-FF:$0000-FFFF
136        if self.r4800 & self.r4801 != 0 {
137            for n in 0..8usize {
138                let armed = self.r4800 & (1 << n) != 0 && self.r4801 & (1 << n) != 0;
139                if armed && self.dma[n].address & 0xFF_FFFF == a {
140                    let mmc_map = self.mmc_map();
141                    if !self.dma_ready {
142                        self.decompressor.init(a, &self.rom, &mmc_map);
143                        self.dma_ready = true;
144                    }
145                    let data = self.decompressor.read(&self.rom, &mmc_map);
146                    self.dma[n].size = self.dma[n].size.wrapping_sub(1);
147                    if self.dma[n].size == 0 {
148                        self.dma_ready = false;
149                        self.r4801 &= !(1 << n);
150                    }
151                    return data;
152                }
153            }
154        }
155        self.mmc_read(a)
156    }
157
158    const fn mmc_map(&self) -> [u8; 4] {
159        [self.r4804, self.r4805, self.r4806, self.r4807]
160    }
161}
162
163impl Board for Sdd1Board {
164    fn name(&self) -> &'static str {
165        "ExHiROM+S-DD1"
166    }
167
168    fn coprocessor(&self) -> Coprocessor {
169        Coprocessor::SDd1
170    }
171
172    fn map(&self, addr24: u32) -> MappedAddr {
173        let a = addr24 & 0xFF_FFFF;
174        let bank = (a >> 16) & 0xFF;
175        let addr = a & 0xFFFF;
176        let in_ctrl = matches!(bank, 0x00..=0x3F | 0x80..=0xBF)
177            && matches!(addr, 0x4800 | 0x4801 | 0x4804..=0x4807);
178        if in_ctrl {
179            MappedAddr::Coprocessor
180        } else {
181            MappedAddr::Rom(a) // ROM/decompression, resolved in read24 (not a plain offset)
182        }
183    }
184
185    fn read24(&mut self, addr24: u32) -> u8 {
186        let a = addr24 & 0xFF_FFFF;
187        let bank = (a >> 16) & 0xFF;
188        let addr = a & 0xFFFF;
189        if matches!(bank, 0x00..=0x3F | 0x80..=0xBF) {
190            match addr {
191                0x4800 => return self.r4800,
192                0x4801 => return self.r4801,
193                0x4804 => return self.r4804,
194                0x4805 => return self.r4805,
195                0x4806 => return self.r4806,
196                0x4807 => return self.r4807,
197                0x6000..=0x7FFF if !self.sram.is_empty() => {
198                    let off = (((bank - if bank >= 0x80 { 0x80 } else { 0x00 }) as usize) * 0x2000
199                        + (addr - 0x6000) as usize)
200                        % self.sram.len();
201                    return self.sram[off];
202                }
203                _ => {}
204            }
205        }
206        self.mcu_read(a)
207    }
208
209    fn write24(&mut self, addr24: u32, val: u8) {
210        let a = addr24 & 0xFF_FFFF;
211        let bank = (a >> 16) & 0xFF;
212        let addr = a & 0xFFFF;
213        if matches!(bank, 0x00..=0x3F | 0x80..=0xBF) {
214            match addr {
215                0x4800 => self.r4800 = val,
216                0x4801 => self.r4801 = val,
217                0x4804 => self.r4804 = val & 0x8F,
218                0x4805 => self.r4805 = val & 0x8F,
219                0x4806 => self.r4806 = val & 0x8F,
220                0x4807 => self.r4807 = val & 0x8F,
221                0x6000..=0x7FFF if !self.sram.is_empty() => {
222                    let off = (((bank - if bank >= 0x80 { 0x80 } else { 0x00 }) as usize) * 0x2000
223                        + (addr - 0x6000) as usize)
224                        % self.sram.len();
225                    self.sram[off] = val;
226                }
227                // ROM is read-only (ares `writeROM` is a no-op).
228                _ => {}
229            }
230        }
231    }
232
233    fn rom(&self) -> &[u8] {
234        &self.rom
235    }
236
237    fn sram(&self) -> &[u8] {
238        &self.sram
239    }
240
241    fn sram_mut(&mut self) -> &mut [u8] {
242        &mut self.sram
243    }
244
245    fn notify_dma_channel(&mut self, channel: usize, address: u32, count: u16) {
246        if let Some(c) = self.dma.get_mut(channel & 7) {
247            c.address = address;
248            c.size = count;
249        }
250    }
251
252    // ROM/SRAM are NOT written here — `System::save_state` captures them separately (see
253    // `crate::board::Board::save_state`'s doc). Only the MMC bank registers, the snooped-DMA
254    // shadow state, and the entropy decoder's mid-stream state (needed to resume a
255    // decompression correctly if a save-state lands mid-DMA-transfer) are this board's own.
256    fn save_state(&self, w: &mut SaveWriter) {
257        w.section(*b"SDB1", |s| {
258            s.write_u8(self.r4800);
259            s.write_u8(self.r4801);
260            s.write_u8(self.r4804);
261            s.write_u8(self.r4805);
262            s.write_u8(self.r4806);
263            s.write_u8(self.r4807);
264            for c in &self.dma {
265                s.write_u32(c.address);
266                s.write_u16(c.size);
267            }
268            s.write_bool(self.dma_ready);
269        });
270        self.decompressor.save_state(w);
271    }
272
273    fn load_state(&mut self, r: &mut SaveReader) -> Result<(), SaveStateError> {
274        let mut s = r.expect_section(*b"SDB1")?;
275        self.r4800 = s.read_u8()?;
276        self.r4801 = s.read_u8()?;
277        // r4804-r4807 are masked to 0x8F on every normal write24 (see above); mask on load too so
278        // a hand-edited/corrupted save-state can't restore a hardware-illegal high-bit pattern.
279        self.r4804 = s.read_u8()? & 0x8F;
280        self.r4805 = s.read_u8()? & 0x8F;
281        self.r4806 = s.read_u8()? & 0x8F;
282        self.r4807 = s.read_u8()? & 0x8F;
283        for c in &mut self.dma {
284            c.address = s.read_u32()?;
285            c.size = s.read_u16()?;
286        }
287        self.dma_ready = s.read_bool()?;
288        if s.remaining() != 0 {
289            return Err(SaveStateError::Invalid(alloc::format!(
290                "SDB1 section has {} trailing byte(s)",
291                s.remaining()
292            )));
293        }
294        self.decompressor.load_state(r)
295    }
296}
297
298/// Build an [`Sdd1Board`] for a cart detected as S-DD1 (`board::select`).
299#[must_use]
300pub fn select(_map_mode: MapMode, rom: Box<[u8]>, sram_size: usize) -> Sdd1Board {
301    Sdd1Board::new(rom, sram_size)
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    fn board() -> Sdd1Board {
309        Sdd1Board::new(vec![0u8; 0x40_0000].into_boxed_slice(), 0)
310    }
311
312    #[test]
313    fn control_register_roundtrip() {
314        let mut b = board();
315        b.write24(0x00_4800, 0xAB);
316        assert_eq!(b.read24(0x00_4800), 0xAB);
317        b.write24(0x00_4804, 0xFF); // masked to 0x8F
318        assert_eq!(b.read24(0x00_4804), 0x8F);
319    }
320
321    #[test]
322    fn mmc_bank_selects_rom_quarter() {
323        let mut b = board();
324        b.rom[0x10_0000] = 0x55; // bank-1 (r4805 default) quarter, offset 0
325        assert_eq!(b.mmc_read(0x10_0000), 0x55);
326    }
327
328    #[test]
329    fn dma_snoop_arms_and_disarms_a_channel() {
330        let mut b = board();
331        b.notify_dma_channel(0, 0xC0_0000, 4);
332        assert_eq!(b.dma[0].address, 0xC0_0000);
333        assert_eq!(b.dma[0].size, 4);
334    }
335
336    #[test]
337    fn registers_and_decompressor_round_trip_through_save_state() {
338        // A non-trivial (non-all-zero) ROM so the decode stream isn't degenerate.
339        let mut rom = vec![0u8; 0x40_0000];
340        for (i, b) in rom.iter_mut().enumerate().take(64) {
341            *b = (i as u8).wrapping_mul(37).wrapping_add(11);
342        }
343        let mut b = Sdd1Board::new(rom.into_boxed_slice(), 0);
344        b.write24(0x00_4805, 0x77); // masked to 0x8F -> 0x05, exercised below
345        b.notify_dma_channel(2, 0xC1_2345, 9);
346        let mmc_map = [0u8, 1, 2, 3];
347        b.decompressor.init(0, &b.rom, &mmc_map);
348        b.decompressor.read(&b.rom, &mmc_map); // advance mid-stream before snapshotting
349
350        let mut w = SaveWriter::new();
351        b.save_state(&mut w);
352        let bytes = w.into_bytes();
353
354        let mut fresh = Sdd1Board::new(b.rom.clone(), 0);
355        let mut r = SaveReader::new(&bytes);
356        fresh.load_state(&mut r).unwrap();
357
358        assert_eq!(fresh.r4805, 0x77 & 0x8F);
359        assert_eq!(fresh.dma[2].address, 0xC1_2345);
360        assert_eq!(fresh.dma[2].size, 9);
361
362        // The restored decoder must continue the SAME stream from the SAME point: its next N
363        // decoded bytes must match the original decoder's next N bytes exactly.
364        let expected: alloc::vec::Vec<u8> = (0..8)
365            .map(|_| b.decompressor.read(&b.rom, &mmc_map))
366            .collect();
367        let actual: alloc::vec::Vec<u8> = (0..8)
368            .map(|_| fresh.decompressor.read(&fresh.rom, &mmc_map))
369            .collect();
370        assert_eq!(actual, expected);
371        assert_eq!(r.remaining(), 0);
372    }
373}