Skip to main content

rustyn64_cart/
save.rs

1//! The four on-cartridge save backends + the Controller Pak.
2//!
3//! Two access paths (`docs/cart.md`, `ref-docs/research-report.md` §6):
4//! - **PI-bus (DOM2 @ `0x0800_0000`):** SRAM (flat) and FlashRAM (a command
5//!   state machine). Driven through `SaveDevice::pi_read`/`pi_write`.
6//! - **Joybus (SI/PIF):** EEPROM 4k/16k and the Controller Pak (flat blocks).
7//!   Driven through `SaveDevice::eeprom_read_block` etc. by the joybus module.
8//!
9//! Every backend round-trips a write and reload byte-for-byte — the accuracy
10//! oracle (the RustyNES battery-save analog). FlashRAM is modeled as its real
11//! erase/program/status machine (n64brew `Flash.md`), not a flat buffer, because
12//! a game that issues an erase-then-program sequence corrupts a flat store.
13#![allow(
14    clippy::doc_markdown,
15    reason = "save-backend prose names FlashRAM/SRAM/EEPROM/DMA/CIR constantly"
16)]
17
18use alloc::boxed::Box;
19use alloc::vec;
20
21use serde::{Deserialize, Serialize};
22
23use crate::SaveType;
24
25/// PI base of the DOM2 save window (SRAM / FlashRAM).
26pub const SAVE_PI_BASE: u32 = 0x0800_0000;
27/// FlashRAM Command Internal Register, at `base + 0x10000`.
28pub const FLASH_CIR: u32 = 0x0801_0000;
29
30/// FlashRAM geometry: 8 sectors × 128 pages × 128 bytes = 128 KiB.
31const FLASH_SIZE: usize = 128 * 1024;
32const FLASH_PAGE: usize = 128;
33const FLASH_SECTOR_PAGES: usize = 128;
34const FLASH_SECTOR: usize = FLASH_SECTOR_PAGES * FLASH_PAGE;
35
36/// The FlashRAM chip's operating mode (n64brew `Flash.md`).
37#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
38enum FlashMode {
39    /// Power-on: reads at the base return the data array.
40    #[default]
41    ReadArray,
42    /// Reads return the 8-bit status word (erase/program busy+ok).
43    Status,
44    /// Reads (via DMA) return the 64-bit silicon ID.
45    SiliconId,
46    /// Writes at the base fill the 128-byte page buffer.
47    LoadPage,
48}
49
50/// The FlashRAM state machine.
51#[derive(Clone, Debug, Serialize, Deserialize)]
52pub struct FlashRam {
53    array: Box<[u8]>,
54    #[serde(with = "serde_big_array::BigArray")]
55    page_buffer: [u8; FLASH_PAGE],
56    mode: FlashMode,
57    /// The last-set erase target (a page offset within the sector to erase, or
58    /// `None` for a pending chip erase). Consumed by the `Erase` (`0x78`) command.
59    erase_sector: Option<usize>,
60    chip_erase: bool,
61    /// The 8-bit status word read in [`FlashMode::Status`].
62    status: u8,
63}
64
65/// FlashRAM status bits (`Flash.md` §Status Mode). We report operations as
66/// instantly complete (BUSY clear, OK set) — the emulated erase/program are not
67/// timed, so software that polls sees success immediately.
68const FLASH_STATUS_ERASE_OK: u8 = 0x08;
69const FLASH_STATUS_PROGRAM_OK: u8 = 0x04;
70
71impl FlashRam {
72    fn new() -> Self {
73        Self {
74            // Erased flash reads back all-ones.
75            array: vec![0xFF; FLASH_SIZE].into_boxed_slice(),
76            page_buffer: [0xFF; FLASH_PAGE],
77            mode: FlashMode::default(),
78            erase_sector: None,
79            chip_erase: false,
80            status: 0,
81        }
82    }
83
84    /// Write the Command Internal Register (`0x0801_0000`), driving the machine.
85    fn write_cir(&mut self, cmd: u32) {
86        match cmd >> 24 {
87            0xF0 => self.mode = FlashMode::ReadArray,
88            0xE1 => self.mode = FlashMode::SiliconId,
89            0xB4 => {
90                self.mode = FlashMode::LoadPage;
91                self.page_buffer = [0xFF; FLASH_PAGE];
92            }
93            0x3C => {
94                self.chip_erase = true;
95                self.erase_sector = None;
96            }
97            0x4B => {
98                // Sector Erase Setup: the low bits index a page in the sector.
99                // Clamp to the 8 real sectors — a guest can write any 16-bit page
100                // index, and an out-of-range sector base would panic the erase.
101                self.chip_erase = false;
102                let page = (cmd & 0xFFFF) as usize;
103                let sector = (page / FLASH_SECTOR_PAGES).min(FLASH_SIZE / FLASH_SECTOR - 1);
104                self.erase_sector = Some(sector * FLASH_SECTOR);
105            }
106            0x78 => {
107                // Execute the pending erase → all-ones, then Status mode.
108                if self.chip_erase {
109                    self.array.fill(0xFF);
110                } else if let Some(base) = self.erase_sector {
111                    let end = (base + FLASH_SECTOR).min(self.array.len());
112                    self.array[base..end].fill(0xFF);
113                }
114                self.chip_erase = false;
115                self.erase_sector = None;
116                self.status = FLASH_STATUS_ERASE_OK;
117                self.mode = FlashMode::Status;
118            }
119            0xA5 => {
120                // Page Program: copy the page buffer to page `XXX`, then Status.
121                let page = (cmd & 0xFFFF) as usize;
122                let base = page * FLASH_PAGE;
123                if base + FLASH_PAGE <= self.array.len() {
124                    // Programming clears bits (AND), matching real flash — a page
125                    // must be erased (all-ones) before it can be reprogrammed.
126                    for (a, &b) in self.array[base..base + FLASH_PAGE]
127                        .iter_mut()
128                        .zip(self.page_buffer.iter())
129                    {
130                        *a &= b;
131                    }
132                }
133                self.status = FLASH_STATUS_PROGRAM_OK;
134                self.mode = FlashMode::Status;
135            }
136            _ => {}
137        }
138    }
139
140    /// Read a byte at the flash base window (`off` = byte offset from base).
141    fn read(&self, off: usize) -> u8 {
142        match self.mode {
143            FlashMode::ReadArray => self.array.get(off).copied().unwrap_or(0xFF),
144            FlashMode::Status => {
145                // The status burst is the pattern `00 <status>` repeated, so the
146                // status byte sits at every ODD offset (1, 3, 5, …), 0 at even
147                // (n64brew `Flash.md` §Status Mode).
148                if off & 1 == 1 { self.status } else { 0 }
149            }
150            // A minimal, stable silicon ID (byte-indexed chip). Real IDs vary;
151            // this is a documented stand-in read via the 8-byte DMA path.
152            FlashMode::SiliconId => [0x11, 0x11, 0x80, 0x00, 0xC2, 0x00, 0x1D, 0x00]
153                .get(off & 7)
154                .copied()
155                .unwrap_or(0),
156            FlashMode::LoadPage => self
157                .page_buffer
158                .get(off & (FLASH_PAGE - 1))
159                .copied()
160                .unwrap_or(0xFF),
161        }
162    }
163
164    /// Write a byte at the flash base window (fills the page buffer in
165    /// [`FlashMode::LoadPage`]; ignored otherwise).
166    fn write(&mut self, off: usize, val: u8) {
167        if self.mode == FlashMode::LoadPage {
168            self.page_buffer[off & (FLASH_PAGE - 1)] = val;
169        }
170    }
171}
172
173/// A cartridge save backend, sized + typed from the resolved [`SaveType`].
174#[derive(Clone, Debug, Default, Serialize, Deserialize)]
175pub enum SaveDevice {
176    /// No on-cart save.
177    #[default]
178    None,
179    /// Serial EEPROM (joybus), flat backing store (512 B or 2 KiB).
180    Eeprom(Box<[u8]>),
181    /// Battery SRAM (PI DOM2), flat 32 KiB.
182    Sram(Box<[u8]>),
183    /// FlashRAM (PI DOM2), the erase/program/status machine.
184    Flash(FlashRam),
185    /// Controller Pak (joybus), flat 32 KiB.
186    ControllerPak(Box<[u8]>),
187}
188
189impl SaveDevice {
190    /// Construct the backend for a resolved save type (erased/zeroed state).
191    #[must_use]
192    pub fn new(save_type: SaveType) -> Self {
193        let flat = |n: usize| vec![0u8; n].into_boxed_slice();
194        match save_type {
195            SaveType::None => Self::None,
196            SaveType::Eeprom4k => Self::Eeprom(flat(512)),
197            SaveType::Eeprom16k => Self::Eeprom(flat(2 * 1024)),
198            SaveType::Sram => Self::Sram(flat(32 * 1024)),
199            SaveType::FlashRam => Self::Flash(FlashRam::new()),
200            SaveType::ControllerPak => Self::ControllerPak(flat(32 * 1024)),
201        }
202    }
203
204    /// PI-bus read (SRAM / FlashRAM) at PI address `addr`. Returns `None` for a
205    /// backend not on the PI bus, so the caller can fall through to open bus.
206    #[must_use]
207    pub fn pi_read(&self, addr: u32) -> Option<u8> {
208        let off = addr.wrapping_sub(SAVE_PI_BASE) as usize;
209        match self {
210            Self::Sram(store) => Some(store.get(off).copied().unwrap_or(0)),
211            Self::Flash(flash) => Some(flash.read(off)),
212            _ => None,
213        }
214    }
215
216    /// PI-bus write (SRAM / FlashRAM). The FlashRAM CIR at `0x0801_0000` is a
217    /// 32-bit command; the Bus assembles the word and calls [`Self::flash_cir`].
218    pub fn pi_write(&mut self, addr: u32, val: u8) {
219        match self {
220            Self::Sram(store) => {
221                let off = addr.wrapping_sub(SAVE_PI_BASE) as usize;
222                if let Some(b) = store.get_mut(off) {
223                    *b = val;
224                }
225            }
226            Self::Flash(flash) => flash.write(addr.wrapping_sub(SAVE_PI_BASE) as usize, val),
227            _ => {}
228        }
229    }
230
231    /// Write the FlashRAM Command Internal Register (a whole 32-bit word).
232    pub fn flash_cir(&mut self, cmd: u32) {
233        if let Self::Flash(flash) = self {
234            flash.write_cir(cmd);
235        }
236    }
237
238    /// Joybus EEPROM read of an 8-byte block (`block` index) into `out`.
239    pub fn eeprom_read_block(&self, block: u8, out: &mut [u8; 8]) {
240        if let Self::Eeprom(store) = self {
241            let base = block as usize * 8;
242            for (i, o) in out.iter_mut().enumerate() {
243                *o = store.get(base + i).copied().unwrap_or(0);
244            }
245        }
246    }
247
248    /// Joybus EEPROM write of an 8-byte block.
249    pub fn eeprom_write_block(&mut self, block: u8, data: &[u8; 8]) {
250        if let Self::Eeprom(store) = self {
251            let base = block as usize * 8;
252            for (i, &d) in data.iter().enumerate() {
253                if let Some(b) = store.get_mut(base + i) {
254                    *b = d;
255                }
256            }
257        }
258    }
259
260    /// Joybus Controller-Pak read of a 32-byte block at `addr` (byte offset).
261    pub fn cpak_read(&self, addr: u16, out: &mut [u8; 32]) {
262        if let Self::ControllerPak(store) = self {
263            let base = addr as usize;
264            for (i, o) in out.iter_mut().enumerate() {
265                *o = store.get(base + i).copied().unwrap_or(0);
266            }
267        }
268    }
269
270    /// Joybus Controller-Pak write of a 32-byte block at `addr`.
271    pub fn cpak_write(&mut self, addr: u16, data: &[u8; 32]) {
272        if let Self::ControllerPak(store) = self {
273            let base = addr as usize;
274            for (i, &d) in data.iter().enumerate() {
275                if let Some(b) = store.get_mut(base + i) {
276                    *b = d;
277                }
278            }
279        }
280    }
281
282    /// The persistable backing bytes (for the host save file). FlashRAM exposes
283    /// its data array; the others their flat store. Empty for [`Self::None`].
284    #[must_use]
285    pub fn backing(&self) -> &[u8] {
286        match self {
287            Self::None => &[],
288            Self::Eeprom(s) | Self::Sram(s) | Self::ControllerPak(s) => s,
289            Self::Flash(f) => &f.array,
290        }
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn sram_round_trips() {
300        let mut d = SaveDevice::new(SaveType::Sram);
301        d.pi_write(SAVE_PI_BASE + 0x1234, 0xAB);
302        d.pi_write(SAVE_PI_BASE + 0x1235, 0xCD);
303        assert_eq!(d.pi_read(SAVE_PI_BASE + 0x1234), Some(0xAB));
304        assert_eq!(d.pi_read(SAVE_PI_BASE + 0x1235), Some(0xCD));
305        assert_eq!(d.backing()[0x1234], 0xAB);
306    }
307
308    #[test]
309    fn eeprom_block_round_trips() {
310        let mut d = SaveDevice::new(SaveType::Eeprom4k);
311        let data = [1, 2, 3, 4, 5, 6, 7, 8];
312        d.eeprom_write_block(3, &data);
313        let mut out = [0u8; 8];
314        d.eeprom_read_block(3, &mut out);
315        assert_eq!(out, data);
316        // A different block is untouched.
317        let mut other = [0xFFu8; 8];
318        d.eeprom_read_block(4, &mut other);
319        assert_eq!(other, [0; 8]);
320    }
321
322    #[test]
323    fn controller_pak_block_round_trips() {
324        let mut d = SaveDevice::new(SaveType::ControllerPak);
325        let data = [0x5A; 32];
326        d.cpak_write(0x0100, &data);
327        let mut out = [0u8; 32];
328        d.cpak_read(0x0100, &mut out);
329        assert_eq!(out, data);
330    }
331
332    /// **FlashRAM erase → load-page → program round-trips through its real
333    /// state machine.** A flat store would mis-handle this: program only clears
334    /// bits, so the page must be erased (all-ones) first.
335    #[test]
336    fn flashram_erase_load_program_round_trips() {
337        let mut d = SaveDevice::new(SaveType::FlashRam);
338        // Erased flash reads all-ones (ReadArray is the power-on mode).
339        assert_eq!(d.pi_read(SAVE_PI_BASE), Some(0xFF));
340
341        // Sector-erase setup for sector 0, then execute.
342        d.flash_cir(0x4B00_0000);
343        d.flash_cir(0x7800_0000);
344        // Now in Status mode: the low status byte reports ERASE_OK.
345        assert_eq!(d.pi_read(SAVE_PI_BASE + 3), Some(FLASH_STATUS_ERASE_OK));
346
347        // Load the 128-byte page buffer, program it into page 0.
348        d.flash_cir(0xB400_0000);
349        for i in 0..FLASH_PAGE {
350            d.pi_write(SAVE_PI_BASE + i as u32, i as u8);
351        }
352        d.flash_cir(0xA500_0000);
353        assert_eq!(d.pi_read(SAVE_PI_BASE + 3), Some(FLASH_STATUS_PROGRAM_OK));
354
355        // Back to ReadArray: page 0 holds the programmed bytes.
356        d.flash_cir(0xF000_0000);
357        assert_eq!(d.pi_read(SAVE_PI_BASE), Some(0));
358        assert_eq!(d.pi_read(SAVE_PI_BASE + 5), Some(5));
359        assert_eq!(d.backing()[7], 7);
360    }
361
362    /// FlashRAM program is an AND (clears bits only) — a bit cannot be set from
363    /// 0 to 1 without an intervening erase.
364    #[test]
365    fn flashram_program_only_clears_bits() {
366        let mut d = SaveDevice::new(SaveType::FlashRam);
367        d.flash_cir(0xB400_0000);
368        d.pi_write(SAVE_PI_BASE, 0x0F); // program 0x0F over the erased 0xFF
369        d.flash_cir(0xA500_0000);
370        d.flash_cir(0xF000_0000);
371        assert_eq!(d.pi_read(SAVE_PI_BASE), Some(0x0F));
372        // Program 0xF0 without erasing: AND with 0x0F → 0x00, not 0xF0.
373        d.flash_cir(0xB400_0000);
374        d.pi_write(SAVE_PI_BASE, 0xF0);
375        d.flash_cir(0xA500_0000);
376        d.flash_cir(0xF000_0000);
377        assert_eq!(
378            d.pi_read(SAVE_PI_BASE),
379            Some(0x00),
380            "program only clears bits"
381        );
382    }
383}