⚠️ Clean-Room / Educativo

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.

PPU Fase C: Renderizado Real de Tiles desde VRAM

Fecha: 2025-12-19 Step ID: 0126 Estado: Verified

Resumen

Después del éxito de la Fase B, que confirmó que el framebuffer y el pipeline de renderizado funcionan correctamente mostrando un patrón de prueba a 60 FPS, este paso implementa el renderizado real de tiles del Background desde VRAM. Para que esto sea posible, también se implementaron las instrucciones de escritura indirecta en memoria: LDI (HL), A (0x22), LDD (HL), A (0x32), y LD (HL), A (0x77).

El renderizado de tiles lee los datos de gráficos que la CPU del juego escribe en VRAM durante la inicialización, decodifica los tiles en formato 2bpp (2 bits por píxel), y los dibuja en el framebuffer aplicando scroll (SCX/SCY) y respetando las configuraciones del registro LCDC (tilemap base, tile data base, direccionamiento signed/unsigned).

Este es el paso que convierte el motor de prueba en un verdadero emulador visual: ahora la PPU puede mostrar los gráficos reales del juego en lugar de patrones de prueba.

Concepto de Hardware

El renderizado del Background en la Game Boy funciona mediante un sistema de tilemaps y tile data. Los juegos almacenan sus gráficos como tiles (baldosas) de 8x8 píxeles en VRAM, y luego organizan estos tiles en un mapa de 32x32 tiles (tilemap) para formar la pantalla completa.

Estructura de VRAM

La VRAM (Video RAM) de la Game Boy tiene 8KB (0x8000-0x9FFF) y se organiza en dos regiones principales:

  • Tile Data (0x8000-0x97FF): Contiene los datos gráficos de los tiles en formato 2bpp (16 bytes por tile)
  • Tile Maps (0x9800-0x9BFF y 0x9C00-0x9FFF): Contienen índices de tiles que forman el mapa del Background

Formato de Tile (2bpp)

Cada tile ocupa 16 bytes (8 líneas × 2 bytes por línea). Cada píxel está codificado en 2 bits:

  • Byte 1: Bits bajos de cada píxel (LSB)
  • Byte 2: Bits altos de cada píxel (MSB)
  • Color del píxel: (bit_alto << 1) | bit_bajo → valores 0-3 (índice de paleta)

Los píxeles se leen de izquierda a derecha, con el bit 7 siendo el píxel más a la izquierda.

Direccionamiento de Tiles

El registro LCDC (0xFF40) controla cómo se direccionan los tiles:

  • Bit 4 = 1: Tile Data en 0x8000 (direccionamiento unsigned: tile IDs 0-255)
  • Bit 4 = 0: Tile Data en 0x8800 (direccionamiento signed: tile IDs -128 a 127, tile 0 en 0x9000)

Instrucciones de Escritura Indirecta

Para que la CPU pueda escribir los datos de tiles en VRAM, necesita instrucciones que escriban en memoria indirecta usando HL como puntero:

  • LDI (HL), A (0x22): Escribe A en (HL) y luego incrementa HL. Útil para copias de memoria secuenciales (equivalente a *HL++ = A en C).
  • LDD (HL), A (0x32): Escribe A en (HL) y luego decrementa HL. Menos común, pero útil para copias en dirección inversa (equivalente a *HL-- = A en C).
  • LD (HL), A (0x77): Escribe A en (HL) sin modificar HL. La instrucción más común para escritura indirecta (equivalente a *HL = A en C).

Estas instrucciones son fundamentales durante la inicialización del juego, cuando se copian datos de gráficos desde la ROM a la VRAM.

Proceso de Renderizado de una Scanline

Para cada línea visible (LY = 0-143), la PPU:

  1. Lee SCY y SCX (registros de scroll) para determinar qué parte del tilemap mostrar
  2. Para cada píxel X en la pantalla (0-159):
    • Calcula la posición en el tilemap: map_x = (x + SCX) % 256, map_y = (LY + SCY) % 256
    • Lee el tile ID del tilemap en esa posición
    • Calcula la dirección del tile en VRAM según el direccionamiento (signed/unsigned)
    • Decodifica el píxel específico del tile (lee 2 bytes, extrae el bit correspondiente)
    • Escribe el índice de color (0-3) en el framebuffer

Implementación

Se implementaron dos componentes principales: las instrucciones de escritura indirecta en la CPU y el renderizado real de scanlines en la PPU.

CPU: Instrucciones de Escritura Indirecta

Se añadieron tres nuevos casos en el switch de CPU::step() en src/core/cpp/CPU.cpp:

  • LDI (HL), A (0x22): Escribe regs_->a en la dirección regs_->get_hl(), luego incrementa HL con wrap-around.
  • LDD (HL), A (0x32): Similar a LDI, pero decrementa HL después de escribir.
  • LD (HL), A (0x77): Ya estaba implementado en el bloque LD r, r', por lo que no se duplicó (solo se añadió un comentario).

Todas estas instrucciones consumen 2 M-Cycles según las especificaciones de Pan Docs.

PPU: Renderizado Real de Scanline

Se reemplazó completamente el método PPU::render_scanline() en src/core/cpp/PPU.cpp. El nuevo código:

  1. Verifica que el LCD esté habilitado (LCDC bit 7)
  2. Lee registros de configuración: SCY, SCX, LCDC
  3. Determina las bases de tilemap y tile data según LCDC
  4. Para cada píxel X (0-159):
    • Calcula posición en el tilemap con scroll
    • Lee tile ID del tilemap
    • Calcula dirección del tile en VRAM (signed/unsigned)
    • Lee 2 bytes de la línea del tile
    • Decodifica el píxel específico extrayendo los bits correspondientes
    • Escribe índice de color (0-3) en el framebuffer

Decisiones de diseño:

  • Se usa acceso directo a MMU para leer VRAM (la PPU tiene acceso completo a memoria)
  • Se calcula cada píxel individualmente en lugar de decodificar tiles completos (simplicidad inicial, puede optimizarse después)
  • Se mantiene el formato de índice de color (0-3) en el framebuffer, la aplicación de paleta se hace en Python

Archivos Afectados

  • src/core/cpp/CPU.cpp - Añadidas instrucciones LDI (HL), A (0x22) y LDD (HL), A (0x32)
  • src/core/cpp/PPU.cpp - Reemplazado render_scanline() con implementación real de renderizado de tiles
  • tests/test_core_cpu_indirect_writes.py - Nuevo archivo con 6 tests para validar las instrucciones de escritura indirecta

Tests y Verificación

Se creó un nuevo archivo de tests test_core_cpu_indirect_writes.py con 6 tests unitarios:

  • test_ldi_hl_a: Verifica que LDI (HL), A escribe correctamente y incrementa HL
  • test_ldi_hl_a_wrap_around: Verifica que LDI maneja correctamente el wrap-around (0xFFFF → 0x0000)
  • test_ldd_hl_a: Verifica que LDD (HL), A escribe correctamente y decrementa HL
  • test_ldd_hl_a_wrap_around: Verifica que LDD maneja correctamente el wrap-around (0x0000 → 0xFFFF)
  • test_ld_hl_a: Verifica que LD (HL), A escribe correctamente sin modificar HL
  • test_ldi_sequence: Verifica una secuencia de múltiples LDI para simular un bucle de copia

Resultado de ejecución:

$ pytest tests/test_core_cpu_indirect_writes.py -v
============================= test session starts =============================
collected 6 items

tests/test_core_cpu_indirect_writes.py::TestLDIndirectWrites::test_ldi_hl_a PASSED
tests/test_core_cpu_indirect_writes.py::TestLDIndirectWrites::test_ldi_hl_a_wrap_around PASSED
tests/test_core_cpu_indirect_writes.py::TestLDIndirectWrites::test_ldd_hl_a PASSED
tests/test_core_cpu_indirect_writes.py::TestLDIndirectWrites::test_ldd_hl_a_wrap_around PASSED
tests/test_core_cpu_indirect_writes.py::TestLDIndirectWrites::test_ld_hl_a PASSED
tests/test_core_cpu_indirect_writes.py::TestLDIndirectWrites::test_ldi_sequence PASSED

============================== 6 passed in 0.06s ==============================

Validación de módulo compilado C++: Todos los tests usan el módulo nativo compilado (viboy_core) y verifican que las instrucciones se ejecutan correctamente con el timing preciso (2 M-Cycles).

Fragmento clave del test:

def test_ldi_hl_a(self):
    """Verifica LDI (HL), A (0x22)"""
    mmu = PyMMU()
    regs = PyRegisters()
    cpu = PyCPU(mmu, regs)
    
    regs.pc = 0x8000
    regs.a = 0xBE
    regs.hl = 0xC000
    
    mmu.write(0x8000, 0x22)  # LDI (HL), A
    cycles = cpu.step()
    
    assert mmu.read(0xC000) == 0xBE
    assert regs.hl == 0xC001
    assert cycles == 2

Fuentes Consultadas

  • Pan Docs - CPU Instruction Set: Especificación de las instrucciones LDI (HL), A, LDD (HL), A y LD (HL), A, incluyendo timing (M-Cycles)
  • Pan Docs - Video Display: Explicación del formato de tiles (2bpp), estructura de VRAM, y proceso de renderizado del Background
  • Pan Docs - LCDC Register: Descripción de los bits que controlan el direccionamiento de tiles (bit 4) y selección de tilemaps

Integridad Educativa

Lo que Entiendo Ahora

  • Formato 2bpp: Entiendo cómo los tiles se almacenan como 16 bytes (8 líneas × 2 bytes), donde cada píxel está codificado en 2 bits distribuidos en dos bytes separados.
  • Direccionamiento de Tiles: La diferencia entre signed y unsigned addressing y cómo afecta al cálculo de la dirección del tile en VRAM.
  • Scroll: Cómo SCX y SCY permiten desplazar el tilemap visible en pantalla, creando el efecto de cámara.
  • Instrucciones de Escritura Indirecta: Cómo LDI/LDD son esenciales para bucles de copia de memoria eficientes en el código de inicialización del juego.

Lo que Falta Confirmar

  • Renderizado con ROMs reales: Necesitamos probar el emulador con ROMs reales (como Tetris) para verificar que los gráficos se renderizan correctamente. Esto validará que toda la cadena (CPU escribe VRAM → PPU lee VRAM → renderiza) funciona.
  • Paleta de colores: Aunque el framebuffer contiene índices 0-3, necesitamos aplicar la paleta BGP (Background Palette) en Python para ver los colores correctos.
  • Optimización de rendimiento: El renderizado actual decodifica píxel por píxel. Podríamos optimizar decodificando líneas completas de tiles y cacheándolas.

Hipótesis y Suposiciones

Asumimos que el cálculo del direccionamiento signed está correcto: cuando LCDC bit 4 = 0, el tile 0 está en 0x9000, lo que significa que los IDs se interpretan como int8_t y se calculan como tile_data_base + (signed_tile_id * 16). Esto necesita validación con ROMs reales.

Próximos Pasos

  • [ ] Probar el emulador con ROMs reales (Tetris, Mario) para verificar que los gráficos se renderizan correctamente
  • [ ] Implementar aplicación de paleta BGP en el renderer Python para mostrar colores correctos (actualmente solo índices 0-3)
  • [ ] Optimizar el renderizado de scanlines (decodificar líneas completas de tiles en lugar de píxel por píxel)
  • [ ] Implementar renderizado de Window (capa opaca sobre Background)
  • [ ] Implementar renderizado de Sprites (OBJ - Objects) para mostrar elementos móviles