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:
- Each sprite is 8 pixels square (8x8).
- Each sprite is stored as a group of 16 bytes.
- Each row of 8 bytes in the sprite is represented by two bytes.
- 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.
- 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 have00001100
. & 1
gives us a mask of 1, which returns just the "last" bit of the byte. So if you had00001100
it would return0
.- Combining these two gives us the bit we want to compare without wasting CPU cycles on looking at a string (caveman-brain).
- Shifting the bits means moving it down a certain number of positions. If you have
- We then take byte1 and shift in 1 space the other direction (3). This means if we have
1
we now have10
. 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
Footnotes
-
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. ↩↩
-
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. ↩