romkit 0.1.0

Identify game ROMs and extract their data and assets as structured files.
Documentation
//! Game Boy and Game Boy Color cartridge headers.

use super::{GameCode, Identity, Platform, Region};
use crate::Result;
use crate::io::{Reader, ascii};

/// The first bytes of the Nintendo logo every cartridge carries at 0x104.
const LOGO: [u8; 4] = [0xCE, 0xED, 0x66, 0x66];
const HEADER_END: usize = 0x150;
/// Set in the CGB flag (0x143) by cartridges that use Game Boy Color features.
const CGB: u8 = 0x80;

/// Cartridges are read in 16 KB banks: bank 0 is always mapped at 0x0000, and one other bank
/// at a time is switched in at 0x4000.
pub const BANK_SIZE: u64 = 0x4000;

/// A location in ROM as the game's code names it: a bank and the address it has while that
/// bank is switched in. Addresses below 0x4000 are in bank 0 whatever the bank says.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct FarPointer {
    pub bank: u8,
    pub address: u16,
}

impl FarPointer {
    pub const fn new(bank: u8, address: u16) -> Self {
        Self { bank, address }
    }

    /// Where it points in the ROM image, or `None` for an address outside ROM (0x8000 and up).
    pub fn offset(self) -> Option<u64> {
        match self.address {
            0..0x4000 => Some(self.address.into()),
            0x4000..0x8000 => {
                Some(u64::from(self.bank) * BANK_SIZE + u64::from(self.address) - BANK_SIZE)
            }
            _ => None,
        }
    }
}

pub fn is_gb(head: &[u8]) -> bool {
    head.len() >= HEADER_END && head[0x104..0x108] == LOGO
}

/// The checksum of the whole ROM stored in the header (big-endian, at 0x14E). Releases that share
/// a title, such as a game's languages and revisions, tell apart by it.
pub fn global_checksum(head: &[u8]) -> Result<u16> {
    let bytes = Reader::new("Game Boy header", head).array_at(0x14E)?;
    Ok(u16::from_be_bytes(bytes))
}

pub fn identify(head: &[u8]) -> Result<Identity> {
    let header = Reader::new("Game Boy header", head);
    let color = header.u8_at(0x143)? & CGB != 0;
    // Color-aware headers shorten the title to 15 bytes; later ones also end it with a
    // four-character manufacturer code.
    let code = header.array_at::<4>(0x13F)?;
    let (title_len, code) = match color {
        false => (16, None),
        true if code.iter().all(u8::is_ascii_alphanumeric) => (11, Some(GameCode(code))),
        true => (15, None),
    };
    Ok(Identity {
        platform: if color {
            Platform::GameBoyColor
        } else {
            Platform::GameBoy
        },
        title: ascii(header.slice_at(0x134, title_len)?),
        code,
        region: match header.u8_at(0x14A)? {
            0 => Region::Japan,
            _ => Region::Overseas,
        },
        revision: header.u8_at(0x14C)?.into(),
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    fn header(title_area: &[u8; 15], cgb_flag: u8) -> Vec<u8> {
        let mut bytes = vec![0; HEADER_END];
        bytes[0x104..0x108].copy_from_slice(&LOGO);
        bytes[0x134..0x143].copy_from_slice(title_area);
        bytes[0x143] = cgb_flag;
        bytes[0x14A] = 1;
        bytes
    }

    #[test]
    fn color_titles_split_off_their_manufacturer_code() {
        let gold = identify(&header(b"POKEMON_GLDAAUE", 0x80)).unwrap();
        assert_eq!(gold.platform, Platform::GameBoyColor);
        assert_eq!(gold.title, "POKEMON_GLD");
        assert_eq!(gold.code, Some(GameCode(*b"AAUE")));

        let yellow = identify(&header(b"POKEMON YELLOW\0", 0x80)).unwrap();
        assert_eq!(yellow.title, "POKEMON YELLOW");
        assert_eq!(yellow.code, None);
    }

    #[test]
    fn far_pointers_map_banks_into_the_image() {
        assert_eq!(FarPointer::new(0x0E, 0x43DE).offset(), Some(0x383DE));
        assert_eq!(FarPointer::new(0x0E, 0x0150).offset(), Some(0x150));
        assert_eq!(FarPointer::new(0x01, 0xC000).offset(), None);
    }

    #[test]
    fn original_titles_use_all_sixteen_bytes() {
        let red = identify(&header(b"POKEMON RED\0\0\0\0", 0)).unwrap();
        assert_eq!(
            (red.platform, red.title.as_str()),
            (Platform::GameBoy, "POKEMON RED")
        );
        assert_eq!(red.region, Region::Overseas);
    }
}