Kabosu - Creando cosas

Logo de la página. Gato esférico con colores verdoso.

Extrayendo gráficos de Game Boy

Publicado: 2024-03-14

Etiquetas: game boy, python


En un artículo anterior expliqué cómo se podía extraer el logo de Nintendo de la cabecera de cualquier ROM de Game Boy. Lo escribí a modo de curiosidad pero realmente era parte de un proyecto que tenía en mente para extraer los gráficos de los juegos de Game Boy de manera automática. La semana pasada seguí adelante con la idea y en este artículo voy a explicar cómo convertir la memoria de la Game Boy en imágenes PNG.

El formato gráfico de la Game Boy

El formato gráfico que se usa para el logo inicial y que vimos en el anterior artículo es monocromo. Utiliza un bit para cada pixel, 1 indica que el pixel es oscuro y 0 es blanco.

Un cuadro verde claro con un 0 dentro. Al lado un cuadro verde oscuro con un 1 dentro

En cambio, el formato que suelen usar los juegos aprovecha los 4 colores de la pantalla de Game Boy. Es decir, cada píxel de la pantalla necesita 2 bits. Por tanto para un tile típico de 8x8 necesitamos 16 bytes.

Los 4 colores de la Game Boy original

Por conveniencia suelo poner el color más claro en el 00 y el más oscuro en el 11 pero hay que tener en cuenta que los colores específicos no están definidos. Cada juego puede asignar paletas a cada sprite y decidir que, por ejemplo, el color 00 sea el tono verde más oscuro.

Convirtiendo datos

Escribí una función que recibe una secuencia de al menos 16 bytes y los junta de dos en dos para formar las 8 filas de píxeles. No tiene mucha dificultad: coge una pareja de bytes y los convierte en dos secuencias 8 bits que posteriormente se juntan.

Si la pareja fuera 0xAA y 0xFF, 170 y 255 en decimal respectivamente, haríamos el siguiente proceso. Primero pasamos a binario los valores:

0xAA -> 10101010 (binario)
0xFF -> 11111111 (binario)

Luego juntamos los bits que están en la misma posición. El bit del segundo valor se pone a la izquierda.

0xAA      =  1  0  1  0  1  0  1  0  1
0xFF      = 1  1  1  1  1  1  1  1  1 
Resultado = 11 10 11 10 11 10 11 10 11

Este es el código que hace ambas cosas:

def toBin(v, size=4):
	return bin(v)[2:].rjust(size, '0')


def toTile(seq):
	ROWS = 8
	COLS = 8
	tile = []
	for i in range(ROWS):
    	    top = toBin(seq[2 * i], 8)
    	    bottom = toBin(seq[2 * i + 1], 8)

    	    row = [0] * COLS
    	    for j in range(COLS):
             row[j] = int(top[j]) + 2*int(bottom[j])
    	    tile.append(row)
	return tile

La función devuelve una matriz de 8x8 números que van de 0 a 3 representando los colores de cada píxel. Seguramente se podría mejorar mucho pero funciona así que de momento la he dejado así.

Ver una matriz de números no es muy espectacular así que tenía que buscar la forma de mostrar el tile por pantalla.

Guardando ficheros PNG

Encontré el módulo pypng que parece suficiente para lo que quiero hacer. Tiene una clase Writer para escribir ficheros PNGs a partir de matrices de enteros y se puede asignar una paleta para poder mostrar los tiles casi como una Game Boy ladrillo.

Es tan sencillo como crear un objeto Writer diciéndole el tamaño de la imagen (8x8 en nuestro caso), cantidad de colores (4, por tanto bitdepth es 2) y los colores que quiero usar. La paleta la copié de aquí.

def drawTileGB(tile, filename):
	COLORS = [(155, 188, 15), (139, 172, 15), (48, 98, 48), (15, 56, 16)]

	writer = png.Writer(len(tile[0]), len(tile), palette=COLORS, bitdepth=2)
	with open(filename, 'wb') as fout:
    	    writer.write(fout, tile)

Problemas

Con este código puedo interpretar cualquier segmento de 16 bits como un tile de Game Boy y ver cómo quedaría pero hay un problema: las ROMs de Game Boy ocupan entre 32KB y varios MB y no toda esa memoria son gráficos. ¿Cómo voy a saber qué partes de la ROM extraer como PNG? Decidí que me daba igual y que iba asumir que todos los segmentos de 16 bytes representan tiles.

Escribí el siguiente código que separa la ROM en segmentos de 16 bytes y luego lo convierte

def extractTiles(data):
	tiles = []
	for i in range(0, len(data), 16):
    	    tileData = data[i:i+16]
    	    tile = toTile(tileData)
    	    tiles.append(tile)
    	    drawTileGB(tile, f'output/tile_{i}.png')
	return tiles

Al ejecutar el script con la ROM de un juego me generó decenas de miles de imágenes. Menos mal que tengo un disco SSD porque si no hubiera tardado varios minutos. Tal y cómo suponía la mayoría de tiles son basura aunque haciendo scroll pude encontrar letras y trozos de lo que parecían ser sprites del juego y escenarios.

Tiles de Game Boy glitcheadas

Tiles de Game Boy. Se ven algunas letras

A pesar del SSD, al explorador de archivos le costaba bastante moverse por las miles de imágenes por lo que decidí que en vez de guardar cada tile en un fichero PNG las guardaría todas en el mismo PNG una al lado de la otra. Con eso tendría una imagen por cada juego y sería todo más manejable.

El siguiente código crea la imagen completa. Le añadí un argumento scale para que el resultado fuera una imagen más grande ya que al hacer zoom se veía todo borroso en las imágenes anteriores. Primero calcula cuántas filas de tiles vamos a necesitar, luego crea una matriz para contener los valores de los píxeles, finalmente va copiando va copiando los tiles en esta imagen utilizando la función applyTile.

def tilesToImg(tiles, tilesPerRow=64, scale=4):
	rows = scale * len(tiles[0]) * (1 + len(tiles) // tilesPerRow)
	cols = scale * len(tiles[0][0]) * tilesPerRow

	img = [ [0]*cols for _ in range(rows) ]

	for i, tileData in enumerate(tiles):
    	posY = scale * len(tiles[0]) * (i // tilesPerRow)
    	posX = scale * len(tiles[0][0]) * (i % tilesPerRow)
    	applyTile(img, tileData, posY, posX, scale)

	return img

def applyTile(img, tileData, posY, posX, scale):
	for i in range(len(tileData)):
    	y = posY + i * scale
    	for j in range(len(tileData[0])):
        	x = posX + j * scale
        	for a in range(scale):
            	for b in range(scale):
                	    img[y + a][x + b] = tileData[i][j]

El resultado de tilesToImg se puede pasar tal cuál a drawTileGB para crear el fichero PNG.

tiles = extractTiles(rom)
img = tilesToImg(tiles)

drawTileGB(img, 'result.png')

Resultado

Con el script ya completo me puse a procesar algunas ROMs de juegos que poseo y los resultados me gustaron mucho. Cada juego convertido a imagen empieza con un montón de tiles con glitches que recuerdan errores míticos como el Pokémon Missing No. Luego suelen aparecer algunos tiles de gráficos: trozos del escenario, letras sueltas, partes de los sprites... Tras estas partes discernibles volvemos otra vez a los glitches que seguramente representan los niveles, la música o el código del juego.

A continuación muestro un par de fragmentos que he obtenido. No son las imágenes completas porque a partir de ellas sería relativamente sencillo recuperar la ROM original y no quiero que me acusen de distribuir nada con copyright.

Tiles de Game Boy cominadas en una imagen

Tiles de Game Boy cominadas en una imagen

Con estas imágenes se intuye lo que hace el juego: compone los escenarios y textos a partir de estos tiles de 8x8. Los sprites también están limitados a 8x8 por lo que si los personajes son más grandes realmente usan múltiples tiles que se dibujan una al lado de la otra para simular que son el mismo objeto.

Siguientes pasos

Quiero usar el script para convertir en imágenes todas las ROMs de los cartuchos originales de Game Boy que tengo por casa. Tengo cientos de juegos guardados en el armario así que primero he de organizar la colección y obtener sus ROMS.

Si alguna de las imágenes resultantes es bonita quizá me haga un fondo de escritorio o un póster con ella aunque cambiaría un poco la paleta para que los colores fueran un poco más suaves.


Artículo siguiente: Reseña libro: Read Write Own - Building the Next Era of the Internet
Artículo anterior: Descargando mis datos de Mastodon con Python