Skip to main content

rustynes_mappers/
m132_txc_22211.rs

1//! TXC `UNL-22211` (mapper 132).
2//!
3//! Built around the TXC bank-select ASIC, modelled here as [`TxcChip`]: a
4//! small accumulator-and-latch state machine driven through the
5//! `$4100-$4103` window whose output only reaches the banking registers when
6//! the game subsequently writes `$8000`. That two-stage handshake is the whole
7//! point of the chip -- it is a crude copy-protection measure, since a naive
8//! emulator that banks on the `$4100` write alone produces the wrong bank.
9//!
10//! The simpler TXC board on mapper 36 is in `m036_txc_policeman.rs`; Sachen's
11//! 3011 drives a variant of the same chip, duplicated in `m136_sachen_3011.rs`.
12//!
13//! A best-effort (Tier-2) board: register-decode correctness verified against
14//! the reference emulators (`Mesen2`, `GeraNES`) and the nesdev wiki, with no
15//! commercial-oracle ROM in the tree. Banking math is direct slice indexing and
16//! every bank select wraps with `% count`, so a register write can never index
17//! out of bounds -- required for the `#![no_std]` chip stack, which cannot
18//! afford a panic on a register access.
19//!
20//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
21//! and `docs/mappers.md` §Mapper coverage matrix.
22
23#![allow(
24    clippy::bool_to_int_with_if,
25    clippy::cast_lossless,
26    clippy::cast_possible_truncation,
27    clippy::doc_markdown,
28    clippy::match_same_arms,
29    clippy::missing_const_for_fn,
30    clippy::similar_names,
31    clippy::struct_excessive_bools,
32    clippy::too_many_lines,
33    clippy::unreadable_literal
34)]
35
36use crate::cartridge::Mirroring;
37use crate::mapper::{Mapper, MapperCaps, MapperError};
38use alloc::{boxed::Box, vec::Vec};
39use alloc::{format, vec};
40
41const PRG_BANK_32K: usize = 0x8000;
42const CHR_BANK_8K: usize = 0x2000;
43const NAMETABLE_SIZE: usize = 0x0400;
44const NAMETABLE_SIZE_U16: u16 = 0x0400;
45
46const SAVE_STATE_VERSION: u8 = 1;
47
48// ---------------------------------------------------------------------------
49// Shared nametable helper (mirrors the one in the other simple-mapper modules).
50// ---------------------------------------------------------------------------
51
52const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
53    let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
54    let local = (addr as usize) & (NAMETABLE_SIZE - 1);
55    let physical = mirroring.physical_bank(table);
56    physical * NAMETABLE_SIZE + local
57}
58
59/// The TXC scrambling-accumulator chip (mappers 132 / 172 / 173 family). This
60/// is the non-JV001 variant used by mapper 132.
61#[derive(Clone, Copy, Default)]
62struct TxcChip {
63    accumulator: u8,
64    inverter: u8,
65    staging: u8,
66    output: u8,
67    increase: bool,
68    invert: bool,
69}
70
71impl TxcChip {
72    const MASK: u8 = 0x07;
73
74    const fn output(self) -> u8 {
75        self.output
76    }
77
78    const fn read(self) -> u8 {
79        let invert_xor = if self.invert { 0xFF } else { 0x00 };
80        (self.accumulator & Self::MASK) | ((self.inverter ^ invert_xor) & !Self::MASK)
81    }
82
83    /// `absolute` is the full CPU address of the write (e.g. `0x4100` or
84    /// `0x8000`); `value` is the 4-bit-masked data already supplied by the
85    /// caller for the register path.
86    const fn write(&mut self, absolute: u16, value: u8) {
87        if absolute < 0x8000 {
88            match absolute & 0xE103 {
89                0x4100 => {
90                    if self.increase {
91                        self.accumulator = self.accumulator.wrapping_add(1);
92                    } else {
93                        let invert_xor = if self.invert { 0xFF } else { 0x00 };
94                        self.accumulator = ((self.accumulator & !Self::MASK)
95                            | (self.staging & Self::MASK))
96                            ^ invert_xor;
97                    }
98                }
99                0x4101 => self.invert = (value & 0x01) != 0,
100                0x4102 => {
101                    self.staging = value & Self::MASK;
102                    self.inverter = value & !Self::MASK;
103                }
104                0x4103 => self.increase = (value & 0x01) != 0,
105                _ => {}
106            }
107        } else {
108            // $8000+ latches the scrambled output (non-JV001 layout).
109            self.output = (self.accumulator & 0x0F) | ((self.inverter & 0x08) << 1);
110        }
111    }
112}
113
114/// Mapper 132 (TXC 22211).
115pub struct Txc132 {
116    prg_rom: Box<[u8]>,
117    chr_rom: Box<[u8]>,
118    vram: Box<[u8]>,
119    txc: TxcChip,
120    mirroring: Mirroring,
121}
122
123impl Txc132 {
124    /// Construct a new mapper 132 board.
125    ///
126    /// # Errors
127    ///
128    /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
129    /// 32 KiB or CHR-ROM is empty / not a multiple of 8 KiB.
130    pub fn new(
131        prg_rom: Box<[u8]>,
132        chr_rom: Box<[u8]>,
133        mirroring: Mirroring,
134    ) -> Result<Self, MapperError> {
135        if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_32K) {
136            return Err(MapperError::Invalid(format!(
137                "mapper 132 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
138                prg_rom.len()
139            )));
140        }
141        if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_8K) {
142            return Err(MapperError::Invalid(format!(
143                "mapper 132 CHR-ROM size {} is not a non-zero multiple of 8 KiB",
144                chr_rom.len()
145            )));
146        }
147        Ok(Self {
148            prg_rom,
149            chr_rom,
150            vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
151            txc: TxcChip::default(),
152            mirroring,
153        })
154    }
155}
156
157impl Mapper for Txc132 {
158    fn caps(&self) -> MapperCaps {
159        MapperCaps::NONE
160    }
161
162    // The chip's read port lives at $4100-$5FFF (mapped); only the $4020-$40FF
163    // gap below it is open bus. $8000-$FFFF PRG-ROM stays mapped (the trait
164    // default) — a `!(...)` here would wrongly open-bus the program ROM and the
165    // reset vector, so the board never boots.
166    fn cpu_read_unmapped(&self, addr: u16) -> bool {
167        // v2.7.2 (core audit §5.5): with no save RAM, nothing drives
168        // `$6000-$7FFF` and it floats; see `Mapper::cpu_read_unmapped`.
169        (matches!(addr, 0x6000..=0x7FFF) && self.sram().is_empty()) || {
170            (0x4020..=0x40FF).contains(&addr)
171        }
172    }
173
174    fn cpu_read(&mut self, addr: u16) -> u8 {
175        match addr {
176            0x4100..=0x5FFF => {
177                // The read is decoded on (addr & 0x0103) == 0x0100
178                // (cross-referenced against the `GeraNES` reference emulator).
179                if (addr & 0x0103) == 0x0100 {
180                    self.txc.read() & 0x0F
181                } else {
182                    0
183                }
184            }
185            0x8000..=0xFFFF => {
186                let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
187                let bank = (((self.txc.output() >> 2) & 0x01) as usize) % count;
188                self.prg_rom[bank * PRG_BANK_32K + (addr as usize - 0x8000)]
189            }
190            _ => 0,
191        }
192    }
193
194    fn cpu_write(&mut self, addr: u16, value: u8) {
195        if (0x4100..=0x5FFF).contains(&addr) || (0x8000..=0xFFFF).contains(&addr) {
196            self.txc.write(addr, value & 0x0F);
197        }
198    }
199
200    fn ppu_read(&mut self, addr: u16) -> u8 {
201        let addr = addr & 0x3FFF;
202        match addr {
203            0x0000..=0x1FFF => {
204                let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
205                let bank = ((self.txc.output() & 0x03) as usize) % count;
206                self.chr_rom[bank * CHR_BANK_8K + addr as usize]
207            }
208            0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
209            _ => 0,
210        }
211    }
212
213    fn ppu_write(&mut self, addr: u16, value: u8) {
214        let addr = addr & 0x3FFF;
215        if let 0x2000..=0x3EFF = addr {
216            let off = nametable_offset(addr, self.mirroring);
217            self.vram[off] = value;
218        }
219    }
220
221    fn current_mirroring(&self) -> Mirroring {
222        self.mirroring
223    }
224
225    fn save_state(&self) -> Vec<u8> {
226        let mut out = Vec::with_capacity(7 + self.vram.len());
227        out.push(SAVE_STATE_VERSION);
228        out.push(self.txc.accumulator);
229        out.push(self.txc.inverter);
230        out.push(self.txc.staging);
231        out.push(self.txc.output);
232        out.push(u8::from(self.txc.increase));
233        out.push(u8::from(self.txc.invert));
234        out.extend_from_slice(&self.vram);
235        out
236    }
237
238    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
239        let expected = 7 + self.vram.len();
240        if data.len() != expected {
241            return Err(MapperError::WrongLength {
242                expected,
243                got: data.len(),
244            });
245        }
246        if data[0] != SAVE_STATE_VERSION {
247            return Err(MapperError::UnsupportedVersion(data[0]));
248        }
249        self.txc.accumulator = data[1];
250        self.txc.inverter = data[2];
251        self.txc.staging = data[3];
252        self.txc.output = data[4];
253        self.txc.increase = data[5] != 0;
254        self.txc.invert = data[6] != 0;
255        self.vram.copy_from_slice(&data[7..7 + self.vram.len()]);
256        Ok(())
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    fn synth_prg_32k(banks: usize) -> Box<[u8]> {
265        let mut v = vec![0xFFu8; banks * PRG_BANK_32K];
266        for b in 0..banks {
267            v[b * PRG_BANK_32K] = b as u8;
268        }
269        v.into_boxed_slice()
270    }
271
272    fn synth_chr_8k(banks: usize) -> Box<[u8]> {
273        let mut v = vec![0u8; banks * CHR_BANK_8K];
274        for b in 0..banks {
275            v[b * CHR_BANK_8K] = b as u8;
276        }
277        v.into_boxed_slice()
278    }
279
280    #[test]
281    fn m132_txc_chip_drives_banks() {
282        let mut m = Txc132::new(synth_prg_32k(2), synth_chr_8k(4), Mirroring::Vertical).unwrap();
283        // Program the chip: set staging via $4102 (low 3 bits = staging,
284        // high bits -> inverter), set increase off ($4103 = 0), then $4100
285        // loads accumulator from staging, then $8000 latches the output.
286        m.cpu_write(0x4103, 0x00); // increase = false
287        m.cpu_write(0x4102, 0b0000_1011 & 0x0F); // staging = 3 (0b011), inverter = 0b1000
288        m.cpu_write(0x4100, 0x00); // accumulator = staging (no invert) = 3
289        m.cpu_write(0x8000, 0x00); // latch: output = (acc&0xF) | ((inv&8)<<1)
290        // acc = 3, inverter low nibble 0b1000 -> (8<<1)=0x10
291        // output = 3 | 0x10 = 0x13.
292        // PRG = (0x13>>2)&1 = 0; CHR = 0x13&3 = 3.
293        assert_eq!(m.cpu_read(0x8000), 0); // PRG bank 0
294        assert_eq!(m.ppu_read(0x0000), 3); // CHR bank 3
295        // Register read window is mapped (not open bus).
296        assert!(!m.cpu_read_unmapped(0x4100));
297    }
298
299    #[test]
300    fn m132_save_state_round_trips_txc_chip_state() {
301        let mut t = Txc132::new(synth_prg_32k(2), synth_chr_8k(4), Mirroring::Vertical).unwrap();
302        t.cpu_write(0x4103, 0x00);
303        t.cpu_write(0x4102, 0x03);
304        t.cpu_write(0x4100, 0x00);
305        t.cpu_write(0x8000, 0x00);
306        let blob = t.save_state();
307        let mut t2 = Txc132::new(synth_prg_32k(2), synth_chr_8k(4), Mirroring::Vertical).unwrap();
308        t2.load_state(&blob).unwrap();
309        assert_eq!(t2.ppu_read(0x0000), t.ppu_read(0x0000));
310    }
311}