Jeremy Heckt's Blog

Creating a GB Sprite Viewer In Rust

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:

  • The function takes a vec/list of bytes. We then initialize a new vector to store our results (1) and then break the original list into groups of two.
  • We then begin to loop over the bits in the processed bytes, starting at the most significant bit (2). This code then takes each byte and shifts it by i positions.
    • Shifting the bits means moving it down a certain number of positions. If you have 11000011 and shift it four positions you now have 00001100.
    • & 1 gives us a mask of 1, which returns just the "last" bit of the byte. So if you had 00001100 it would return 0.
    • Combining these two gives us the bit we want to compare without wasting CPU cycles on looking at a string (caveman-brain).
  • We then take byte1 and shift in 1 space the other direction (3). This means if we have 1 we now have 10. We then use a bitwise OR operation to "merge" in byte2.
    • A bitwise OR operation will return a 1 if either the target or the source bit are 1. If both are zero then it returns zero. Since we shifted back byte1 we will always have a zero in that spot, and the bitwise OR will give us whatever value is in byte2 in that place.

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:

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

All palettes were taken from Lospec:

  • DMG: https://lospec.com/palette-list/dmg-01-accurate
  • GameBoy Light: https://lospec.com/palette-list/game-boy-light
  • GameBoy Pocket: https://lospec.com/palette-list/pocketgb

SameGame, DMG palette Bomberman GB, DMG palette Super Mario Land 2, DMG palette Super Mario Land 2, GameBoy Light 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. 

  2. This can be done by pressing up, down, left, or right on the directional pad while holding down either A or B buttons during startup when using an original GameBoy game in a GameBoy Color.