Skip to main content

rustynes_mappers/
m080_taito_x1_005.rs

1//! Taito X1-005 (iNES mapper 80) implementation.
2//!
3//! A Taito ASIC board (Kyonshiizu 2, Kyuukyoku Harikiri Koushien on the
4//! related X1-017 board, Bakushou!! Jinsei Gekijou, etc.). The chip exposes a
5//! small register window high in the `$6000-$7FFF` space plus an on-cart
6//! 128-byte battery RAM. There is **no IRQ**.
7//!
8//! Register map (nesdev `INES_Mapper_080.xhtml`):
9//!
10//! ```text
11//!   $7EF0 [CCCC CCCM]  2 KiB CHR bank -> PPU $0000-$07FF (value & 0xFE)
12//!   $7EF1 [CCCC CCCM]  2 KiB CHR bank -> PPU $0800-$0FFF (value & 0xFE)
13//!   $7EF2 [CCCC CCCC]  1 KiB CHR bank -> PPU $1000-$13FF
14//!   $7EF3 [CCCC CCCC]  1 KiB CHR bank -> PPU $1400-$17FF
15//!   $7EF4 [CCCC CCCC]  1 KiB CHR bank -> PPU $1800-$1BFF
16//!   $7EF5 [CCCC CCCC]  1 KiB CHR bank -> PPU $1C00-$1FFF
17//!   $7EF6 [.... ...M]  M = mirroring (0 = Horizontal, 1 = Vertical)
18//!   $7EF8 [VVVV VVVV]  RAM enable port A (write $A3 to enable)
19//!   $7EF9 [VVVV VVVV]  RAM enable port B (write $A3 to enable)
20//!   $7EFA/$7EFB [PPPP PPPP]  8 KiB PRG bank -> $8000-$9FFF
21//!   $7EFC/$7EFD [PPPP PPPP]  8 KiB PRG bank -> $A000-$BFFF
22//!   $7EFE/$7EFF [PPPP PPPP]  8 KiB PRG bank -> $C000-$DFFF
23//!   $7F00-$7FFF        128-byte battery RAM (mirrored every $80) — enabled by
24//!                      writing $A3 to BOTH $7EF8 and $7EF9.
25//! ```
26//!
27//! The two 2 KiB CHR registers ($7EF0/$7EF1) carry the CHR bank in the upper
28//! seven bits; the standard board ignores the low "nametable" bit and uses the
29//! `$7EF6` software H/V control for CIRAM mirroring, which is what we model.
30//! There are THREE switchable 8 KiB PRG banks ($8000/$A000/$C000) selected by
31//! `$7EFA`/`$7EFC`/`$7EFE` (each with an odd-address alias); only `$E000` is
32//! fixed to the last bank. (nesdev `INES_Mapper_080`, verified against the
33//! Mesen2 `TaitoX1005` board: missing the `$7EFE` $C000 register stranded the
34//! reset bank and blanked `Kyonshiizu 2`; `$7EF6` polarity is 0=Horz/1=Vert.)
35//!
36//! See `docs/mappers.md` §Mapper coverage matrix.
37
38#![allow(clippy::cast_possible_truncation, clippy::doc_markdown)]
39
40use crate::cartridge::Mirroring;
41use crate::mapper::{Mapper, MapperCaps, MapperError};
42use alloc::{boxed::Box, vec::Vec};
43use alloc::{format, vec};
44
45const PRG_BANK_8K: usize = 0x2000;
46const CHR_BANK_1K: usize = 0x0400;
47const NAMETABLE_SIZE: usize = 0x0400;
48const NAMETABLE_SIZE_U16: u16 = 0x0400;
49const RAM_LEN: usize = 0x80; // 128-byte on-cart battery RAM
50const RAM_MAGIC: u8 = 0xA3;
51
52// Bumped 1 -> 2 when the switchable PRG bank array grew from 2 to 3 entries
53// (the `$7EFE/$7EFF` $C000 register was added): the header layout changed size,
54// so a version-1 blob is structurally incompatible and is rejected cleanly via
55// `UnsupportedVersion` rather than surfacing a confusing length mismatch.
56const SAVE_STATE_VERSION: u8 = 2;
57
58/// Taito X1-005 mapper (iNES mapper 80).
59pub struct TaitoX1005 {
60    prg_rom: Box<[u8]>,
61    chr: Box<[u8]>,
62    vram: Box<[u8]>,
63    ram: [u8; RAM_LEN],
64    chr_is_ram: bool,
65    /// 1 KiB CHR bank registers for PPU `$0000-$1FFF` (eight 1 KiB windows).
66    chr_1k: [u8; 8],
67    /// 8 KiB PRG banks for `$8000`, `$A000` and `$C000` (`$E000` is fixed).
68    prg_bank: [u8; 3],
69    mirroring: Mirroring,
70    /// `$7EF8` / `$7EF9` enable-latch; RAM is readable/writable only when both
71    /// hold `$A3`.
72    ram_enable: [u8; 2],
73}
74
75impl TaitoX1005 {
76    /// Construct a new Taito X1-005 mapper.
77    ///
78    /// `prg_rom` must be a non-zero multiple of 8 KiB. CHR-RAM is selected when
79    /// `chr_rom` is empty; otherwise CHR-ROM length must be a multiple of 1 KiB.
80    ///
81    /// # Errors
82    ///
83    /// Returns [`MapperError::Invalid`] when sizes don't match the constraints.
84    pub fn new(
85        prg_rom: Box<[u8]>,
86        chr_rom: Box<[u8]>,
87        mirroring: Mirroring,
88    ) -> Result<Self, MapperError> {
89        if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_8K) {
90            return Err(MapperError::Invalid(format!(
91                "Taito-X1-005 PRG-ROM size {} is not a non-zero multiple of 8 KiB",
92                prg_rom.len()
93            )));
94        }
95        let chr_is_ram = chr_rom.is_empty();
96        let chr: Box<[u8]> = if chr_is_ram {
97            vec![0u8; 8 * CHR_BANK_1K].into_boxed_slice()
98        } else if chr_rom.len().is_multiple_of(CHR_BANK_1K) {
99            chr_rom
100        } else {
101            return Err(MapperError::Invalid(format!(
102                "Taito-X1-005 expects a 1 KiB multiple of CHR; got {} bytes",
103                chr_rom.len()
104            )));
105        };
106        Ok(Self {
107            prg_rom,
108            chr,
109            vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
110            ram: [0u8; RAM_LEN],
111            chr_is_ram,
112            chr_1k: [0, 1, 2, 3, 4, 5, 6, 7],
113            prg_bank: [0, 1, 2],
114            mirroring,
115            ram_enable: [0, 0],
116        })
117    }
118
119    const fn ram_enabled(&self) -> bool {
120        self.ram_enable[0] == RAM_MAGIC && self.ram_enable[1] == RAM_MAGIC
121    }
122
123    const fn nametable_offset(&self, addr: u16) -> usize {
124        let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
125        let local = (addr as usize) & (NAMETABLE_SIZE - 1);
126        let physical = self.mirroring.physical_bank(table);
127        physical * NAMETABLE_SIZE + local
128    }
129
130    fn read_prg(&self, addr: u16) -> u8 {
131        let bank_count = (self.prg_rom.len() / PRG_BANK_8K).max(1);
132        let slot = (addr >> 13) & 0x03; // 0=$8000,1=$A000,2=$C000,3=$E000
133        let bank = match slot {
134            0 => self.prg_bank[0] as usize,
135            1 => self.prg_bank[1] as usize,
136            2 => self.prg_bank[2] as usize,
137            // $E000-$FFFF is hard-wired to the last 8 KiB bank (the reset
138            // vector lives here).
139            _ => bank_count - 1,
140        } % bank_count;
141        let off = (addr as usize) & (PRG_BANK_8K - 1);
142        self.prg_rom[bank * PRG_BANK_8K + off]
143    }
144
145    fn chr_offset(&self, addr: u16) -> usize {
146        let len = self.chr.len().max(1);
147        let idx = ((addr >> 10) & 0x07) as usize; // 0..=7 over $0000-$1FFF
148        let base = (self.chr_1k[idx] as usize) * CHR_BANK_1K;
149        (base + (addr as usize & (CHR_BANK_1K - 1))) % len
150    }
151}
152
153impl Mapper for TaitoX1005 {
154    // Battery save: the 128-byte on-chip RAM at `$7F00-$7FFF` (core audit
155    // IMP-09). Exposed whatever the `$7EF8/$7EF9 = $A3` enable says -- the
156    // enable gates CPU access, not the battery-backed contents.
157    fn sram(&self) -> &[u8] {
158        &self.ram
159    }
160    fn sram_mut(&mut self) -> &mut [u8] {
161        &mut self.ram
162    }
163
164    // v2.8.0 Phase 4 — no per-cycle hooks (no IRQ, no audio): the bus
165    // skips all four per-CPU-cycle dispatches for this board.
166    fn caps(&self) -> MapperCaps {
167        MapperCaps::NONE
168    }
169
170    fn cpu_read(&mut self, addr: u16) -> u8 {
171        match addr {
172            0x7F00..=0x7FFF => {
173                if self.ram_enabled() {
174                    self.ram[(addr as usize) & (RAM_LEN - 1)]
175                } else {
176                    0
177                }
178            }
179            0x8000..=0xFFFF => self.read_prg(addr),
180            _ => 0,
181        }
182    }
183
184    fn cpu_write(&mut self, addr: u16, value: u8) {
185        match addr {
186            // Register window. The chip decodes $7EF0-$7EFF; the 128-byte RAM
187            // occupies $7F00-$7FFF. The two 2 KiB registers each drive a pair
188            // of adjacent 1 KiB slots (value & 0xFE = even base, +1 for the
189            // second half).
190            0x7EF0 => {
191                let base = value & 0xFE;
192                self.chr_1k[0] = base;
193                self.chr_1k[1] = base | 1;
194            }
195            0x7EF1 => {
196                let base = value & 0xFE;
197                self.chr_1k[2] = base;
198                self.chr_1k[3] = base | 1;
199            }
200            0x7EF2 => self.chr_1k[4] = value,
201            0x7EF3 => self.chr_1k[5] = value,
202            0x7EF4 => self.chr_1k[6] = value,
203            0x7EF5 => self.chr_1k[7] = value,
204            0x7EF6 | 0x7EF7 => {
205                // $7EF6 bit 0: 0 = Horizontal, 1 = Vertical (nesdev mapper 080;
206                // Mesen2 TaitoX1005).
207                self.mirroring = if (value & 0x01) != 0 {
208                    Mirroring::Vertical
209                } else {
210                    Mirroring::Horizontal
211                };
212            }
213            0x7EF8 => self.ram_enable[0] = value,
214            0x7EF9 => self.ram_enable[1] = value,
215            0x7EFA | 0x7EFB => self.prg_bank[0] = value,
216            0x7EFC | 0x7EFD => self.prg_bank[1] = value,
217            0x7EFE | 0x7EFF => self.prg_bank[2] = value,
218            0x7F00..=0x7FFF if self.ram_enabled() => {
219                self.ram[(addr as usize) & (RAM_LEN - 1)] = value;
220            }
221            _ => {}
222        }
223    }
224
225    fn ppu_read(&mut self, addr: u16) -> u8 {
226        let addr = addr & 0x3FFF;
227        match addr {
228            0x0000..=0x1FFF => self.chr[self.chr_offset(addr)],
229            0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)],
230            _ => 0,
231        }
232    }
233
234    fn ppu_write(&mut self, addr: u16, value: u8) {
235        let addr = addr & 0x3FFF;
236        match addr {
237            0x0000..=0x1FFF => {
238                if self.chr_is_ram {
239                    let off = self.chr_offset(addr);
240                    self.chr[off] = value;
241                }
242            }
243            0x2000..=0x3EFF => {
244                let off = self.nametable_offset(addr);
245                self.vram[off] = value;
246            }
247            _ => {}
248        }
249    }
250
251    fn current_mirroring(&self) -> Mirroring {
252        self.mirroring
253    }
254
255    fn debug_info(&self) -> crate::mapper::MapperDebugInfo {
256        let mut info = crate::mapper::MapperDebugInfo {
257            mapper_id: 80,
258            name: "Taito X1-005 (80)".into(),
259            mirroring: crate::mapper::mirroring_name(self.mirroring),
260            ..Default::default()
261        };
262        for (i, b) in self.prg_bank.iter().enumerate() {
263            info.prg_banks
264                .push((format!("PRG{i}"), format!("{b:#04x}")));
265        }
266        for (i, b) in self.chr_1k.iter().enumerate() {
267            info.chr_banks
268                .push((format!("CHR{i}"), format!("{b:#04x}")));
269        }
270        info
271    }
272
273    fn save_state(&self) -> Vec<u8> {
274        // Header: 1 (version) + 8 (chr_1k) + 3 (prg_bank) + 1 (mirroring) +
275        // 2 (ram_enable) = 15 bytes.
276        let mut out = Vec::with_capacity(
277            15 + RAM_LEN + self.vram.len() + if self.chr_is_ram { self.chr.len() } else { 0 },
278        );
279        out.push(SAVE_STATE_VERSION);
280        out.extend_from_slice(&self.chr_1k);
281        out.extend_from_slice(&self.prg_bank);
282        out.push(match self.mirroring {
283            Mirroring::Horizontal => 1,
284            _ => 0,
285        });
286        out.extend_from_slice(&self.ram_enable);
287        out.extend_from_slice(&self.ram);
288        out.extend_from_slice(&self.vram);
289        if self.chr_is_ram {
290            out.extend_from_slice(&self.chr);
291        }
292        out
293    }
294
295    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
296        // Check the version byte FIRST: a version-1 blob has a different length
297        // (the PRG bank array grew 2 -> 3), so checking length first would mask
298        // the real cause with a `WrongLength` error. An empty blob has no version
299        // byte to read, so fall through to the length check in that case.
300        if let Some(&ver) = data.first()
301            && ver != SAVE_STATE_VERSION
302        {
303            return Err(MapperError::UnsupportedVersion(ver));
304        }
305        let need_chr = if self.chr_is_ram { self.chr.len() } else { 0 };
306        let expected = 15 + RAM_LEN + self.vram.len() + need_chr;
307        if data.len() != expected {
308            return Err(MapperError::WrongLength {
309                expected,
310                got: data.len(),
311            });
312        }
313        self.chr_1k.copy_from_slice(&data[1..9]);
314        self.prg_bank.copy_from_slice(&data[9..12]);
315        self.mirroring = if data[12] == 1 {
316            Mirroring::Horizontal
317        } else {
318            Mirroring::Vertical
319        };
320        self.ram_enable.copy_from_slice(&data[13..15]);
321        let mut cursor = 15;
322        self.ram.copy_from_slice(&data[cursor..cursor + RAM_LEN]);
323        cursor += RAM_LEN;
324        self.vram
325            .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
326        cursor += self.vram.len();
327        if self.chr_is_ram {
328            self.chr
329                .copy_from_slice(&data[cursor..cursor + self.chr.len()]);
330        }
331        Ok(())
332    }
333}
334
335#[cfg(test)]
336#[allow(clippy::cast_possible_truncation)]
337mod tests {
338    use super::*;
339
340    fn synth_prg(banks_8k: usize) -> Box<[u8]> {
341        let mut v = vec![0u8; banks_8k * PRG_BANK_8K];
342        for b in 0..banks_8k {
343            v[b * PRG_BANK_8K] = b as u8;
344        }
345        v.into_boxed_slice()
346    }
347
348    fn synth_chr_1k(banks: usize) -> Box<[u8]> {
349        let mut v = vec![0u8; banks * CHR_BANK_1K];
350        for b in 0..banks {
351            v[b * CHR_BANK_1K] = b as u8;
352        }
353        v.into_boxed_slice()
354    }
355
356    #[test]
357    fn prg_banks_and_fixed_tail() {
358        let mut m = TaitoX1005::new(synth_prg(8), synth_chr_1k(8), Mirroring::Vertical).unwrap();
359        // Default: $8000 = bank 0, $A000 = bank 1, $C000 = bank 2, $E000 = {-1} = 7.
360        assert_eq!(m.cpu_read(0x8000), 0);
361        assert_eq!(m.cpu_read(0xA000), 1);
362        assert_eq!(m.cpu_read(0xC000), 2);
363        assert_eq!(m.cpu_read(0xE000), 7);
364        // All three switchable banks ($7EFA/$7EFC/$7EFE) select independently;
365        // only $E000 is hard-wired to the last bank.
366        m.cpu_write(0x7EFA, 3);
367        m.cpu_write(0x7EFC, 5);
368        m.cpu_write(0x7EFE, 4);
369        assert_eq!(m.cpu_read(0x8000), 3);
370        assert_eq!(m.cpu_read(0xA000), 5);
371        assert_eq!(m.cpu_read(0xC000), 4);
372        assert_eq!(m.cpu_read(0xE000), 7);
373        // Odd-address aliases hit the same registers.
374        m.cpu_write(0x7EFB, 1);
375        m.cpu_write(0x7EFD, 2);
376        m.cpu_write(0x7EFF, 6);
377        assert_eq!(m.cpu_read(0x8000), 1);
378        assert_eq!(m.cpu_read(0xA000), 2);
379        assert_eq!(m.cpu_read(0xC000), 6);
380    }
381
382    #[test]
383    fn chr_2k_and_1k_banks() {
384        let mut m = TaitoX1005::new(synth_prg(8), synth_chr_1k(16), Mirroring::Vertical).unwrap();
385        // 2K register $7EF0 (value & 0xFE) -> slots 0/1; value 4 keeps bank 4.
386        m.cpu_write(0x7EF0, 4);
387        assert_eq!(m.ppu_read(0x0000), 4);
388        assert_eq!(m.ppu_read(0x0400), 5); // adjacent 1K slot is bank+1
389        // Second 2K register $7EF1 -> slots 2/3.
390        m.cpu_write(0x7EF1, 8);
391        assert_eq!(m.ppu_read(0x0800), 8);
392        assert_eq!(m.ppu_read(0x0C00), 9);
393        // 1K registers $7EF2-$7EF5 -> slots 4..7.
394        m.cpu_write(0x7EF2, 11);
395        assert_eq!(m.ppu_read(0x1000), 11);
396        m.cpu_write(0x7EF5, 13);
397        assert_eq!(m.ppu_read(0x1C00), 13);
398    }
399
400    #[test]
401    fn mirroring_register() {
402        let mut m = TaitoX1005::new(synth_prg(4), synth_chr_1k(8), Mirroring::Vertical).unwrap();
403        // $7EF6 bit 0: 1 = Vertical, 0 = Horizontal (nesdev mapper 080).
404        m.cpu_write(0x7EF6, 0x01);
405        assert_eq!(m.current_mirroring(), Mirroring::Vertical);
406        m.cpu_write(0x7EF6, 0x00);
407        assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
408    }
409
410    #[test]
411    fn battery_ram_needs_both_magic_writes() {
412        let mut m = TaitoX1005::new(synth_prg(4), synth_chr_1k(8), Mirroring::Vertical).unwrap();
413        // Not enabled: writes are dropped, reads return 0.
414        m.cpu_write(0x7F00, 0x55);
415        assert_eq!(m.cpu_read(0x7F00), 0);
416        // Only one magic latch -> still disabled.
417        m.cpu_write(0x7EF8, RAM_MAGIC);
418        m.cpu_write(0x7F00, 0x55);
419        assert_eq!(m.cpu_read(0x7F00), 0);
420        // Both magic latches -> enabled.
421        m.cpu_write(0x7EF9, RAM_MAGIC);
422        m.cpu_write(0x7F00, 0x55);
423        assert_eq!(m.cpu_read(0x7F00), 0x55);
424        // Mirrors every 128 bytes within $7F00-$7FFF.
425        assert_eq!(m.cpu_read(0x7F80), 0x55);
426    }
427
428    #[test]
429    fn load_state_rejects_old_version_cleanly() {
430        // A version-1 blob (the pre-fix 2-entry PRG layout) is one byte shorter
431        // than the current version-2 header. The version byte is checked first,
432        // so it must surface a clean `UnsupportedVersion(1)` rather than a
433        // confusing `WrongLength` length mismatch.
434        let mut m = TaitoX1005::new(synth_prg(8), synth_chr_1k(16), Mirroring::Vertical).unwrap();
435        let mut blob = m.save_state();
436        blob[0] = 1; // masquerade as a legacy version-1 state
437        match m.load_state(&blob) {
438            Err(MapperError::UnsupportedVersion(1)) => {}
439            other => panic!("expected UnsupportedVersion(1), got {other:?}"),
440        }
441    }
442
443    #[test]
444    fn save_state_round_trip() {
445        let mut m = TaitoX1005::new(synth_prg(8), synth_chr_1k(16), Mirroring::Vertical).unwrap();
446        m.cpu_write(0x7EFA, 5);
447        m.cpu_write(0x7EFC, 6);
448        m.cpu_write(0x7EF0, 4);
449        m.cpu_write(0x7EF6, 0x01);
450        m.cpu_write(0x7EF8, RAM_MAGIC);
451        m.cpu_write(0x7EF9, RAM_MAGIC);
452        m.cpu_write(0x7F10, 0xAB);
453        let blob = m.save_state();
454        let mut m2 = TaitoX1005::new(synth_prg(8), synth_chr_1k(16), Mirroring::Vertical).unwrap();
455        m2.load_state(&blob).unwrap();
456        assert_eq!(m.cpu_read(0x8000), m2.cpu_read(0x8000));
457        assert_eq!(m.cpu_read(0xA000), m2.cpu_read(0xA000));
458        assert_eq!(m.ppu_read(0x0000), m2.ppu_read(0x0000));
459        assert_eq!(m.current_mirroring(), m2.current_mirroring());
460        assert_eq!(m2.cpu_read(0x7F10), 0xAB);
461    }
462}