Skip to main content

rustysnes_cart/coproc/
obc1.rs

1//! The OBC1 board — a simple sprite-object-controller ASIC (Metal Combat: Falcon's Revenge).
2//!
3//! OBC1 builds sprite (OAM) tables in its own dedicated 8 KiB RAM, which the game then DMAs to
4//! PPU OAM. There is no on-chip program (no LLE, no chip-ROM dump) — it is a fixed hardware
5//! register file over that RAM, so the board is functional the moment the cart loads
6//! (`docs/adr/0003` — nothing to silently degrade). Clean-room port of ares' `OBC1` component
7//! (ISC): every register at `$1FF0-$1FF7` (bus address `& 0x1FFF`) redirects through a movable
8//! `baseptr`/`address`/`shift` cursor into the RAM; every other address is a plain RAM byte.
9//!
10//! Bus window (`ares` board `SHVC-2E3M-01`/`OBC1-LOROM-RAM`, both LoROM): `$00-3F,$80-BF:$6000-
11//! $7FFF` and the mirror `$70-71,$F0-F1:$6000-$7FFF,$E000-$FFFF`, both masked to a repeating 8 KiB
12//! window (`addr & 0x1FFF`) over the SAME dedicated RAM — never the cartridge's own SRAM. ROM and
13//! everything else delegates to the wrapped base board.
14
15// Chip-name jargon (OBC1, OAM, ...) is not Rust code.
16#![allow(clippy::doc_markdown)]
17
18use alloc::boxed::Box;
19use alloc::vec;
20
21use rustysnes_savestate::{SaveReader, SaveStateError, SaveWriter};
22
23use crate::board::{Board, Coprocessor, MappedAddr};
24
25/// OBC1's own dedicated RAM size — 8 KiB (`ares` `SHVC-2E3M-01` Save-RAM size, confirmed against
26/// Metal Combat: Falcon's Revenge's game-database entry).
27const RAM_SIZE: usize = 0x2000;
28
29/// Classify a 24-bit CPU address into the OBC1 window, returning the RAM-relative offset
30/// (`addr & 0x1FFF`) if it lands in either bus window.
31fn classify(addr24: u32) -> Option<u16> {
32    let bank = (addr24 >> 16) & 0xFF;
33    let addr = addr24 & 0xFFFF;
34    let in_main = matches!(bank, 0x00..=0x3F | 0x80..=0xBF) && (0x6000..=0x7FFF).contains(&addr);
35    let in_mirror = matches!(bank, 0x70 | 0x71 | 0xF0 | 0xF1)
36        && ((0x6000..=0x7FFF).contains(&addr) || addr >= 0xE000);
37    (in_main || in_mirror).then_some((addr & 0x1FFF) as u16)
38}
39
40/// Movable read/write cursor into the 8 KiB RAM, reprogrammed via `$1FF5`/`$1FF6`.
41#[derive(Debug, Clone, Copy, Default)]
42struct Status {
43    /// Selected slot index (7-bit, `$1FF6 & 0x7F`).
44    address: u16,
45    /// Table base (`$1800` or `$1C00`, from `$1FF5` bit 0).
46    baseptr: u16,
47    /// Bit-pair shift into the packed `$1FF4` byte (`($1FF6 & 3) << 1`).
48    shift: u16,
49}
50
51/// A LoROM cartridge carrying an OBC1.
52pub struct Obc1Board {
53    inner: Box<dyn Board>,
54    ram: Box<[u8]>,
55    status: Status,
56}
57
58impl core::fmt::Debug for Obc1Board {
59    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
60        f.debug_struct("Obc1Board")
61            .field("inner", &self.inner.name())
62            .field("ram_len", &self.ram.len())
63            .field("status", &self.status)
64            .finish()
65    }
66}
67
68impl Obc1Board {
69    /// Wrap a base board (`inner`, the cart's LoROM ROM/SRAM decode) with an OBC1. The cursor is
70    /// primed from the RAM's own persisted `$1FF5`/`$1FF6` bytes (ares `OBC1::power`), so a
71    /// reloaded save continues from where it left off.
72    #[must_use]
73    pub fn new(inner: Box<dyn Board>) -> Self {
74        let ram = vec![0u8; RAM_SIZE].into_boxed_slice();
75        let mut b = Self {
76            inner,
77            ram,
78            status: Status::default(),
79        };
80        b.power();
81        b
82    }
83
84    /// Re-derive the read/write cursor from the RAM's own persisted control bytes (called once at
85    /// construction, mirroring ares' `OBC1::power`).
86    fn power(&mut self) {
87        self.status.baseptr = if self.ram_read(0x1FF5) & 1 != 0 {
88            0x1800
89        } else {
90            0x1C00
91        };
92        self.status.address = u16::from(self.ram_read(0x1FF6) & 0x7F);
93        self.status.shift = u16::from(self.ram_read(0x1FF6) & 3) << 1;
94    }
95
96    fn ram_read(&self, addr: u16) -> u8 {
97        self.ram[usize::from(addr & 0x1FFF)]
98    }
99
100    fn ram_write(&mut self, addr: u16, val: u8) {
101        self.ram[usize::from(addr & 0x1FFF)] = val;
102    }
103
104    fn read_register(&self, addr: u16) -> u8 {
105        let s = self.status;
106        match addr {
107            0x1FF0..=0x1FF3 => self.ram_read(s.baseptr + (s.address << 2) + (addr - 0x1FF0)),
108            0x1FF4 => self.ram_read(s.baseptr + (s.address >> 2) + 0x200),
109            _ => self.ram_read(addr),
110        }
111    }
112
113    fn write_register(&mut self, addr: u16, val: u8) {
114        let s = self.status;
115        match addr {
116            0x1FF0..=0x1FF3 => self.ram_write(s.baseptr + (s.address << 2) + (addr - 0x1FF0), val),
117            0x1FF4 => {
118                let slot = s.baseptr + (s.address >> 2) + 0x200;
119                let old = self.ram_read(slot);
120                let merged = (old & !(3 << s.shift)) | ((val & 3) << s.shift);
121                self.ram_write(slot, merged);
122            }
123            0x1FF5 => {
124                self.status.baseptr = if val & 1 != 0 { 0x1800 } else { 0x1C00 };
125                self.ram_write(addr, val);
126            }
127            0x1FF6 => {
128                self.status.address = u16::from(val & 0x7F);
129                self.status.shift = u16::from(val & 3) << 1;
130                self.ram_write(addr, val);
131            }
132            _ => self.ram_write(addr, val),
133        }
134    }
135}
136
137impl Board for Obc1Board {
138    fn name(&self) -> &'static str {
139        "LoROM+OBC1"
140    }
141
142    fn coprocessor(&self) -> Coprocessor {
143        Coprocessor::Obc1
144    }
145
146    fn map(&self, addr24: u32) -> MappedAddr {
147        if classify(addr24).is_some() {
148            MappedAddr::Coprocessor
149        } else {
150            self.inner.map(addr24)
151        }
152    }
153
154    fn read24(&mut self, addr24: u32) -> u8 {
155        match classify(addr24) {
156            Some(a) => self.read_register(a),
157            None => self.inner.read24(addr24),
158        }
159    }
160
161    fn write24(&mut self, addr24: u32, val: u8) {
162        if let Some(a) = classify(addr24) {
163            self.write_register(a, val);
164        } else {
165            self.inner.write24(addr24, val);
166        }
167    }
168
169    fn rom(&self) -> &[u8] {
170        self.inner.rom()
171    }
172
173    // OBC1's dedicated RAM IS the save data (ares board type=RAM content=Save) — expose it
174    // through the standard SRAM hooks so the existing save-file load/store path just works,
175    // rather than the cartridge's own (usually absent) SRAM.
176    fn sram(&self) -> &[u8] {
177        &self.ram
178    }
179
180    fn sram_mut(&mut self) -> &mut [u8] {
181        &mut self.ram
182    }
183
184    // The RAM itself round-trips via `sram`/`sram_mut` above (`System::save_state` captures SRAM
185    // separately) — the only extra state is the movable cursor, plus whatever the wrapped base
186    // board carries (nothing for plain LoROM today, but delegating keeps this composable if that
187    // ever changes).
188    fn save_state(&self, w: &mut SaveWriter) {
189        w.section(*b"OBC1", |s| {
190            s.write_u16(self.status.address);
191            s.write_u16(self.status.baseptr);
192            s.write_u16(self.status.shift);
193        });
194        self.inner.save_state(w);
195    }
196
197    fn load_state(&mut self, r: &mut SaveReader) -> Result<(), SaveStateError> {
198        let mut s = r.expect_section(*b"OBC1")?;
199        let address = s.read_u16()?;
200        let baseptr = s.read_u16()?;
201        let shift = s.read_u16()?;
202        // Untrusted (possibly hand-edited or corrupted) input: `address`/`baseptr`/`shift` are
203        // never independently free-form (they're always derived from a 7-bit `$1FF6` write and a
204        // 1-bit `$1FF5` write — see `write_register` above), so validate the exact ranges hardware
205        // can actually produce rather than trusting the bytes. An out-of-range `shift` in
206        // particular would panic on the next `$1FF4` access (`(val & 3) << shift` overflows a u8
207        // once shift >= 8) — this is the boundary that check protects.
208        if address > 0x7F || !matches!(baseptr, 0x1800 | 0x1C00) || !matches!(shift, 0 | 2 | 4 | 6)
209        {
210            return Err(SaveStateError::Invalid(alloc::format!(
211                "OBC1 cursor out of range: address={address:#x} baseptr={baseptr:#x} \
212                 shift={shift}"
213            )));
214        }
215        if s.remaining() != 0 {
216            return Err(SaveStateError::Invalid(alloc::format!(
217                "OBC1 section has {} trailing byte(s)",
218                s.remaining()
219            )));
220        }
221        self.status.address = address;
222        self.status.baseptr = baseptr;
223        self.status.shift = shift;
224        self.inner.load_state(r)
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::board::LoRom;
232    use alloc::vec;
233
234    fn board() -> Obc1Board {
235        let inner = Box::new(LoRom::new(
236            vec![0u8; 0x8_0000].into_boxed_slice(),
237            vec![].into_boxed_slice(),
238        ));
239        Obc1Board::new(inner)
240    }
241
242    #[test]
243    fn window_classify() {
244        assert_eq!(classify(0x00_6000), Some(0x0000));
245        assert_eq!(classify(0x3F_7FFF), Some(0x1FFF));
246        assert_eq!(classify(0x70_6000), Some(0x0000));
247        assert_eq!(classify(0xF1_E000), Some(0x0000));
248        assert_eq!(classify(0x00_8000), None); // ROM, not OBC1
249    }
250
251    #[test]
252    fn slot_read_write_roundtrip() {
253        let mut b = board();
254        // Select slot 5, baseptr default $1C00 (ram[$1FF5] bit0 == 0 at power-on).
255        b.write24(0x00_7FF6, 5);
256        b.write24(0x00_7FF0, 0xAA);
257        b.write24(0x00_7FF1, 0xBB);
258        assert_eq!(b.read24(0x00_7FF0), 0xAA);
259        assert_eq!(b.read24(0x00_7FF1), 0xBB);
260        // Directly verify the slot landed at baseptr + (5<<2) + 0/1.
261        assert_eq!(b.ram[0x1C00 + (5 << 2)], 0xAA);
262        assert_eq!(b.ram[0x1C00 + (5 << 2) + 1], 0xBB);
263    }
264
265    #[test]
266    fn packed_slot_read_modify_write() {
267        let mut b = board();
268        b.write24(0x00_7FF6, 3); // address=3, shift=(3&3)<<1=6
269        b.write24(0x00_7FF4, 0b11); // writes bits [7:6] of the packed byte
270        let slot = 0x1C00 + 0x200; // (address=3) >> 2 == 0
271        assert_eq!(b.ram[slot], 0b1100_0000);
272    }
273
274    #[test]
275    fn baseptr_toggle() {
276        let mut b = board();
277        b.write24(0x00_7FF5, 1);
278        assert_eq!(b.status.baseptr, 0x1800);
279        b.write24(0x00_7FF5, 0);
280        assert_eq!(b.status.baseptr, 0x1C00);
281    }
282
283    #[test]
284    fn cursor_round_trips_through_save_state() {
285        let mut b = board();
286        b.write24(0x00_7FF6, 5); // reprogram address/shift away from power-on defaults
287        b.write24(0x00_7FF5, 1); // baseptr = $1800
288
289        let mut w = SaveWriter::new();
290        b.save_state(&mut w);
291        let bytes = w.into_bytes();
292
293        let mut fresh = board();
294        let mut r = SaveReader::new(&bytes);
295        fresh.load_state(&mut r).unwrap();
296
297        assert_eq!(fresh.status.address, b.status.address);
298        assert_eq!(fresh.status.baseptr, b.status.baseptr);
299        assert_eq!(fresh.status.shift, b.status.shift);
300        assert_eq!(r.remaining(), 0);
301    }
302
303    #[test]
304    fn out_of_range_cursor_is_rejected_not_panicked_on() {
305        // shift=8 would panic on the next $1FF4 access ((val & 3) << 8 overflows a u8) if it were
306        // accepted — this is exactly the untrusted-input boundary `load_state` must guard.
307        let mut w = SaveWriter::new();
308        w.section(*b"OBC1", |s| {
309            s.write_u16(0); // address: valid
310            s.write_u16(0x1C00); // baseptr: valid
311            s.write_u16(8); // shift: OUT OF RANGE
312        });
313        let bytes = w.into_bytes();
314        let mut b = board();
315        let mut r = SaveReader::new(&bytes);
316        assert!(matches!(
317            b.load_state(&mut r),
318            Err(SaveStateError::Invalid(_))
319        ));
320    }
321
322    #[test]
323    fn trailing_bytes_in_section_are_rejected() {
324        let mut w = SaveWriter::new();
325        w.section(*b"OBC1", |s| {
326            s.write_u16(0);
327            s.write_u16(0x1C00);
328            s.write_u16(0);
329            s.write_u8(0xFF); // extra byte the real format never writes
330        });
331        let bytes = w.into_bytes();
332        let mut b = board();
333        let mut r = SaveReader::new(&bytes);
334        assert!(matches!(
335            b.load_state(&mut r),
336            Err(SaveStateError::Invalid(_))
337        ));
338    }
339}