Skip to main content

rustynes_mappers/
m136_sachen_3011.rs

1//! Sachen `3011` (mapper 136).
2//!
3//! Drives its CHR select through a TXC-style accumulator chip -- the same
4//! two-stage arrangement modelled in `txc.rs`, where a value is assembled in
5//! the `$4100-$4103` window and only latched into the banking registers on a
6//! subsequent write. The chip state is duplicated here rather than shared
7//! because the two boards clock it differently.
8//!
9//! A best-effort (Tier-2) board: register-decode correctness verified against
10//! the reference emulators (`Mesen2`, `GeraNES`) and the nesdev wiki, with no
11//! commercial-oracle ROM in the tree. Banking math is direct slice indexing and
12//! every bank select wraps with `% count`, so a register write can never index
13//! out of bounds -- required for the `#![no_std]` chip stack, which cannot
14//! afford a panic on a register access.
15//!
16//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
17//! and `docs/mappers.md` §Mapper coverage matrix.
18
19#![allow(
20    clippy::cast_possible_truncation,
21    clippy::cast_lossless,
22    clippy::match_same_arms,
23    clippy::doc_markdown,
24    clippy::similar_names,
25    clippy::too_many_lines,
26    clippy::missing_const_for_fn,
27    clippy::struct_excessive_bools,
28    clippy::bool_to_int_with_if,
29    clippy::unreadable_literal
30)]
31
32use crate::cartridge::Mirroring;
33use crate::mapper::{Mapper, MapperCaps, MapperError};
34use alloc::{boxed::Box, format, vec, vec::Vec};
35
36const PRG_BANK_8K: usize = 0x2000;
37const CHR_BANK_8K: usize = 0x2000;
38const NAMETABLE_SIZE: usize = 0x0400;
39const NAMETABLE_SIZE_U16: u16 = 0x0400;
40
41const SAVE_STATE_VERSION: u8 = 1;
42
43// ---------------------------------------------------------------------------
44// Shared nametable + mirroring helpers (mirror the other simple-mapper modules).
45// ---------------------------------------------------------------------------
46
47const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
48    let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
49    let local = (addr as usize) & (NAMETABLE_SIZE - 1);
50    let physical = mirroring.physical_bank(table);
51    physical * NAMETABLE_SIZE + local
52}
53
54const fn mirroring_to_byte(m: Mirroring) -> u8 {
55    match m {
56        Mirroring::Horizontal => 0,
57        Mirroring::Vertical => 1,
58        Mirroring::SingleScreenA => 2,
59        Mirroring::SingleScreenB => 3,
60        Mirroring::FourScreen => 4,
61        Mirroring::MapperControlled => 5,
62    }
63}
64
65const fn byte_to_mirroring(b: u8, fallback: Mirroring) -> Mirroring {
66    match b {
67        0 => Mirroring::Horizontal,
68        1 => Mirroring::Vertical,
69        2 => Mirroring::SingleScreenA,
70        3 => Mirroring::SingleScreenB,
71        4 => Mirroring::FourScreen,
72        5 => Mirroring::MapperControlled,
73        _ => fallback,
74    }
75}
76
77/// Validate a PRG-ROM image is a non-zero multiple of 8 KiB.
78fn check_prg(prg: &[u8], id: u16) -> Result<(), MapperError> {
79    if prg.is_empty() || !prg.len().is_multiple_of(PRG_BANK_8K) {
80        return Err(MapperError::Invalid(format!(
81            "mapper {id} PRG-ROM size {} is not a non-zero multiple of 8 KiB",
82            prg.len()
83        )));
84    }
85    Ok(())
86}
87
88#[derive(Default, Clone)]
89struct TxcChip {
90    accumulator: u8,
91    inverter: u8,
92    staging: u8,
93    output: u8,
94    increase: bool,
95    invert: bool,
96}
97
98impl TxcChip {
99    const MASK: u8 = 0x07;
100    const SAVE_LEN: usize = 6;
101
102    fn read(&self) -> u8 {
103        (self.accumulator & Self::MASK)
104            | ((self.inverter ^ if self.invert { 0xFF } else { 0 }) & !Self::MASK)
105    }
106
107    fn write(&mut self, addr: u16, value: u8) {
108        if addr < 0x8000 {
109            match addr & 0xE103 {
110                0x4100 => {
111                    if self.increase {
112                        self.accumulator = self.accumulator.wrapping_add(1);
113                    } else {
114                        self.accumulator = ((self.accumulator & !Self::MASK)
115                            | (self.staging & Self::MASK))
116                            ^ if self.invert { 0xFF } else { 0 };
117                    }
118                }
119                0x4101 => self.invert = value & 0x01 != 0,
120                0x4102 => {
121                    self.staging = value & Self::MASK;
122                    self.inverter = value & !Self::MASK;
123                }
124                0x4103 => self.increase = value & 0x01 != 0,
125                _ => {}
126            }
127        } else {
128            self.output = (self.accumulator & 0x0F) | ((self.inverter & 0x08) << 1);
129        }
130    }
131
132    fn save(&self, out: &mut Vec<u8>) {
133        out.push(self.accumulator);
134        out.push(self.inverter);
135        out.push(self.staging);
136        out.push(self.output);
137        out.push(u8::from(self.increase));
138        out.push(u8::from(self.invert));
139    }
140
141    fn load(&mut self, d: &[u8]) {
142        self.accumulator = d[0];
143        self.inverter = d[1];
144        self.staging = d[2];
145        self.output = d[3];
146        self.increase = d[4] != 0;
147        self.invert = d[5] != 0;
148    }
149}
150
151/// Sachen 3011 (mapper 136): TXC protection chip driving an 8 KiB CHR select.
152pub struct Sachen3011 {
153    prg_rom: Box<[u8]>,
154    chr: Box<[u8]>,
155    chr_is_ram: bool,
156    vram: Box<[u8]>,
157    mirroring: Mirroring,
158    chr_count_8k: usize,
159    txc: TxcChip,
160}
161
162impl Sachen3011 {
163    fn new(
164        prg_rom: Box<[u8]>,
165        chr_rom: Box<[u8]>,
166        mirroring: Mirroring,
167    ) -> Result<Self, MapperError> {
168        check_prg(&prg_rom, 136)?;
169        let chr_is_ram = chr_rom.is_empty();
170        let chr: Box<[u8]> = if chr_is_ram {
171            vec![0u8; CHR_BANK_8K].into_boxed_slice()
172        } else {
173            chr_rom
174        };
175        let chr_count_8k = (chr.len() / CHR_BANK_8K).max(1);
176        Ok(Self {
177            prg_rom,
178            chr,
179            chr_is_ram,
180            vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
181            mirroring,
182            chr_count_8k,
183            txc: TxcChip::default(),
184        })
185    }
186}
187
188impl Mapper for Sachen3011 {
189    fn caps(&self) -> MapperCaps {
190        MapperCaps::NONE
191    }
192
193    fn cpu_read(&mut self, addr: u16) -> u8 {
194        match addr {
195            0x4100..=0x5FFF => {
196                // $4100 returns the chip read in the low 6 bits.
197                let v = if addr & 0x103 == 0x100 {
198                    self.txc.read() & 0x3F
199                } else {
200                    0
201                };
202                self.txc.write(addr, 0); // refresh output (read has side-effects).
203                v
204            }
205            0x8000..=0xFFFF => {
206                // Wrap against the ACTUAL image length, not a rounded-up bank
207                // count. `check_prg` admits any non-zero multiple of 8 KiB, so a
208                // 16 KiB PRG yields `count == 1` and a modulus of 32768 — and
209                // `$C000` then resolves to offset 16384, one past the end of a
210                // 16 KiB slice. `% len()` is what actually upholds this crate's
211                // "a register write can never index out of bounds" invariant on
212                // ROM-parsed (untrusted) sizes. `check_prg` guarantees non-empty,
213                // so the modulus cannot divide by zero.
214                self.prg_rom[(addr as usize & 0x7FFF) % self.prg_rom.len()]
215            }
216            _ => 0,
217        }
218    }
219
220    fn cpu_read_unmapped(&self, addr: u16) -> bool {
221        // v2.7.2 (core audit §5.5): with no save RAM, nothing drives
222        // `$6000-$7FFF` and it floats; see `Mapper::cpu_read_unmapped`.
223        (matches!(addr, 0x6000..=0x7FFF) && self.sram().is_empty()) || {
224            // $4100-$5FFF except the protection port reads open bus.
225            (0x4020..=0x5FFF).contains(&addr) && (addr & 0x103 != 0x100)
226        }
227    }
228
229    fn cpu_write(&mut self, addr: u16, value: u8) {
230        if (0x4100..=0xFFFF).contains(&addr) {
231            self.txc.write(addr, value & 0x3F);
232        }
233    }
234
235    fn ppu_read(&mut self, addr: u16) -> u8 {
236        let addr = addr & 0x3FFF;
237        match addr {
238            0x0000..=0x1FFF => {
239                let bank = (self.txc.output as usize) % self.chr_count_8k;
240                self.chr[bank * CHR_BANK_8K + (addr as usize & 0x1FFF)]
241            }
242            0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
243            _ => 0,
244        }
245    }
246
247    fn ppu_write(&mut self, addr: u16, value: u8) {
248        let addr = addr & 0x3FFF;
249        match addr {
250            0x0000..=0x1FFF if self.chr_is_ram => {
251                self.chr[addr as usize & (CHR_BANK_8K - 1)] = value;
252            }
253            0x2000..=0x3EFF => {
254                let off = nametable_offset(addr, self.mirroring);
255                self.vram[off] = value;
256            }
257            _ => {}
258        }
259    }
260
261    fn current_mirroring(&self) -> Mirroring {
262        self.mirroring
263    }
264
265    fn save_state(&self) -> Vec<u8> {
266        let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
267        let mut out = Vec::with_capacity(1 + TxcChip::SAVE_LEN + 1 + self.vram.len() + chr_ram);
268        out.push(SAVE_STATE_VERSION);
269        self.txc.save(&mut out);
270        out.push(mirroring_to_byte(self.mirroring));
271        out.extend_from_slice(&self.vram);
272        if self.chr_is_ram {
273            out.extend_from_slice(&self.chr);
274        }
275        out
276    }
277
278    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
279        let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
280        let expected = 1 + TxcChip::SAVE_LEN + 1 + self.vram.len() + chr_ram;
281        if data.len() != expected {
282            return Err(MapperError::WrongLength {
283                expected,
284                got: data.len(),
285            });
286        }
287        if data[0] != SAVE_STATE_VERSION {
288            return Err(MapperError::UnsupportedVersion(data[0]));
289        }
290        let mut c = 1;
291        self.txc.load(&data[c..c + TxcChip::SAVE_LEN]);
292        c += TxcChip::SAVE_LEN;
293        self.mirroring = byte_to_mirroring(data[c], self.mirroring);
294        c += 1;
295        self.vram.copy_from_slice(&data[c..c + self.vram.len()]);
296        c += self.vram.len();
297        if self.chr_is_ram {
298            self.chr.copy_from_slice(&data[c..c + self.chr.len()]);
299        }
300        Ok(())
301    }
302}
303
304/// Mapper 136 (Sachen 3011, TXC protection CHR select).
305///
306/// # Errors
307/// [`MapperError::Invalid`] on a bad PRG size.
308pub fn new_m136(
309    prg_rom: Box<[u8]>,
310    chr_rom: Box<[u8]>,
311    mirroring: Mirroring,
312) -> Result<Sachen3011, MapperError> {
313    Sachen3011::new(prg_rom, chr_rom, mirroring)
314}
315
316#[cfg(test)]
317#[allow(clippy::cast_possible_truncation)]
318mod tests {
319    use super::*;
320
321    fn synth_prg_8k(banks: usize) -> Box<[u8]> {
322        let mut v = vec![0xFFu8; banks * PRG_BANK_8K];
323        for b in 0..banks {
324            v[b * PRG_BANK_8K] = b as u8;
325        }
326        v.into_boxed_slice()
327    }
328
329    fn synth_chr_8k(banks: usize) -> Box<[u8]> {
330        let mut v = vec![0u8; banks * CHR_BANK_8K];
331        for b in 0..banks {
332            v[b * CHR_BANK_8K] = b as u8;
333        }
334        v.into_boxed_slice()
335    }
336
337    #[test]
338    fn sachen3011_txc_chr_select() {
339        let mut m = new_m136(synth_prg_8k(4), synth_chr_8k(8), Mirroring::Vertical).unwrap();
340        // Stage a value into the accumulator, then latch it via $8000 to output.
341        m.cpu_write(0x4102, 0x03); // staging = 3
342        m.cpu_write(0x4103, 0x00); // increase = false
343        m.cpu_write(0x4100, 0x00); // accumulator = staging
344        m.cpu_write(0x8000, 0x00); // refresh output
345        // output low nibble = accumulator low nibble (3) -> CHR bank 3.
346        assert_eq!(m.ppu_read(0x0000), 3);
347    }
348
349    #[test]
350    fn sachen3011_save_state_round_trip() {
351        let mut m = new_m136(synth_prg_8k(4), synth_chr_8k(8), Mirroring::Vertical).unwrap();
352        m.cpu_write(0x4102, 0x02);
353        m.cpu_write(0x4100, 0x00);
354        m.cpu_write(0x8000, 0x00);
355        m.ppu_write(0x2003, 0x11);
356        let blob = m.save_state();
357        let mut m2 = new_m136(synth_prg_8k(4), synth_chr_8k(8), Mirroring::Vertical).unwrap();
358        m2.load_state(&blob).unwrap();
359        assert_eq!(m2.ppu_read(0x0000), m.ppu_read(0x0000));
360        assert_eq!(m2.ppu_read(0x2003), 0x11);
361    }
362}