Creating a GB Sprite Viewer In Rust

2024/11/02

I had a few days off of work recently around Halloween and I decided I wanted to finally learn some basic Rust. Plus, the logo matches the colors of the season.

The Weekend Project

I wanted to twiddle a few bits with this project, but not too many, so I felt as if a good project would be to extract sprites from GameBoy ROMs. I did an initially internet search and found a similar project here: https://github.com/taylus/gb-rom-viewer. I decided to attack the problem the a similar way as this project, but that I would use Rust.

In the project that inspired me the original author converted all data in the ROM into sprites. At first I thought that I would implement a way to find sprites and ignore the code or unusable parts of memory, but this turned out to be quite a hard problem. There is not really a standard of where developers stored their sprites - they can be anywhere. Because of this I followed the same method. You can see examples of what full ROMs look like at the end of this article.

Generating The Sprites

The first thing we need to understand is how GameBoy graphics are stored in the ROM:

  1. Each sprite is 8 pixels square (8x8).
  2. Each sprite is stored as a group of 16 bytes.
  3. Each row of 8 bytes in the sprite is represented by two bytes.
  4. For each bit in these two bytes you “combine” the bit in the same position and read the value. The possible binary values are 00,01,10,11. These represent the numbers 0,1,2,3.
  5. The numbers returned by (4) represent the pixel color for that position in that row of the sprite.

For a more clear example we can show this example, courtesy of https://www.huderlem.com/demos/gameboy2bpp.html:

  Tile:                                     Image:

  .33333..                     .33333.. -> 01111100 -> $7C
  22...22.                                 01111100 -> $7C
  11...11.                     22...22. -> 00000000 -> $00
  2222222. <-- digits                      11000110 -> $C6
  33...33.     represent       11...11. -> 11000110 -> $C6
  22...22.     color                       00000000 -> $00
  11...11.     numbers         2222222. -> 00000000 -> $00
  ........                                 11111110 -> $FE
                               33...33. -> 11000110 -> $C6
                                           11000110 -> $C6
                               22...22. -> 00000000 -> $00
                                           11000110 -> $C6
                               11...11. -> 11000110 -> $C6
                                           00000000 -> $00
                               ........ -> 00000000 -> $00
                                           00000000 -> $00

My initial thought was to convert each group of 16 bytes into a string representing it in bits and then compare each index in the string, but this is very caveman-brain. The GameBoy certainly lacked the ability to do all of those string conversions all the time and play smoothly.

So I decided to go with the route of comparing individual bits together. 1

pub fn bytes_to_sprite_info(bts: &[u8]) -> Vec<isize> {
		// (1)
    let mut sprite: Vec<isize> = vec![];

	
    for c in bts.chunks(2) {
        let byte1 = c[0];
        let byte2 = c[1];
				
				// (2)
        for i in (0..8).rev() { // Process bits from most significant to least significant
            let bit1 = (byte1 >> i) & 1; // Extract the i-th bit of byte1
            let bit2 = (byte2 >> i) & 1; // Extract the i-th bit of byte2

            // (3)
            let color = (bit2 as isize) << 1 | (bit1 as isize);
            sprite.push(color);
        }
    }

    sprite
}

Lets break this down:

So now lets do this for the entire ROM and store it in its own place.

    // Initiate a Vec for every chunk of the ROM
    let mut sprite_chunks: Vec<Vec<isize>> = vec![];

    for chunk in bytes.chunks(16) {
        sprite_chunks.push(bytes_to_sprite_info(chunk));
    }

Once we define the palettes in a way that can be accessed by the values 0,1,2,3 we can then just iterate over sprite_chunks and create lists of values for each pixel in each potential 8x8 sprite in the entire ROM.

pub struct Palettes {
    pub dmg: Vec<i32>,
    pub light: Vec<i32>,
    pub pocket: Vec<i32>,
}

impl Palettes {
    pub fn new() -> Palettes {
        Palettes {
            dmg: vec![0x9a9e3f, 0x496b22, 0x0e450b, 0x1b2a09],
            light: vec![0x00b582, 0x009a70, 0x00694a, 0x004f3a],
            pocket: vec![0xaea691, 0x887b6a,0x605444,0x4e3f2a]
        }
    }
}

I decided to do the three main GameBoy palettes (DMG, Pocket, Light). I would like to also include the 16 alternate palettes that were possible on the GameBoy Color when playing original GameBoy Games. 1

Creating The Images

Now that we have the HEX color codes for each pixel in all of the sprites we can go about actually generating the sprites as PNG images.

We used a similar bit-shifting method to get the RGB values out of the HEX color codes and placed them all in individual 8x8 images, which we stored in their own vec.

// Create a new 8x8 RGB image
    let mut img = RgbImage::new(8, 8);

    // Iterate over the pixels, applying colors from the `colors` array
    for (i, color) in colors.iter().cycle().enumerate().take(64) {
        let x = (i % 8) as u32;
        let y = (i / 8) as u32;

        let r = ((color >> 16) & 0xff) as u8;
        let g = ((color >> 8) & 0xff) as u8;
        let b = (color & 0xff) as u8;

        img.put_pixel(x, y, Rgb([r, g, b]));
    }

After this we did a similar thing to stitch all of the potential sprites together, but instead of placing a pixel we place an already generated image onto a large image “canvas”.

// Create the large image canvas
    let mut large_img = ImageBuffer::new(large_image_width as u32, large_image_height as u32);

    // Place each 8x8 image into the large image
    for (index, small_img) in small_images.iter().enumerate() {
        let x_offset = (index % num_images_per_row) * small_image_size;
        let y_offset = (index / num_images_per_row) * small_image_size;

        for x in 0..small_image_size {
            for y in 0..small_image_size {
                let pixel = small_img.get_pixel(x as u32, y as u32);
                large_img.put_pixel(x_offset as u32 + x as u32, y_offset as u32 + y as u32, *pixel);
            }
        }
    }

Example Results

Here are some example images, in the following order:

All palettes were taken from Lospec:

SameGame, DMG palette
SameGame, DMG palette
Bomberman GB, DMG palette
Bomberman GB, DMG palette
Super Mario Land 2, DMG palette
Super Mario Land 2, DMG palette
Super Mario Land 2, GameBoy Light palette
Super Mario Land 2, GameBoy Light palette
Super Mario Land 2, GameBoy Pocket palette
Super Mario Land 2, GameBoy Pocket palette

Footnotes


  1. I did not know how to do this before I started this project, hence my caveman-brain idea with strings. I had to ask the LLMs for help and then took the time to fully understand what is going on. ↩︎ ↩︎