Skip to main content

rustynes_mappers/
unif.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2//
3// Provenance: the UNIF board-name tables are derived from Mesen2 (`UnifLoader.cpp`, GPL-3.0-or-later) and FCEUX (`unif.cpp`, GPL-2.0-or-later). See docs/originality-and-provenance.md (Section 1)
4// and NOTICE for the complete, audited derivation record.
5//! UNIF (`.unf` / `.unif`) cartridge-container parser (v1.6.0 Workstream E2).
6//!
7//! UNIF is a chunked container that, unlike iNES, carries **no mapper number** —
8//! it identifies the cartridge by a **board-name string** in its `MAPR` chunk.
9//! This module parses the container (header + length-prefixed chunks), assembles
10//! the PRG/CHR banks, and resolves the board name to an iNES mapper id via
11//! [`board_to_mapper`] so the result can be routed through the existing mapper
12//! dispatch. It unlocks the pirate / multicart / homebrew dumps that exist only
13//! as UNIF (no iNES equivalent).
14//!
15//! # Container layout
16//!
17//! ```text
18//! HEADER (32 bytes):
19//!     magic    : "UNIF"     (4 bytes)
20//!     revision : u32 LE      (4 bytes)
21//!     reserved : 24 bytes    (zero)
22//! CHUNKS (repeat to EOF):
23//!     id     : 4 ASCII bytes  (e.g. "MAPR", "PRG0", "CHR0", "MIRR", "BATR")
24//!     length : u32 LE
25//!     data   : `length` bytes
26//! ```
27//!
28//! Chunk IDs are case-sensitive 4-byte tags. The ones this loader consumes:
29//! `MAPR` (board name, NUL-terminated ASCII), `PRG0`..`PRGF` / `CHR0`..`CHRF`
30//! (ROM banks, concatenated in ascending index order), `MIRR` (1-byte mirroring
31//! mode), `BATR` (presence ⇒ battery-backed), `TVCI` (1-byte TV system). Other
32//! chunks (`NAME`, `DINF`, `PCK?`, `CCK?`, `READ`, …) are skipped. This is the
33//! pure container parse; building a [`crate::Cartridge`] + the mapper from the
34//! resolved fields is the loader's job.
35//!
36//! `no_std` + `alloc` only (the chip stack constraint). Sources for the board
37//! table: Mesen2 + puNES `src/core/unif.c`, cross-checked against
38//! `docs/mappers.md` (see `scripts/coverage/UNIF_BOARD_MAP.md`).
39
40use alloc::{string::String, vec::Vec};
41
42use crate::cartridge::{Mirroring, Region};
43use thiserror::Error;
44
45/// Magic prefix of every UNIF container — the first 4 bytes of a `.unf` file.
46pub const UNIF_MAGIC: &[u8; 4] = b"UNIF";
47
48/// Fixed UNIF header length: magic(4) + revision(4) + reserved(24).
49const UNIF_HEADER_LEN: usize = 32;
50
51/// Errors from parsing a UNIF container.
52#[derive(Debug, Error, PartialEq, Eq)]
53#[non_exhaustive]
54pub enum UnifError {
55    /// Shorter than the 32-byte fixed header.
56    #[error("UNIF truncated: header needs {UNIF_HEADER_LEN} bytes, got {0}")]
57    HeaderTruncated(usize),
58    /// The magic prefix is not `"UNIF"`.
59    #[error("UNIF magic mismatch: expected \"UNIF\", got {0:?}")]
60    BadMagic([u8; 4]),
61    /// A chunk's declared length runs past the end of the file.
62    #[error("UNIF chunk {id} at offset {offset} declares {len} bytes past EOF")]
63    ChunkOverrun {
64        /// 4-char chunk id.
65        id: String,
66        /// Byte offset of the chunk header.
67        offset: usize,
68        /// Declared payload length.
69        len: usize,
70    },
71    /// No `MAPR` board-name chunk was present.
72    #[error("UNIF has no MAPR board-name chunk")]
73    NoMapr,
74    /// The `MAPR` board name does not resolve to a known iNES mapper.
75    #[error("UNIF board name {0:?} is not a known/implemented board")]
76    UnknownBoard(String),
77}
78
79/// A parsed + resolved UNIF image, ready to build a [`crate::Cartridge`].
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct UnifImage {
82    /// The raw `MAPR` board name (NUL trimmed), e.g. `"NES-NROM"`.
83    pub board: String,
84    /// iNES mapper id resolved from [`Self::board`] via [`board_to_mapper`].
85    pub mapper_id: u16,
86    /// PRG-ROM: `PRG0`..`PRGF` chunks concatenated in ascending index order.
87    pub prg_rom: Vec<u8>,
88    /// CHR-ROM: `CHR0`..`CHRF` concatenated (empty ⇒ the board uses CHR-RAM).
89    pub chr_rom: Vec<u8>,
90    /// Initial mirroring from the `MIRR` chunk (default horizontal).
91    pub mirroring: Mirroring,
92    /// `true` if a `BATR` chunk was present (battery-backed save RAM).
93    pub has_battery: bool,
94    /// Region from the `TVCI` chunk (NTSC default; "both" maps to NTSC).
95    pub region: Region,
96}
97
98/// Resolve a UNIF `MAPR` board name to its iNES mapper id, or `None` if the
99/// board is unknown / not implemented by `RustyNES`.
100///
101/// Matching is case-insensitive and tolerant of the standard UNIF vendor
102/// prefixes (`NES-`, `HVC-`, `UNL-`, `BMC-`, `BTL-`) — `"NES-NROM"` and
103/// `"NROM"` both resolve to mapper 0. The table is the `RustyNES`-implemented
104/// subset (porting an unimplemented board would only fail later in dispatch);
105/// it is not the full UNIF board universe.
106#[must_use]
107pub fn board_to_mapper(board: &str) -> Option<u16> {
108    // Strip the NUL terminator(s) first, THEN trim — otherwise trailing
109    // whitespace hidden behind a `\0` (e.g. "NES-NROM \0") would survive.
110    let upper = board.trim_end_matches('\0').trim().to_ascii_uppercase();
111    // Try the name as-is, then with a leading vendor prefix stripped.
112    if let Some(m) = lookup_board(&upper) {
113        return Some(m);
114    }
115    for prefix in ["NES-", "HVC-", "UNL-", "BMC-", "BTL-"] {
116        if let Some(rest) = upper.strip_prefix(prefix)
117            && let Some(m) = lookup_board(rest)
118        {
119            return Some(m);
120        }
121    }
122    None
123}
124
125/// Exact (already-uppercased) board-name lookup. The board-name -> mapper-number
126/// mapping is largely factual UNIF board-naming data (from `docs/mappers.md` and
127/// the nesdev UNIF board list), but this table was derived from Mesen2's
128/// `UnifLoader.cpp` (GPL-3.0-or-later) and FCEUX's `unif.cpp` (GPL-2.0-or-later).
129/// See NOTICE and docs/originality-and-provenance.md (Section 1).
130// Arms are grouped by vendor (Nintendo / Konami / Bandai / Sachen / ...) for
131// provenance and readability; some distinct board families intentionally share
132// a mapper id (e.g. several boards resolve to MMC3 = 4), so identical-body arms
133// are deliberately kept separate rather than merged.
134// The board table is one large flat match by design (a name→number lookup);
135// the v1.8.9 breadth pass pushed it past the default line cap, but splitting it
136// would only obscure the per-vendor grouping.
137#[allow(clippy::match_same_arms, clippy::too_many_lines)]
138fn lookup_board(b: &str) -> Option<u16> {
139    Some(match b {
140        // Nintendo discrete / first-party
141        "NROM" | "NROM-128" | "NROM-256" | "RROM" | "RROM-128" => 0,
142        "SLROM" | "SKROM" | "SAROM" | "SBROM" | "SCROM" | "SEROM" | "SFROM" | "SGROM" | "SHROM"
143        | "SJROM" | "SKROM-MMC1B2" | "SLROM-MMC1B2" | "SNROM" | "SOROM" | "SUROM" | "SXROM"
144        | "SL1ROM" => 1,
145        "UNROM" | "UOROM" => 2,
146        "UNROM-512-8" | "UNROM-512-16" | "UNROM-512-32" => 30,
147        "CNROM" => 3,
148        "CPROM" => 13,
149        "TLROM" | "TSROM" | "TKROM" | "TKSROM" | "TBROM" | "TFROM" | "TGROM" | "TNROM"
150        | "TVROM" | "TEROM" | "B4" | "HKROM" => 4,
151        "TLSROM" => 118,
152        "TQROM" => 119,
153        "TR1ROM" => 64,
154        "DRROM" => 206,
155        "EKROM" | "ELROM" | "ETROM" | "EWROM" => 5,
156        "AMROM" | "ANROM" | "AN1ROM" | "AOROM" => 7,
157        "PNROM" | "PEEOROM" => 9,
158        "FJROM" | "FKROM" => 10,
159        "GNROM" | "MHROM" => 66,
160        "BNROM" | "NINA-001" | "NINA-002" => 34,
161        "NINA-03" | "NINA-06" => 79,
162        "NINA-07" => 11,
163        // Konami VRC
164        "KONAMI-VRC-1" => 75,
165        "KONAMI-VRC-2" => 23,
166        "KONAMI-VRC-3" => 73,
167        "KONAMI-VRC-4" => 21,
168        "KONAMI-VRC-6" => 24,
169        "KONAMI-VRC-7" | "VRC7" => 85,
170        // Nanjing / Waixing / pirate-ish
171        "MMC3" => 4,
172        "MAPPER245" => 245,
173        // Color Dreams / Wisdom Tree
174        "COLORDREAMS" | "CDREAM" => 11,
175        // Bandai
176        "BANDAI-74*161/161/32" => 152,
177        "BANDAI-FCG" | "BANDAI-LZ93D50" | "BANDAI-LZ93D50+24C02" => 16,
178        "BANDAI-LZ93D50+24C01" => 159,
179        // Sunsoft
180        "SUNSOFT_UNROM" => 93,
181        "SUNSOFT-1" => 184,
182        "SUNSOFT-2" => 89,
183        "SUNSOFT-3" => 67,
184        "SUNSOFT-4" | "NTBROM" => 68,
185        "SUNSOFT-5B" | "SUNSOFT-FME-7" => 69,
186        "JF-16" => 78,
187        // Irem
188        "IREM-G101" => 32,
189        "IREM-H3001" => 65,
190        "IREM-74*161/161/21/138" => 77,
191        "IREM-HOLYDIVER" => 78,
192        "HVC-UN1ROM" => 94,
193        // Jaleco
194        "JALECO-JF-11" | "JALECO-JF-14" => 140,
195        "JALECO-JF-13" => 86,
196        "JALECO-JF-16" => 78,
197        "JALECO-JF-17" => 72,
198        "JALECO-JF-19" => 92,
199        "JALECO-SS88006" => 18,
200        // Namco
201        "NAMCOT-3433" | "NAMCOT-3443" | "NAMCOT-3453" => 88,
202        "NAMCOT-3446" => 76,
203        "NAMCOT-163" => 19,
204        "NAMCOT-175" | "NAMCOT-340" => 210,
205        // Taito
206        "TAITO-TC0190FMC" | "TAITO-TC0190FMR" => 33,
207        "TC0190FMC+PAL16R4" => 48,
208        "TAITO-X1-005" => 80,
209        "TAITO-X1-017" => 82,
210        // Camerica / Codemasters
211        "CAMERICA-BF9093" | "CAMERICA-ALGN" | "BF9097" => 71,
212        "CAMERICA-BF9096" | "CAMERICA-ALGQ" => 232,
213        // AVE
214        "AVE-NINA-01" | "AVE-NINA-02" => 34,
215        "AVE-NINA-03" | "AVE-NINA-06" => 79,
216        // Sachen — board-suffix-disambiguated 8259 family (verified vs puNES/Mesen2)
217        "SACHEN-8259A" => 141,
218        "SACHEN-8259B" => 138,
219        "SACHEN-8259C" => 139,
220        "SACHEN-8259D" => 137,
221        "SACHEN-74LS374N" => 150,
222        "SA-016-1M" => 79,
223        "SA-72007" => 145,
224        "SA-72008" => 133,
225        "SA-NROM" => 143,
226        "SA-0036" => 149,
227        "SA-0037" => 148,
228        "TCA01" => 143,
229        "TCU01" | "TC-U01-1.5M" => 147,
230        // Misc multicarts / homebrew
231        "GTROM" | "CHEAPOCABRA" => 111,
232        "ACTION52" => 228,
233        "CALTRON6IN1" => 41,
234        "MAGICFLOOR" => 218,
235        "RET-CUFROM" => 29,
236        // --- v1.8.9 "Backlog" beta.6 UNIF board-map breadth: well-known board
237        // names mapping to families RustyNES already implements. Derived from
238        // Mesen2's `UnifLoader.cpp` (GPL-3.0-or-later) + FCEUX's `unif.cpp`
239        // (GPL-2.0-or-later); see NOTICE + docs/originality-and-provenance.md §1.
240        // NTDEC / TXC / discrete BMC families.
241        "11160" => 299,
242        "N625092" => 221,
243        "22211" => 132,
244        "43272" | "WAIXING-FW01" => 227,
245        "603-5052" => 238,
246        "8157" => 301,
247        "GK-192" => 58,
248        "SC-127" => 35,
249        "TEK90" => 90,
250        "FS304" => 162,
251        "NTD-03" => 290,
252        "42IN1RESETSWITCH" => 226,
253        "NOVELDIAMOND9999999IN1" => 201,
254        // Sachen (TXC protection / 9602 ASIC).
255        "SA-002" => 136,
256        "SA-9602B" => 513,
257        // FK23C / COOLBOY / MINDKIDS reusable-ASIC BMC.
258        "FK23C" | "FK23CA" | "SUPER24IN1SC03" => 176,
259        "COOLBOY" | "MINDKIDS" => 268,
260        // Kaiser FDS-conversion ASIC family.
261        "KS7032" => 142,
262        "KS7017" => 303,
263        "KS7031" => 305,
264        "KS7016" => 306,
265        "KS7013B" => 312,
266        // Unlicensed discrete BMC multicarts.
267        "60311C" => 289,
268        "810544-C-A1" => 261,
269        "830425C-4391T" => 320,
270        "830118C" => 348,
271        "K-3046" => 336,
272        "G-146" => 349,
273        "BS-5" => 286,
274        _ => return None,
275    })
276}
277
278/// Parse a UNIF container into a resolved [`UnifImage`].
279///
280/// # Errors
281///
282/// Returns [`UnifError`] for a bad magic, a truncated header, a chunk whose
283/// declared length overruns the file, a missing `MAPR` chunk, or a board name
284/// that does not resolve to a known/implemented mapper. Never panics on
285/// malformed input.
286pub fn parse_unif(bytes: &[u8]) -> Result<UnifImage, UnifError> {
287    if bytes.len() < UNIF_HEADER_LEN {
288        return Err(UnifError::HeaderTruncated(bytes.len()));
289    }
290    let mut magic = [0u8; 4];
291    magic.copy_from_slice(&bytes[..4]);
292    if &magic != UNIF_MAGIC {
293        return Err(UnifError::BadMagic(magic));
294    }
295
296    let mut board: Option<String> = None;
297    // PRG/CHR banks indexed 0..=15 ('0'..'9','A'..'F'), assembled in order.
298    let mut prg_banks: [Option<Vec<u8>>; 16] = Default::default();
299    let mut chr_banks: [Option<Vec<u8>>; 16] = Default::default();
300    let mut mirroring = Mirroring::Horizontal;
301    let mut has_battery = false;
302    let mut region = Region::Ntsc;
303
304    let mut off = UNIF_HEADER_LEN;
305    while off + 8 <= bytes.len() {
306        let id = &bytes[off..off + 4];
307        let len = u32::from_le_bytes([
308            bytes[off + 4],
309            bytes[off + 5],
310            bytes[off + 6],
311            bytes[off + 7],
312        ]) as usize;
313        let data_start = off + 8;
314        let data_end = data_start.checked_add(len).filter(|&e| e <= bytes.len());
315        let Some(data_end) = data_end else {
316            return Err(UnifError::ChunkOverrun {
317                id: String::from_utf8_lossy(id).into_owned(),
318                offset: off,
319                len,
320            });
321        };
322        let data = &bytes[data_start..data_end];
323
324        match id {
325            b"MAPR" => {
326                // NUL-terminated ASCII board name.
327                let end = data.iter().position(|&b| b == 0).unwrap_or(data.len());
328                board = Some(String::from_utf8_lossy(&data[..end]).into_owned());
329            }
330            b"MIRR" => {
331                // 0=H, 1=V, 2=mirror-all (treat as single-screen → H), 3=four
332                // screen, 4=four-screen variant. Map conservatively.
333                mirroring = match data.first().copied().unwrap_or(0) {
334                    1 => Mirroring::Vertical,
335                    3 | 4 => Mirroring::FourScreen,
336                    _ => Mirroring::Horizontal,
337                };
338            }
339            b"BATR" => has_battery = true,
340            b"TVCI" => {
341                region = match data.first().copied().unwrap_or(0) {
342                    1 => Region::Pal,
343                    _ => Region::Ntsc, // 0 = NTSC, 2 = "both" → NTSC
344                };
345            }
346            _ => {
347                if id[..3] == *b"PRG"
348                    && let Some(slot) = hex_nibble(id[3])
349                {
350                    prg_banks[slot as usize] = Some(data.to_vec());
351                } else if id[..3] == *b"CHR"
352                    && let Some(slot) = hex_nibble(id[3])
353                {
354                    chr_banks[slot as usize] = Some(data.to_vec());
355                }
356                // Anything else (NAME/DINF/READ/PCK?/CCK?/…) is ignored.
357            }
358        }
359        off = data_end;
360    }
361
362    let board = board.ok_or(UnifError::NoMapr)?;
363    let mapper_id =
364        board_to_mapper(&board).ok_or_else(|| UnifError::UnknownBoard(board.clone()))?;
365
366    let mut prg_rom = Vec::new();
367    for bank in prg_banks.into_iter().flatten() {
368        prg_rom.extend_from_slice(&bank);
369    }
370    let mut chr_rom = Vec::new();
371    for bank in chr_banks.into_iter().flatten() {
372        chr_rom.extend_from_slice(&bank);
373    }
374
375    Ok(UnifImage {
376        board,
377        mapper_id,
378        prg_rom,
379        chr_rom,
380        mirroring,
381        has_battery,
382        region,
383    })
384}
385
386/// Synthesize an equivalent **NES 2.0** image from a parsed UNIF, so the
387/// standard [`crate::parse`] path builds the [`crate::Cartridge`] + mapper with
388/// zero duplicated mapper construction.
389///
390/// PRG is zero-padded to a 16 KiB multiple and CHR to 8 KiB; empty CHR encodes
391/// a CHR-RAM board (8 KiB CHR-RAM via NES 2.0 byte 11). NES 2.0 (not iNES 1.0)
392/// is used deliberately: it preserves the region (byte 12) the `TVCI` chunk
393/// gave us, and its byte-9 size MSB nibbles represent the large multicart PRG
394/// banks (> 255 × 16 KiB) that iNES 1.0 cannot. Every board in the table maps
395/// to a mapper id ≤ 255, well within range.
396// Every `as u8` below extracts a masked header byte-field (`& 0xFF` / `& 0x0F`),
397// so the truncation is the intended field slice, not a lossy cast.
398#[allow(clippy::cast_possible_truncation)]
399#[must_use]
400pub fn unif_to_ines(img: &UnifImage) -> Vec<u8> {
401    const PRG_UNIT: usize = 16 * 1024;
402    const CHR_UNIT: usize = 8 * 1024;
403
404    let mut prg = img.prg_rom.clone();
405    let prg_rem = prg.len() % PRG_UNIT;
406    if prg_rem != 0 {
407        prg.resize(prg.len() + (PRG_UNIT - prg_rem), 0);
408    }
409    let mut chr = img.chr_rom.clone();
410    let chr_rem = chr.len() % CHR_UNIT;
411    if chr_rem != 0 {
412        chr.resize(chr.len() + (CHR_UNIT - chr_rem), 0);
413    }
414    let prg_banks = prg.len() / PRG_UNIT;
415    let chr_banks = chr.len() / CHR_UNIT;
416    let mapper = img.mapper_id;
417
418    let mirror_bits: u8 = match img.mirroring {
419        Mirroring::Vertical => 0x01,
420        Mirroring::FourScreen => 0x08,
421        _ => 0x00, // Horizontal / single-screen
422    };
423
424    let mut h = [0u8; 16];
425    h[0..4].copy_from_slice(b"NES\x1A");
426    h[4] = (prg_banks & 0xFF) as u8; // PRG size LSB (16 KiB units)
427    h[5] = (chr_banks & 0xFF) as u8; // CHR size LSB (8 KiB units)
428    h[6] = (((mapper & 0x0F) as u8) << 4) | mirror_bits | (u8::from(img.has_battery) << 1);
429    // byte 7: mapper bits 4-7 in the high nibble; bits 2-3 = 0b10 (NES 2.0
430    // marker); console type = 0 (NES).
431    h[7] = ((((mapper >> 4) & 0x0F) as u8) << 4) | 0x08;
432    h[8] = ((mapper >> 8) & 0x0F) as u8; // mapper bits 8-11 (submapper nibble = 0)
433    h[9] = (((prg_banks >> 8) & 0x0F) as u8) | ((((chr_banks >> 8) & 0x0F) as u8) << 4);
434    // byte 10: PRG-RAM (low nibble) / PRG-NVRAM (high nibble) size shift
435    // (size = 64 << shift). NES 2.0 byte 10 is authoritative — the iNES-1.0
436    // "default 8 KiB for MMC1/3/5" heuristic does NOT apply to a NES-2.0 image —
437    // so declare 8 KiB save/work RAM (64 << 7) unconditionally, as NVRAM when
438    // the board is battery-backed and as volatile PRG-RAM otherwise. Mappers
439    // that need none simply leave it unused; mappers that need it (MMC1/3/5
440    // save data + work RAM) would otherwise get zero and fail to save / boot.
441    h[10] = if img.has_battery { 0x70 } else { 0x07 };
442    // byte 11: CHR-RAM size shift (size = 64 << shift). 8 KiB = 64 << 7 when the
443    // board ships no CHR-ROM; 0 (no CHR-RAM) when it does.
444    h[11] = if chr.is_empty() { 0x07 } else { 0x00 };
445    h[12] = match img.region {
446        Region::Pal => 1,
447        Region::Multi => 2,
448        Region::Dendy => 3,
449        Region::Ntsc => 0,
450    };
451
452    let mut out = Vec::with_capacity(16 + prg.len() + chr.len());
453    out.extend_from_slice(&h);
454    out.extend_from_slice(&prg);
455    out.extend_from_slice(&chr);
456    out
457}
458
459/// Map the trailing byte of a `PRG?`/`CHR?` chunk id (`'0'..='9'`, `'A'..='F'`,
460/// `'a'..='f'`) to its bank slot 0..=15. `None` for any other byte.
461const fn hex_nibble(b: u8) -> Option<u8> {
462    match b {
463        b'0'..=b'9' => Some(b - b'0'),
464        b'A'..=b'F' => Some(b - b'A' + 10),
465        b'a'..=b'f' => Some(b - b'a' + 10),
466        _ => None,
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use alloc::vec;
474
475    /// Build a synthetic UNIF blob: header + the given chunks (id, data).
476    fn build_unif(chunks: &[(&[u8; 4], Vec<u8>)]) -> Vec<u8> {
477        let mut v = Vec::new();
478        v.extend_from_slice(UNIF_MAGIC);
479        v.extend_from_slice(&7u32.to_le_bytes()); // revision
480        v.extend_from_slice(&[0u8; 24]); // reserved
481        for (id, data) in chunks {
482            v.extend_from_slice(*id);
483            v.extend_from_slice(&u32::try_from(data.len()).unwrap().to_le_bytes());
484            v.extend_from_slice(data);
485        }
486        v
487    }
488
489    #[test]
490    fn board_resolution_bare_and_prefixed_and_unknown() {
491        assert_eq!(board_to_mapper("NROM"), Some(0));
492        assert_eq!(board_to_mapper("NES-NROM"), Some(0));
493        assert_eq!(board_to_mapper("HVC-SLROM"), Some(1));
494        assert_eq!(board_to_mapper("UNL-SACHEN-8259A"), Some(141));
495        assert_eq!(board_to_mapper("sachen-8259d"), Some(137)); // case-insensitive
496        assert_eq!(board_to_mapper("KONAMI-VRC-2"), Some(23));
497        assert_eq!(board_to_mapper("BANDAI-LZ93D50+24C01"), Some(159));
498        assert_eq!(board_to_mapper("DEFINITELY-NOT-A-BOARD"), None);
499    }
500
501    #[test]
502    fn v1_8_9_added_boards_resolve_to_implemented_mappers() {
503        // Every board added in the v1.8.9 "Backlog" beta.6 breadth pass must
504        // resolve to a family RustyNES implements. Bare + UNL-/BMC-prefixed.
505        let cases: &[(&str, u16)] = &[
506            // NTDEC / TXC / discrete BMC.
507            ("11160", 299),
508            ("N625092", 221),
509            ("22211", 132),
510            ("43272", 227),
511            ("WAIXING-FW01", 227),
512            ("603-5052", 238),
513            ("8157", 301),
514            ("GK-192", 58),
515            ("SC-127", 35),
516            ("TEK90", 90),
517            ("FS304", 162),
518            ("NTD-03", 290),
519            ("42IN1RESETSWITCH", 226),
520            ("NOVELDIAMOND9999999IN1", 201),
521            // Sachen.
522            ("SA-002", 136),
523            ("SA-9602B", 513),
524            // FK23C / COOLBOY / MINDKIDS.
525            ("FK23C", 176),
526            ("FK23CA", 176),
527            ("SUPER24IN1SC03", 176),
528            ("COOLBOY", 268),
529            ("MINDKIDS", 268),
530            // Kaiser.
531            ("KS7032", 142),
532            ("KS7017", 303),
533            ("KS7031", 305),
534            ("KS7016", 306),
535            ("KS7013B", 312),
536            // Unlicensed discrete BMC.
537            ("60311C", 289),
538            ("810544-C-A1", 261),
539            ("830425C-4391T", 320),
540            ("830118C", 348),
541            ("K-3046", 336),
542            ("G-146", 349),
543            ("BS-5", 286),
544            // Nintendo discrete aliases newly recognized.
545            ("SL1ROM", 1),
546            ("TEROM", 4),
547            ("NTBROM", 68),
548        ];
549        for &(board, mapper) in cases {
550            assert_eq!(
551                board_to_mapper(board),
552                Some(mapper),
553                "board {board:?} should resolve to mapper {mapper}"
554            );
555            // The standard UNL- vendor prefix must resolve identically.
556            let prefixed = alloc::format!("UNL-{board}");
557            assert_eq!(
558                board_to_mapper(&prefixed),
559                Some(mapper),
560                "prefixed board {prefixed:?} should resolve to mapper {mapper}"
561            );
562        }
563    }
564
565    #[test]
566    fn sachen_8259_variants_resolve_distinctly() {
567        // The one place a naive "8259 -> one mapper" guess is wrong.
568        assert_eq!(board_to_mapper("SACHEN-8259A"), Some(141));
569        assert_eq!(board_to_mapper("SACHEN-8259B"), Some(138));
570        assert_eq!(board_to_mapper("SACHEN-8259C"), Some(139));
571        assert_eq!(board_to_mapper("SACHEN-8259D"), Some(137));
572    }
573
574    #[test]
575    fn parse_minimal_nrom_unif() {
576        let prg = vec![0xEAu8; 16 * 1024];
577        let chr = vec![0x55u8; 8 * 1024];
578        let blob = build_unif(&[
579            (b"MAPR", b"NES-NROM\0".to_vec()),
580            (b"PRG0", prg.clone()),
581            (b"CHR0", chr.clone()),
582            (b"MIRR", vec![1]), // vertical
583            (b"BATR", vec![]),
584        ]);
585        let img = parse_unif(&blob).expect("parse");
586        assert_eq!(img.board, "NES-NROM");
587        assert_eq!(img.mapper_id, 0);
588        assert_eq!(img.prg_rom, prg);
589        assert_eq!(img.chr_rom, chr);
590        assert_eq!(img.mirroring, Mirroring::Vertical);
591        assert!(img.has_battery);
592        assert_eq!(img.region, Region::Ntsc);
593    }
594
595    #[test]
596    fn multiple_prg_chr_banks_concatenate_in_index_order() {
597        let blob = build_unif(&[
598            (b"MAPR", b"UNROM\0".to_vec()),
599            (b"PRG1", vec![0x11; 16 * 1024]), // out of order on purpose
600            (b"PRG0", vec![0x00; 16 * 1024]),
601            (b"CHR0", vec![]),
602        ]);
603        let img = parse_unif(&blob).expect("parse");
604        assert_eq!(img.mapper_id, 2);
605        assert_eq!(img.prg_rom.len(), 32 * 1024);
606        // PRG0 (0x00) must come before PRG1 (0x11) regardless of file order.
607        assert_eq!(img.prg_rom[0], 0x00);
608        assert_eq!(img.prg_rom[16 * 1024], 0x11);
609        assert!(img.chr_rom.is_empty(), "no CHR banks => CHR-RAM board");
610    }
611
612    #[test]
613    fn rejects_bad_magic_and_truncation_without_panicking() {
614        assert!(matches!(
615            parse_unif(&[0u8; 10]),
616            Err(UnifError::HeaderTruncated(10))
617        ));
618        let mut blob = build_unif(&[(b"MAPR", b"NROM\0".to_vec())]);
619        blob[..4].copy_from_slice(b"NESM");
620        assert!(matches!(parse_unif(&blob), Err(UnifError::BadMagic(_))));
621    }
622
623    #[test]
624    fn rejects_missing_mapr_and_unknown_board() {
625        let no_mapr = build_unif(&[(b"PRG0", vec![0; 16 * 1024])]);
626        assert_eq!(parse_unif(&no_mapr), Err(UnifError::NoMapr));
627        let unknown = build_unif(&[(b"MAPR", b"WHO-KNOWS\0".to_vec())]);
628        assert!(matches!(
629            parse_unif(&unknown),
630            Err(UnifError::UnknownBoard(_))
631        ));
632    }
633
634    #[test]
635    fn rejects_chunk_length_overrun() {
636        let mut blob = build_unif(&[(b"MAPR", b"NROM\0".to_vec())]);
637        // Append a PRG0 header claiming a huge length with no data.
638        blob.extend_from_slice(b"PRG0");
639        blob.extend_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
640        assert!(matches!(
641            parse_unif(&blob),
642            Err(UnifError::ChunkOverrun { .. })
643        ));
644    }
645
646    #[test]
647    fn unif_parses_through_the_cartridge_path() {
648        // A UNIF blob must load via the top-level `parse()` (UNIF-magic
649        // dispatch -> synthesize NES 2.0 -> standard parse) and yield the right
650        // Cartridge + a constructed mapper.
651        let prg = vec![0xEAu8; 16 * 1024];
652        let chr = vec![0x55u8; 8 * 1024];
653        let blob = build_unif(&[
654            (b"MAPR", b"NES-NROM\0".to_vec()),
655            (b"PRG0", prg.clone()),
656            (b"CHR0", chr.clone()),
657            (b"MIRR", vec![1]), // vertical
658            (b"BATR", vec![]),
659            (b"TVCI", vec![1]), // PAL
660        ]);
661        let (cart, _mapper) = crate::parse(&blob).expect("UNIF loads via the cartridge path");
662        assert_eq!(cart.mapper_id, 0);
663        assert_eq!(&*cart.prg_rom, &prg[..]);
664        assert_eq!(&*cart.chr_rom, &chr[..]);
665        assert_eq!(cart.mirroring, Mirroring::Vertical);
666        assert!(cart.has_battery);
667        assert!(cart.is_nes2, "the synthesized image is NES 2.0");
668        assert_eq!(
669            cart.region,
670            Region::Pal,
671            "TVCI region survives the synthesis"
672        );
673    }
674
675    #[test]
676    fn unif_nrom_matches_the_equivalent_ines() {
677        // The Cartridge a UNIF NROM produces must equal the one the equivalent
678        // hand-built iNES NROM produces (same PRG/CHR/mapper/mirroring).
679        let prg = vec![0x42u8; 16 * 1024];
680        let chr = vec![0x99u8; 8 * 1024];
681        let unif = build_unif(&[
682            (b"MAPR", b"NROM\0".to_vec()),
683            (b"PRG0", prg.clone()),
684            (b"CHR0", chr.clone()),
685        ]);
686        let (uc, _) = crate::parse(&unif).expect("unif");
687        // Equivalent iNES 1.0 NROM: 1x16 KiB PRG, 1x8 KiB CHR, mapper 0, horiz.
688        let mut ines = vec![b'N', b'E', b'S', 0x1A, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
689        ines.extend_from_slice(&prg);
690        ines.extend_from_slice(&chr);
691        let (ic, _) = crate::parse(&ines).expect("ines");
692        assert_eq!(uc.mapper_id, ic.mapper_id);
693        assert_eq!(uc.prg_rom, ic.prg_rom);
694        assert_eq!(uc.chr_rom, ic.chr_rom);
695        assert_eq!(uc.mirroring, ic.mirroring);
696    }
697
698    #[test]
699    fn unif_chr_ram_board_synthesizes_chr_ram() {
700        // No CHR chunk => CHR-RAM board: empty CHR-ROM, non-zero CHR-RAM.
701        let blob = build_unif(&[
702            (b"MAPR", b"UNROM\0".to_vec()),
703            (b"PRG0", vec![0u8; 16 * 1024]),
704        ]);
705        let (cart, _) = crate::parse(&blob).expect("unif");
706        assert_eq!(cart.mapper_id, 2);
707        assert!(cart.uses_chr_ram(), "no CHR chunk => CHR-RAM board");
708        assert!(cart.chr_ram_size >= 8 * 1024, "8 KiB CHR-RAM synthesized");
709    }
710
711    #[test]
712    fn unif_save_ram_board_synthesizes_prg_ram() {
713        // An MMC1 SNROM board with a battery must get PRG-(N)RAM — the NES 2.0
714        // byte-10 fix (a save-data board would otherwise get zero PRG-RAM).
715        let blob = build_unif(&[
716            (b"MAPR", b"SNROM\0".to_vec()),
717            (b"PRG0", vec![0u8; 16 * 1024]),
718            (b"BATR", vec![]),
719        ]);
720        let (cart, _) = crate::parse(&blob).expect("unif");
721        assert_eq!(cart.mapper_id, 1, "SNROM => MMC1");
722        assert!(cart.has_battery);
723        assert!(
724            cart.prg_ram_size >= 8 * 1024,
725            "battery MMC1 board must get >= 8 KiB PRG-RAM, got {}",
726            cart.prg_ram_size
727        );
728    }
729}