1use alloc::{string::String, vec::Vec};
41
42use crate::cartridge::{Mirroring, Region};
43use thiserror::Error;
44
45pub const UNIF_MAGIC: &[u8; 4] = b"UNIF";
47
48const UNIF_HEADER_LEN: usize = 32;
50
51#[derive(Debug, Error, PartialEq, Eq)]
53#[non_exhaustive]
54pub enum UnifError {
55 #[error("UNIF truncated: header needs {UNIF_HEADER_LEN} bytes, got {0}")]
57 HeaderTruncated(usize),
58 #[error("UNIF magic mismatch: expected \"UNIF\", got {0:?}")]
60 BadMagic([u8; 4]),
61 #[error("UNIF chunk {id} at offset {offset} declares {len} bytes past EOF")]
63 ChunkOverrun {
64 id: String,
66 offset: usize,
68 len: usize,
70 },
71 #[error("UNIF has no MAPR board-name chunk")]
73 NoMapr,
74 #[error("UNIF board name {0:?} is not a known/implemented board")]
76 UnknownBoard(String),
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct UnifImage {
82 pub board: String,
84 pub mapper_id: u16,
86 pub prg_rom: Vec<u8>,
88 pub chr_rom: Vec<u8>,
90 pub mirroring: Mirroring,
92 pub has_battery: bool,
94 pub region: Region,
96}
97
98#[must_use]
107pub fn board_to_mapper(board: &str) -> Option<u16> {
108 let upper = board.trim_end_matches('\0').trim().to_ascii_uppercase();
111 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#[allow(clippy::match_same_arms, clippy::too_many_lines)]
138fn lookup_board(b: &str) -> Option<u16> {
139 Some(match b {
140 "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-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 "MMC3" => 4,
172 "MAPPER245" => 245,
173 "COLORDREAMS" | "CDREAM" => 11,
175 "BANDAI-74*161/161/32" => 152,
177 "BANDAI-FCG" | "BANDAI-LZ93D50" | "BANDAI-LZ93D50+24C02" => 16,
178 "BANDAI-LZ93D50+24C01" => 159,
179 "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-G101" => 32,
189 "IREM-H3001" => 65,
190 "IREM-74*161/161/21/138" => 77,
191 "IREM-HOLYDIVER" => 78,
192 "HVC-UN1ROM" => 94,
193 "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 "NAMCOT-3433" | "NAMCOT-3443" | "NAMCOT-3453" => 88,
202 "NAMCOT-3446" => 76,
203 "NAMCOT-163" => 19,
204 "NAMCOT-175" | "NAMCOT-340" => 210,
205 "TAITO-TC0190FMC" | "TAITO-TC0190FMR" => 33,
207 "TC0190FMC+PAL16R4" => 48,
208 "TAITO-X1-005" => 80,
209 "TAITO-X1-017" => 82,
210 "CAMERICA-BF9093" | "CAMERICA-ALGN" | "BF9097" => 71,
212 "CAMERICA-BF9096" | "CAMERICA-ALGQ" => 232,
213 "AVE-NINA-01" | "AVE-NINA-02" => 34,
215 "AVE-NINA-03" | "AVE-NINA-06" => 79,
216 "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 "GTROM" | "CHEAPOCABRA" => 111,
232 "ACTION52" => 228,
233 "CALTRON6IN1" => 41,
234 "MAGICFLOOR" => 218,
235 "RET-CUFROM" => 29,
236 "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 "SA-002" => 136,
256 "SA-9602B" => 513,
257 "FK23C" | "FK23CA" | "SUPER24IN1SC03" => 176,
259 "COOLBOY" | "MINDKIDS" => 268,
260 "KS7032" => 142,
262 "KS7017" => 303,
263 "KS7031" => 305,
264 "KS7016" => 306,
265 "KS7013B" => 312,
266 "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
278pub 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 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 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 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, };
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 }
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#[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, };
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; h[5] = (chr_banks & 0xFF) as u8; h[6] = (((mapper & 0x0F) as u8) << 4) | mirror_bits | (u8::from(img.has_battery) << 1);
429 h[7] = ((((mapper >> 4) & 0x0F) as u8) << 4) | 0x08;
432 h[8] = ((mapper >> 8) & 0x0F) as u8; h[9] = (((prg_banks >> 8) & 0x0F) as u8) | ((((chr_banks >> 8) & 0x0F) as u8) << 4);
434 h[10] = if img.has_battery { 0x70 } else { 0x07 };
442 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
459const 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 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()); v.extend_from_slice(&[0u8; 24]); 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)); 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 let cases: &[(&str, u16)] = &[
506 ("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 ("SA-002", 136),
523 ("SA-9602B", 513),
524 ("FK23C", 176),
526 ("FK23CA", 176),
527 ("SUPER24IN1SC03", 176),
528 ("COOLBOY", 268),
529 ("MINDKIDS", 268),
530 ("KS7032", 142),
532 ("KS7017", 303),
533 ("KS7031", 305),
534 ("KS7016", 306),
535 ("KS7013B", 312),
536 ("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 ("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 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 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]), (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]), (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 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 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 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]), (b"BATR", vec![]),
659 (b"TVCI", vec![1]), ]);
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 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 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 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 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}