Skip to main content

rustynes_mappers/
m119_tqrom.rs

1//! TQROM (iNES mapper 119) implementation.
2//!
3//! TQROM is a Nintendo board built around a stock MMC3, used by *Pin\*Bot* and
4//! *High Speed*. It is byte-for-byte the MMC3 (mapper 4) for PRG banking, the
5//! A12-edge scanline IRQ, and mirroring — the *only* difference is a **mixed
6//! CHR address space**: the board carries **64 KiB of CHR-ROM plus 8 KiB of
7//! CHR-RAM**, and each 1 KiB CHR bank chooses between them at fetch time.
8//!
9//! Per `nesdev_wiki/INES_Mapper_119.xhtml` / "TQROM": the MMC3 CHR bank
10//! registers are 8-bit, and **bit 6 of the resolved 1 KiB bank number selects
11//! the memory**:
12//!
13//! - bit 6 **clear** → CHR-ROM (the low 6 bits index the 64 KiB = 64 banks),
14//! - bit 6 **set**   → CHR-RAM (8 KiB = 8 banks, so the low 3 bits index it).
15//!
16//! CHR writes only land when the selected bank addresses CHR-RAM; a write to a
17//! CHR-ROM-selected bank is ignored (it is ROM).
18//!
19//! Because the ROM/RAM split is invisible to the stock MMC3 (which masks every
20//! bank into a single CHR slice), this mapper **embeds an [`Mmc3`]** holding the
21//! CHR-ROM and delegates PRG / IRQ / mirroring to it verbatim, but takes over
22//! the pattern-table (`$0000-$1FFF`) read/write path: it asks the embedded
23//! MMC3 for the *raw* 1 KiB bank number ([`Mmc3::chr_bank_1k`]) and routes the
24//! access to its own CHR-ROM or CHR-RAM accordingly. It also snoops the
25//! bank-select / bank-data writes — but only to forward them; the MMC3 keeps
26//! the authoritative registers, so `chr_bank_1k` reflects the live banking.
27
28#![allow(
29    clippy::cast_possible_truncation,
30    clippy::cast_lossless,
31    clippy::missing_const_for_fn,
32    clippy::doc_markdown
33)]
34
35use crate::cartridge::Mirroring;
36use crate::m004_mmc3::Mmc3;
37use crate::mapper::{Mapper, MapperCaps, MapperDebugInfo, MapperError};
38use alloc::format;
39use alloc::string::ToString;
40use alloc::{boxed::Box, vec, vec::Vec};
41
42const CHR_BANK_1K: usize = 0x0400;
43const CHR_RAM_SIZE: usize = 8 * CHR_BANK_1K; // 8 KiB
44/// Bit 6 of a resolved 1 KiB CHR bank number selects CHR-RAM (set) vs
45/// CHR-ROM (clear).
46const CHR_RAM_SELECT: usize = 0x40;
47
48const SAVE_STATE_VERSION: u8 = 1;
49
50/// TQROM mapper (iNES mapper 119): MMC3 core with a mixed 64 KiB CHR-ROM +
51/// 8 KiB CHR-RAM address space.
52pub struct Tqrom {
53    inner: Mmc3,
54    chr_ram: Box<[u8]>,
55}
56
57impl Tqrom {
58    /// Construct a new TQROM mapper.
59    ///
60    /// `prg_rom` / `chr_rom` follow [`Mmc3::new`]; `chr_rom` is the 64 KiB
61    /// CHR-ROM (TQROM is never CHR-RAM-only — the 8 KiB CHR-RAM is always
62    /// allocated here in addition). `prg_ram_bytes == 0` selects the default
63    /// 8 KiB.
64    ///
65    /// # Errors
66    ///
67    /// Returns [`MapperError::Invalid`] on size mismatch (propagated from the
68    /// embedded MMC3).
69    pub fn new(
70        prg_rom: Box<[u8]>,
71        chr_rom: Box<[u8]>,
72        initial_mirroring: Mirroring,
73        prg_ram_bytes: usize,
74    ) -> Result<Self, MapperError> {
75        let inner = Mmc3::new(
76            prg_rom,
77            chr_rom,
78            initial_mirroring,
79            prg_ram_bytes,
80            crate::m004_mmc3::Mmc3Revision::Sharp,
81        )?;
82        Ok(Self {
83            inner,
84            chr_ram: vec![0u8; CHR_RAM_SIZE].into_boxed_slice(),
85        })
86    }
87
88    /// Resolve a pattern-table address (`$0000-$1FFF`) to whether it selects
89    /// CHR-RAM and the byte offset within the selected memory.
90    fn resolve_chr(&self, addr: u16) -> (bool, usize) {
91        let bank = self.inner.chr_bank_1k(addr);
92        let offset_in_bank = (addr as usize) & (CHR_BANK_1K - 1);
93        if bank & CHR_RAM_SELECT != 0 {
94            // CHR-RAM: low 3 bits index the 8 KiB (8 banks).
95            let ram_bank = bank & 0x07;
96            (true, ram_bank * CHR_BANK_1K + offset_in_bank)
97        } else {
98            // CHR-ROM: low 6 bits index the 64 KiB (64 banks).
99            let rom_bank = bank & 0x3F;
100            (false, rom_bank * CHR_BANK_1K + offset_in_bank)
101        }
102    }
103}
104
105impl Mapper for Tqrom {
106    // Battery save lives in the wrapped MMC3's PRG-RAM (core audit IMP-10).
107    fn sram(&self) -> &[u8] {
108        self.inner.sram()
109    }
110    fn sram_mut(&mut self) -> &mut [u8] {
111        self.inner.sram_mut()
112    }
113
114    // v2.8.0 Phase 4 — CPU-cycle hook + IRQ source; no on-cart audio.
115    fn caps(&self) -> MapperCaps {
116        MapperCaps::CYCLE_IRQ
117    }
118
119    fn cpu_read(&mut self, addr: u16) -> u8 {
120        self.inner.cpu_read(addr)
121    }
122
123    fn cpu_write(&mut self, addr: u16, value: u8) {
124        self.inner.cpu_write(addr, value);
125    }
126
127    fn cpu_read_unmapped(&self, addr: u16) -> bool {
128        self.inner.cpu_read_unmapped(addr)
129    }
130
131    fn ppu_read(&mut self, addr: u16) -> u8 {
132        let a = addr & 0x3FFF;
133        if a < 0x2000 {
134            let (is_ram, off) = self.resolve_chr(a);
135            if is_ram {
136                self.chr_ram[off % self.chr_ram.len()]
137            } else {
138                // Read CHR-ROM through the embedded MMC3, which holds the
139                // 64 KiB CHR slice. The MMC3 masks the bank against its own
140                // CHR size; for a 64 KiB CHR-ROM that mask is identical to
141                // our 6-bit `& 0x3F`, so this yields the correct byte.
142                self.inner.ppu_read(a)
143            }
144        } else {
145            // Nametable reads delegate to the MMC3 (mirroring + 4-screen).
146            self.inner.ppu_read(a)
147        }
148    }
149
150    fn ppu_write(&mut self, addr: u16, value: u8) {
151        let a = addr & 0x3FFF;
152        if a < 0x2000 {
153            let (is_ram, off) = self.resolve_chr(a);
154            if is_ram {
155                let len = self.chr_ram.len();
156                self.chr_ram[off % len] = value;
157            }
158            // CHR-ROM-selected bank: write ignored (it is ROM).
159        } else {
160            self.inner.ppu_write(a, value);
161        }
162    }
163
164    fn nametable_address(&self, addr: u16) -> u16 {
165        self.inner.nametable_address(addr)
166    }
167
168    fn current_mirroring(&self) -> Mirroring {
169        self.inner.current_mirroring()
170    }
171
172    fn notify_a12(&mut self, level: bool) {
173        self.inner.notify_a12(level);
174    }
175
176    fn notify_a12_at_sub_dot(&mut self, level: bool, sub_dot: u8) {
177        self.inner.notify_a12_at_sub_dot(level, sub_dot);
178    }
179
180    fn notify_cpu_cycle(&mut self) {
181        self.inner.notify_cpu_cycle();
182    }
183
184    fn irq_pending(&self) -> bool {
185        self.inner.irq_pending()
186    }
187
188    fn irq_acknowledge(&mut self) {
189        self.inner.irq_acknowledge();
190    }
191
192    fn debug_info(&self) -> MapperDebugInfo {
193        let mut info = self.inner.debug_info();
194        info.mapper_id = 119;
195        info.name = "TQROM (119)".into();
196        for slot in 0u16..8 {
197            let (is_ram, _) = self.resolve_chr(slot * CHR_BANK_1K as u16);
198            info.chr_banks.push((
199                format!("slot{slot}"),
200                if is_ram { "RAM" } else { "ROM" }.to_string(),
201            ));
202        }
203        info
204    }
205
206    fn save_state(&self) -> Vec<u8> {
207        // Our own small header (version + CHR-RAM) followed by the embedded
208        // MMC3's full save state.
209        let inner = self.inner.save_state();
210        let mut out = Vec::with_capacity(1 + self.chr_ram.len() + inner.len());
211        out.push(SAVE_STATE_VERSION);
212        out.extend_from_slice(&self.chr_ram);
213        out.extend_from_slice(&inner);
214        out
215    }
216
217    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
218        let header = 1 + self.chr_ram.len();
219        if data.len() < header {
220            return Err(MapperError::WrongLength {
221                expected: header,
222                got: data.len(),
223            });
224        }
225        if data[0] != SAVE_STATE_VERSION {
226            return Err(MapperError::UnsupportedVersion(data[0]));
227        }
228        self.chr_ram.copy_from_slice(&data[1..header]);
229        self.inner.load_state(&data[header..])
230    }
231}
232
233#[cfg(test)]
234#[allow(clippy::cast_possible_truncation)]
235mod tests {
236    use super::*;
237    use alloc::vec;
238
239    const PRG_BANK_8K: usize = 0x2000;
240
241    fn synth_prg(banks_8k: usize) -> Box<[u8]> {
242        let mut v = vec![0u8; banks_8k * PRG_BANK_8K];
243        for b in 0..banks_8k {
244            v[b * PRG_BANK_8K] = b as u8;
245        }
246        v.into_boxed_slice()
247    }
248
249    /// 64 KiB CHR-ROM (64 1 KiB banks), each bank's first byte = bank number.
250    fn synth_chr_rom() -> Box<[u8]> {
251        let mut v = vec![0u8; 64 * CHR_BANK_1K];
252        for b in 0..64 {
253            v[b * CHR_BANK_1K] = b as u8;
254        }
255        v.into_boxed_slice()
256    }
257
258    fn fresh() -> Tqrom {
259        Tqrom::new(synth_prg(8), synth_chr_rom(), Mirroring::Vertical, 0).unwrap()
260    }
261
262    fn select_write(m: &mut Tqrom, reg: u8, value: u8) {
263        m.cpu_write(0x8000, reg);
264        m.cpu_write(0x8001, value);
265    }
266
267    #[test]
268    fn delegates_prg_banking_to_mmc3() {
269        let mut m = fresh();
270        // Last 8 KiB bank fixed at $E000, exactly like MMC3.
271        assert_eq!(m.cpu_read(0xE000), 7);
272        select_write(&mut m, 6, 3); // R6 = 3
273        assert_eq!(m.cpu_read(0x8000), 3);
274    }
275
276    #[test]
277    fn bit6_clear_reads_chr_rom() {
278        let mut m = fresh();
279        // CHR mode 0: R2 maps the 1 KiB slot at $1000. Set R2 = 5 (bit 6
280        // clear) -> CHR-ROM bank 5, whose first byte is 5.
281        select_write(&mut m, 2, 5);
282        assert_eq!(m.ppu_read(0x1000), 5, "bit-6-clear bank reads CHR-ROM");
283    }
284
285    #[test]
286    fn bit6_set_reads_and_writes_chr_ram() {
287        let mut m = fresh();
288        // R2 = 0x40 -> bit 6 set -> CHR-RAM bank 0. Initially zero.
289        select_write(&mut m, 2, 0x40);
290        assert_eq!(m.ppu_read(0x1000), 0, "fresh CHR-RAM bank reads zero");
291        // Writes land in CHR-RAM and read back.
292        m.ppu_write(0x1000, 0xAB);
293        assert_eq!(m.ppu_read(0x1000), 0xAB, "CHR-RAM is writable");
294        // A different CHR-RAM bank (0x40 | 1) is independent storage.
295        select_write(&mut m, 2, 0x41);
296        assert_eq!(m.ppu_read(0x1000), 0, "CHR-RAM bank 1 distinct from bank 0");
297        m.ppu_write(0x1000, 0xCD);
298        assert_eq!(m.ppu_read(0x1000), 0xCD);
299        // Back to bank 0: original value preserved.
300        select_write(&mut m, 2, 0x40);
301        assert_eq!(m.ppu_read(0x1000), 0xAB);
302    }
303
304    #[test]
305    fn chr_rom_writes_are_ignored() {
306        let mut m = fresh();
307        // R2 = 5 (CHR-ROM bank 5). A write must be ignored (ROM).
308        select_write(&mut m, 2, 5);
309        m.ppu_write(0x1000, 0xFF);
310        assert_eq!(m.ppu_read(0x1000), 5, "write to a CHR-ROM bank is ignored");
311    }
312
313    #[test]
314    fn chr_ram_bank_index_uses_low_3_bits() {
315        let mut m = fresh();
316        // Bank 0x40 | 7 selects CHR-RAM bank 7 (the last of the 8 KiB).
317        select_write(&mut m, 2, 0x47);
318        m.ppu_write(0x1000, 0x77);
319        assert_eq!(m.ppu_read(0x1000), 0x77);
320        // 0x40 | 0x0F masks to the same low-3-bits = 7 bank.
321        select_write(&mut m, 2, 0x4F);
322        assert_eq!(
323            m.ppu_read(0x1000),
324            0x77,
325            "CHR-RAM bank index is the low 3 bits of the register"
326        );
327    }
328
329    #[test]
330    fn irq_delegates_to_mmc3() {
331        let mut m = fresh();
332        m.cpu_write(0xC000, 3);
333        m.cpu_write(0xC001, 0);
334        m.cpu_write(0xE001, 0);
335        for _ in 0..5 {
336            m.notify_a12(false);
337            for _ in 0..4 {
338                m.notify_cpu_cycle();
339            }
340            m.notify_a12(true);
341        }
342        assert!(m.irq_pending(), "IRQ counter behaves like MMC3");
343    }
344
345    #[test]
346    fn mirroring_register_toggles_h_v() {
347        let mut m = fresh();
348        m.cpu_write(0xA000, 0);
349        assert_eq!(m.current_mirroring(), Mirroring::Vertical);
350        m.cpu_write(0xA000, 1);
351        assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
352    }
353
354    #[test]
355    fn save_load_round_trip_preserves_chr_ram() {
356        let mut m = fresh();
357        select_write(&mut m, 6, 3);
358        select_write(&mut m, 2, 0x40); // CHR-RAM bank 0
359        m.ppu_write(0x1000, 0x5A);
360        m.cpu_write(0xC000, 0x10);
361        let blob = m.save_state();
362        let mut other = fresh();
363        other.load_state(&blob).unwrap();
364        assert_eq!(other.cpu_read(0x8000), m.cpu_read(0x8000));
365        // CHR-RAM round-trips.
366        other.cpu_write(0x8000, 2);
367        other.cpu_write(0x8001, 0x40);
368        assert_eq!(other.ppu_read(0x1000), 0x5A);
369    }
370}