gb_cart 0.2.0

GB/CGB file header library and utilities.
Documentation
// SPDX-License-Identifier: LGPL-2.1-or-later OR GPL-2.0-or-later OR MPL-2.0
// SPDX-FileCopyrightText: 2024 Gabriel Marcano <gabemarcano@yahoo.com>

use crate::cart_type::CartType;
use crate::error::Error;
use crate::licensee::Licensee;
use crate::licensee::NewLicensee;

use std::fmt;
use std::io::Read;
use std::io::Seek;
use std::io::SeekFrom;
use std::str;

use byteorder::BigEndian;
use byteorder::LittleEndian;
use byteorder::ReadBytesExt;

/// Represents the region the cartridge is made for.
#[derive(Debug)]
pub enum Region {
    Japan,
    Elsewhere,
}

impl fmt::Display for Region {
    fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result {
        match self {
            Self::Japan => write!(fmt, "Japan")?,
            Self::Elsewhere => write!(fmt, "Not Japan")?,
        }
        Ok(())
    }
}

impl From<u8> for Region {
    fn from(data: u8) -> Self {
        match data {
            0 => Self::Japan,
            _ => Self::Elsewhere,
        }
    }
}

/// Represents the metadata held by the GB/CGB internal header.
#[derive(Debug)]
pub struct Header {
    /// Application entry point. Bootrom jumps to this address once it is done.
    pub entry_point: u32,
    /// Bitmap of the Nintendo logo.
    pub nintendo_logo: [u8; 48],
    /// Title of the game cartridge. It is either 16 ASCII bytes long, or 11, depending on if the
    /// [`Header::cgb_flag`] is missing or found, respectively. Unused bytes are replaced with space
    /// characters.
    pub title: String,
    /// Manufacturer code, only available if the [`Header::cgb_flag`] is found.
    pub manufacturer_code: Option<String>,
    /// CGB flag, indicating support for CGB unique features or operation.
    pub cgb_flag: Option<u8>,
    /// New licensee code. This is used if the [`Header::licensee`] field is equal to 0x33.
    pub new_licensee: Option<NewLicensee>,
    /// Indicates support for Super Game Boy (SGB) functions.
    pub sgb_flag: bool,
    /// Cartridge type. Specifically, what the configuration of the ROM, mapper, saves, etc. is.
    pub cart_type: CartType,
    /// The size of the ROM in KiB.
    pub rom_size: u16,
    /// The size of save RAM in KiB.
    pub ram_size: u8,
    /// The region the cartridge is made for.
    pub region: Region,
    /// The original licensee field. If it is 0x33, the licensee information is in [`Header::new_licensee`].
    pub licensee: Licensee,
    /// ROM version.
    pub rom_version: u8,
    /// The checksum of the GB/CGB header.
    pub header_checksum: u8,
    /// The checksum of the entire cartridge.
    pub checksum: u16,
}

impl Header {
    /// Prints the stored logo to stdout.
    ///
    /// Prints two spaces or two '█' per pixel due to terminal characters being taller than they
    /// are wider.
    pub fn print_logo(&self) {
        let output = self.extract_boot_logo();
        for y in 0..16 {
            for x in 0..48 {
                if output[x + y * 48] == 0 {
                    print!("  ");
                } else {
                    print!("\u{2588}\u{2588}");
                }
            }
            println!();
        }
    }

    /// Extracts the boot logo and returns it as a 48x16 8bit grayscale image.
    fn extract_boot_logo(&self) -> [u8; 48 * 16] {
        // 2 pairs of 2 bytes x 4 form a single tile
        // First byte has low bits, second high bits

        let logo = self.nintendo_logo;
        let mut output = [0u8; 48 * 16];
        // Each 4 bytes comprise of a tile of 8x4 (that's stretched to 8x8) pixels
        for i in (0..logo.len()).step_by(4) {
            let tile_num = i / 4;
            let output_index = ((tile_num % 6) * 8) + ((tile_num / 6) * 48 * 8);
            // Tiles are arranged as:
            // | byte 0 | byte 2 |
            // | byte 1 | byte 3 |
            //
            // And each byte has two rows of pixels, one bit per pixel:
            // MSB | 6 | 5 |  4
            //  3  | 2 | 1 | LSB
            //
            // The boot logo tiles are half the display height, when rendered each row needs to be
            // doubled.

            // Process 4 pixels at a time, which are extracted from just a single byte of data.
            for y in (0..7).step_by(4) {
                for x in 0..8 {
                    let byte_index = (x / 4) * 2 + (y / 4);
                    let byte = logo[i + byte_index];
                    // Renders one byte from a tile
                    let output_index = output_index + x + y * 48;
                    let bit = x % 4;
                    output[output_index] = ((byte >> (7 - bit)) & 0x1) * 0xFF;
                    output[output_index + 48] = ((byte >> (7 - bit)) & 0x1) * 0xFF;
                    output[output_index + 48 * 2] = ((byte >> (3 - bit)) & 0x1) * 0xFF;
                    output[output_index + 48 * 3] = ((byte >> (3 - bit)) & 0x1) * 0xFF;
                }
            }
        }
        output
    }
}

/// Represents a GB/CGB cartridge.
pub struct Cart<T: Read + Seek> {
    pub header: Header,
    io: T,
}

pub trait HeaderRead {
    /// Parses the GB ROM metadata from the object provided, returning a Header object with the
    /// header metadata.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Parse`] if the header cannot be found or if a field in the header contains
    /// an unexpected value.
    /// Returns [`Error::IO`] if an IO error took place while reading from the file.
    fn read_gb_header(&mut self) -> Result<Header, Error>;
}

impl<T: Read + Seek> Cart<T> {
    /// Returns a new Cart.
    ///
    /// # Errors
    ///
    /// See [`HeaderRead::read_gb_header`] for possible errors.
    pub fn new(mut io: T) -> Result<Self, Error> {
        let header = io.read_gb_header()?;
        Ok(Self { header, io })
    }
}

/// Trims null and whitespace characters (in that order) from the given string.
fn trim(string: &str) -> &str {
    string.trim_matches('\0').trim()
}

impl<T: Read + Seek> HeaderRead for T {
    fn read_gb_header(&mut self) -> Result<Header, Error> {
        self.seek(SeekFrom::Start(0x100))?;

        let entry_point = self.read_u32::<LittleEndian>()?;
        let mut nintendo_logo = [0u8; 48];
        self.read_exact(&mut nintendo_logo)?;
        let mut title = [0u8; 16];
        self.read_exact(&mut title)?;

        let (title, manufacturer_code, cgb_flag) = if title[15].is_ascii() {
            (&title[..], None, None)
        } else {
            let manufacturer_code = &title[11..15];
            if !manufacturer_code.is_ascii() {
                return Err(Error::Parse("manufacturer code is not ASCII".into()));
            }
            let cgb_flag = title[15];
            (
                &title[0..11],
                Some(trim(str::from_utf8(manufacturer_code)?).to_string()),
                Some(cgb_flag),
            )
        };

        if !title.is_ascii() {
            return Err(Error::Parse("title not valid ASCII".into()));
        }
        let title = trim(str::from_utf8(title)?).to_string();

        let new_licensee = self.read_u16::<LittleEndian>()?;
        let lower_nibble: u8 = (new_licensee & 0x0F) as u8;
        let upper_nibble: u8 = ((new_licensee >> 4) & 0xF0) as u8;
        let new_licensee = (upper_nibble | lower_nibble).into();
        let sgb_flag = self.read_u8()? == 0x03;
        let cart_type = self.read_u8()?.into();
        let rom_size = self.read_u8()?;
        if rom_size > 8 {
            return Err(Error::Parse("ROM size too large".into()));
        }
        let rom_size: u16 = 32 * (1 << rom_size);
        let ram_size = self.read_u8()?;
        let ram_size = match ram_size {
            0 => 0,
            2 => 8,
            3 => 32,
            4 => 128,
            5 => 64,
            _ => return Err(Error::Parse("Unknown RAM size".into())),
        };
        let region = self.read_u8()?.into();
        let licensee = self.read_u8()?.into();
        let rom_version = self.read_u8()?;
        let header_checksum = self.read_u8()?;
        let checksum = self.read_u16::<BigEndian>()?;

        let new_licensee = match licensee {
            Licensee::NewLicenseeField => Some(new_licensee),
            _ => None,
        };

        Ok(Header {
            entry_point,
            nintendo_logo,
            title,
            manufacturer_code,
            cgb_flag,
            new_licensee,
            sgb_flag,
            cart_type,
            rom_size,
            ram_size,
            region,
            licensee,
            rom_version,
            header_checksum,
            checksum,
        })
    }
}

impl<T: Read + Seek> Cart<T> {
    /// Returns the checksum of the header.
    ///
    /// # Errors
    ///
    /// Returns [`Error::IO`] if there are any IO errors while reading or seeking from the cart's
    /// underlying IO object.
    pub fn header_checksum(&mut self) -> Result<u8, Error> {
        self.io.seek(SeekFrom::Start(0x134))?;
        let mut checksum = 0u8;
        for _ in 0..(0x14D - 0x134) {
            checksum = checksum.wrapping_sub(self.io.read_u8()?).wrapping_sub(1);
        }
        Ok(checksum)
    }

    /// Returns the checksum of the entire cart.
    ///
    /// The checksum is simply the sum of every byte in the cart, truncated to 16 bits.
    ///
    /// # Errors
    ///
    /// Returns [`Error::IO`] if there are any IO errors while reading or seeking from the cart's
    /// underlying IO object.
    pub fn checksum(&mut self) -> Result<u16, Error> {
        self.io.seek(SeekFrom::Start(0))?;
        let mut checksum = 0u16;
        let mut data = Vec::new();
        // It's faster to read in the entire ROM... on modern systems, the max size fits in memory
        // just fine, so just read it all
        self.io.read_to_end(&mut data)?;
        // Null out current checksum values, they're not part of checksum calculation
        data[0x14E] = 0;
        data[0x14F] = 0;
        for byte in &data {
            checksum = checksum.wrapping_add(u16::from(*byte));
        }
        Ok(checksum)
    }
}