Este proyecto es educativo y Open Source. No se copia código de otros emuladores. Implementación basada únicamente en documentación técnica y tests permitidas.
Integración Gráfica y Decodificador de Tiles
Resumen
¡Hito visual histórico! Se integró Pygame para visualizar gráficos y se implementó el decodificador de tiles en formato 2bpp (2 bits por píxel) de la Game Boy. Ahora el emulador puede "ver" y mostrar el contenido de la VRAM, decodificando los gráficos que la CPU escribe en memoria. La implementación incluye la función decode_tile_line() que convierte dos bytes en 8 píxeles con colores 0-3, y la clase Renderer que inicializa Pygame y proporciona un modo debug para visualizar todos los tiles de la VRAM en una rejilla. El sistema se integra con el bucle principal, renderizando cuando se detecta V-Blank.
Concepto de Hardware
La Game Boy no almacena imágenes completas (bitmaps) en memoria. En su lugar, usa un sistema de tiles (baldosas) de 8x8 píxeles que se combinan para formar fondos y sprites.
VRAM (Video RAM)
La memoria gráfica se encuentra en el rango 0x8000-0x9FFF (8KB = 8192 bytes). Esta área contiene los datos de los tiles y más adelante contendrá también mapas de fondo y sprites.
Formato 2bpp (2 Bits Per Pixel)
Cada tile ocupa 16 bytes (2 bytes por línea × 8 líneas). El formato 2bpp permite representar 4 colores diferentes por píxel (00, 01, 10, 11).
Para cada línea horizontal de 8 píxeles:
- Byte 1 (LSB): Contiene los bits menos significativos (bajos) de cada píxel
- Byte 2 (MSB): Contiene los bits más significativos (altos) de cada píxel
El color de cada píxel se calcula combinando los bits correspondientes:
color = (MSB << 1) | LSB
Esto produce valores de 0 a 3:
- Color 0 (0b00): LSB=0, MSB=0 → Blanco (transparente en sprites)
- Color 1 (0b01): LSB=1, MSB=0 → Gris claro
- Color 2 (0b10): LSB=0, MSB=1 → Gris oscuro
- Color 3 (0b11): LSB=1, MSB=1 → Negro
Ejemplo de Decodificación
Para una línea con:
- Byte 1 (LSB):
0x3C=00111100 - Byte 2 (MSB):
0x7E=01111110
El píxel más a la izquierda (bit 7):
- LSB = bit 7 de Byte 1 = 0
- MSB = bit 7 de Byte 2 = 0
- Color = (0 << 1) | 0 = 0
El segundo píxel (bit 6):
- LSB = bit 6 de Byte 1 = 0
- MSB = bit 6 de Byte 2 = 1
- Color = (1 << 1) | 0 = 2
Fuente: Pan Docs - Tile Data, 2bpp Format
Implementación
Se implementó un sistema completo de renderizado gráfico usando Pygame, con un modo debug que visualiza todos los tiles de la VRAM.
Componentes creados/modificados
src/gpu/renderer.py: Nuevo módulo con la claseRenderery la función helperdecode_tile_line()src/viboy.py: Integración del renderer en el bucle principal, manejo de eventos Pygame, y renderizado en V-Blanksrc/gpu/__init__.py: Exportación condicional deRenderer(solo si pygame está disponible)tests/test_gpu_tile_decoder.py: Suite completa de tests TDD para el decodificador 2bpp
Función decode_tile_line()
Decodifica una línea de 8 píxeles a partir de dos bytes:
- Recorre cada bit de izquierda a derecha (bit 7 a bit 0)
- Extrae el bit bajo del byte1 y el bit alto del byte2
- Calcula el color como:
(bit_high << 1) | bit_low - Devuelve una lista de 8 enteros (0-3) representando los colores
Clase Renderer
Responsabilidades:
- Inicialización: Configura Pygame, crea ventana escalada (por defecto 3x, resultando en 480×432 píxeles)
render_vram_debug(): Decodifica todos los tiles de VRAM y los dibuja en una rejilla de 32×16 tiles_draw_tile(): Dibuja un tile individual de 8×8 píxeles usando la paleta de griseshandle_events(): Maneja eventos de Pygame (especialmente cierre de ventana)quit(): Cierra Pygame limpiamente
Integración en Viboy
El renderer se inicializa opcionalmente (si pygame está disponible) tanto en __init__() como en load_cartridge(). En el bucle principal:
- Se manejan eventos de Pygame antes de cada ciclo
- Se detecta el inicio de V-Blank (LY >= 144) mediante transición de estado
- Cuando se entra en V-Blank, se llama a
render_vram_debug()para actualizar la pantalla - En el bloque
finally, se cierra el renderer limpiamente
Decisiones de diseño
- Paleta de grises fija: Por ahora usamos una paleta simple (Blanco, Gris claro, Gris oscuro, Negro) para visualización. Más adelante se implementará la paleta real (BGP, OBP0, OBP1)
- Modo debug primero: Implementamos primero
render_vram_debug()para verificar que la decodificación funciona. El renderizado completo del juego vendrá después - Renderizado en V-Blank: Renderizamos solo cuando detectamos V-Blank para no saturar el sistema con actualizaciones constantes
- Importación condicional: El renderer solo se carga si pygame está disponible, permitiendo que el emulador funcione sin gráficos
Archivos Afectados
src/gpu/renderer.py- Nuevo módulo con clase Renderer y función decode_tile_line()src/gpu/__init__.py- Exportación condicional de Renderersrc/viboy.py- Integración del renderer, manejo de eventos Pygame, y renderizado en V-Blanktests/test_gpu_tile_decoder.py- Nuevo archivo con 6 tests unitarios para decode_tile_line()
Tests y Verificación
Se implementó una suite completa de tests TDD para validar la decodificación 2bpp.
Tests unitarios
Comando ejecutado: python3 -m pytest tests/test_gpu_tile_decoder.py -v
Entorno: macOS (darwin 21.6.0), Python 3.9.6, pytest 8.4.2
Resultado: 6 passed in 0.11s
Qué valida:
- Decodificación básica: Verifica que una línea con bytes 0x3C y 0x7E produce los colores correctos [0, 2, 3, 3, 3, 3, 2, 0]
- Todos los colores: Valida que podemos obtener los 4 valores posibles (0, 1, 2, 3) usando diferentes combinaciones de bytes
- Colores específicos: Tests separados para Color 0 (ambos bytes 0x00), Color 1 (LSB=0xFF, MSB=0x00), Color 3 (ambos bytes 0xFF)
- Patrones complejos: Verifica un patrón alternado (0xAA y 0x55) que produce una secuencia [1, 2, 1, 2, 1, 2, 1, 2]
Código del test esencial
def test_decode_2bpp_line_basic(self) -> None:
"""Test básico: decodificar una línea de tile 2bpp"""
byte1 = 0x3C # 00111100 (LSB)
byte2 = 0x7E # 01111110 (MSB)
result = decode_tile_line(byte1, byte2)
assert len(result) == 8
assert result[0] == 0 # Bit 7: LSB=0, MSB=0 -> 0
assert result[1] == 2 # Bit 6: LSB=0, MSB=1 -> 2
assert result[2] == 3 # Bit 5: LSB=1, MSB=1 -> 3
# ... más aserciones
Ruta completa: tests/test_gpu_tile_decoder.py
Estos tests demuestran que la decodificación 2bpp funciona correctamente según la especificación: el byte1 contiene los bits menos significativos, el byte2 contiene los bits más significativos, y el color se calcula como (MSB << 1) | LSB.
Prueba con ROM de Tetris
ROM: Tetris DX (ROM aportada por el usuario, no distribuida)
Modo de ejecución: UI (Pygame), escala 3x (480×432 píxeles)
Criterio de éxito: El renderer debería inicializarse correctamente y mostrar la ventana de Pygame cuando se detecte V-Blank.
Observación:
- Pygame se instaló correctamente (pygame-ce 2.5.6)
- El renderer se inicializa sin errores:
INFO: Renderer inicializado: 480x432 (scale=3) - El emulador ejecuta 70,118 ciclos antes de detenerse
- El juego se detiene en el opcode 0x1D (DEC E) que aún no está implementado
- La ventana de Pygame no se muestra porque el juego se detiene antes de llegar al primer V-Blank (necesita ejecutar más instrucciones para llegar a LY=144)
Resultado: draft - El renderer funciona correctamente (verificado con test independiente), pero el juego necesita opcodes adicionales (INC/DEC de 8 bits) para avanzar hasta el primer V-Blank y renderizar tiles reales.
Notas legales: La ROM de Tetris DX es aportada por el usuario para pruebas locales, no se distribuye ni se incluye en el repositorio.
Fuentes Consultadas
- Pan Docs: Tile Data, 2bpp Format - Especificación del formato de tiles de 8×8 píxeles con 2 bits por píxel
- Pan Docs: Video RAM (VRAM) - Ubicación y estructura de la memoria gráfica (0x8000-0x9FFF)
- Pan Docs: LCD Timing, V-Blank - Cuándo es seguro actualizar la pantalla
Nota: La implementación sigue estrictamente la documentación técnica. No se consultó código de otros emuladores.
Integridad Educativa
Lo que Entiendo Ahora
- Formato 2bpp: Entiendo que cada píxel se codifica con 2 bits, permitiendo 4 colores. Los bits están divididos en dos bytes: byte1 (LSB) y byte2 (MSB), y el color se calcula como
(MSB << 1) | LSB. - Estructura de tiles: Cada tile de 8×8 píxeles ocupa exactamente 16 bytes (2 bytes por línea). La VRAM puede almacenar hasta 512 tiles (8192 bytes / 16 bytes por tile).
- Renderizado en V-Blank: Es seguro actualizar la pantalla solo durante V-Blank (LY >= 144), porque durante el renderizado de líneas visibles, la PPU está leyendo activamente la VRAM.
- Paleta de colores: Los valores 0-3 son índices de color que se mapean a colores reales mediante registros de paleta (BGP, OBP0, OBP1). Por ahora usamos una paleta de grises fija para debug.
Lo que Falta Confirmar
- Renderizado con juego real: El renderer está funcional pero no se ha podido verificar con datos reales de Tetris porque el juego se detiene en opcode 0x1D (DEC E) antes de llegar al primer V-Blank. Falta implementar los opcodes INC/DEC de 8 bits faltantes para que el juego pueda avanzar.
- Paleta real: Falta implementar la lectura de los registros BGP, OBP0 y OBP1 para mapear correctamente los índices 0-3 a colores reales (que pueden ser diferentes para fondo y sprites).
- Renderizado completo: Este paso solo muestra los tiles en una rejilla. Falta implementar el renderizado real de la pantalla usando mapas de fondo (Tile Maps), scroll, ventana, y sprites.
- OAM (Object Attribute Memory): Falta entender completamente cómo se organizan los sprites en OAM y cómo se renderizan sobre el fondo.
- Prioridad y transparencia: Falta implementar las reglas de prioridad entre fondo y sprites, y cómo el Color 0 es transparente en sprites.
Hipótesis y Suposiciones
Renderizado en cada V-Blank: Por ahora renderizamos cada vez que detectamos el inicio de V-Blank. Esto puede ser demasiado frecuente y podría afectar el rendimiento. Más adelante deberíamos considerar renderizar solo cuando el contenido de VRAM cambia significativamente, o limitar la frecuencia de actualización.
Modo debug: La visualización actual en modo debug muestra todos los tiles en una rejilla, no el renderizado real del juego. Esto es intencional para verificar que la decodificación funciona, pero no muestra cómo se ve realmente el juego.
Opcodes faltantes: El juego se detiene en DEC E (0x1D) antes de llegar a renderizar. Esto confirma que necesitamos implementar los opcodes INC/DEC de 8 bits restantes (INC D/E/H/L y DEC D/E/H/L) para que los juegos puedan ejecutarse completamente. Estos opcodes siguen el mismo patrón que los ya implementados (DEC B, DEC C, DEC A), por lo que deberían ser rápidos de añadir.
Próximos Pasos
- [ ] Implementar lectura de registros de paleta (BGP, OBP0, OBP1) para mapear índices de color a colores reales
- [ ] Implementar renderizado de fondo usando Tile Maps (0x9800-0x9BFF y 0x9C00-0x9FFF)
- [ ] Implementar scroll del fondo (registros SCX y SCY)
- [ ] Implementar renderizado de ventana (Window) usando registros WX y WY
- [ ] Implementar renderizado de sprites desde OAM (0xFE00-0xFE9F)
- [ ] Implementar prioridades y transparencia (Color 0 es transparente en sprites)
- [ ] Optimizar el renderizado para que solo actualice cuando sea necesario