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.
Modos PPU y Registro STAT
Resumen
Se implementó la máquina de estados de modos PPU (Mode 0, 1, 2, 3) que controla el ciclo de vida de cada línea de escaneo. La PPU ahora actualiza dinámicamente su modo según el timing de la línea, permitiendo que los juegos detecten cuándo es seguro acceder a la VRAM. Se integró el registro STAT (0xFF41) en la MMU para que los juegos puedan leer el modo PPU actual y configurar interrupciones basadas en modos. Esta implementación es crítica porque muchos juegos esperan que STAT cambie dinámicamente antes de continuar con la inicialización o el renderizado.
Concepto de Hardware
Los 4 Modos de la PPU: Cada línea de escaneo de 456 T-Cycles se divide en estados que indican qué está haciendo la PPU en cada momento:
- Mode 2 (OAM Search): Primeros ~80 ciclos. La PPU busca sprites en OAM (Object Attribute Memory) que intersectan con la línea actual. Durante este modo, la CPU está bloqueada de acceder a OAM (0xFE00-0xFE9F) para evitar conflictos de acceso.
- Mode 3 (Pixel Transfer): Siguientes ~172 ciclos (80-251). La PPU dibuja los píxeles de la línea leyendo tiles de VRAM y aplicando paletas. Durante este modo, la CPU está bloqueada de acceder a VRAM (0x8000-0x9FFF) y OAM para evitar corrupción de datos durante el renderizado.
- Mode 0 (H-Blank): Resto de la línea (~204 ciclos, 252-455). Descanso horizontal después de dibujar la línea. Durante este modo, la CPU puede acceder libremente a VRAM y OAM para actualizar tiles, tilemaps, sprites, etc.
- Mode 1 (V-Blank): Líneas 144-153 completas (10 líneas). Descanso vertical después de dibujar todas las líneas visibles. Durante este modo, la CPU puede acceder libremente a VRAM y OAM. Es el momento ideal para actualizar gráficos, ya que no hay renderizado activo.
Registro STAT (0xFF41): Los juegos leen constantemente este registro para saber en qué modo está la PPU:
- Bits 0-1: Modo PPU actual (00=H-Blank, 01=V-Blank, 10=OAM Search, 11=Pixel Transfer). De solo lectura.
- Bit 2: LYC=LY Coincidence Flag (LY == LYC). Indica si la línea actual coincide con LYC.
- Bit 3: Mode 0 (H-Blank) Interrupt Enable. Si está activo, genera interrupción cuando entra en H-Blank.
- Bit 4: Mode 1 (V-Blank) Interrupt Enable. Si está activo, genera interrupción cuando entra en V-Blank.
- Bit 5: Mode 2 (OAM Search) Interrupt Enable. Si está activo, genera interrupción cuando entra en OAM Search.
- Bit 6: LYC=LY Coincidence Interrupt Enable. Si está activo, genera interrupción cuando LY == LYC.
- Bit 7: No usado (siempre 0).
Problema identificado: Si el registro STAT no se actualiza dinámicamente, los juegos que hacen polling de STAT esperan eternamente a que la PPU entre en un modo seguro (H-Blank o V-Blank) antes de continuar. Esto causa que el juego se quede congelado con el LCD apagado (LCDC=0x00), esperando una señal que nunca llega.
Fuente: Pan Docs - LCD Status Register (STAT), PPU Modes, LCD Timing
Implementación
1. Máquina de Estados de Modos PPU
Se añadió el atributo mode a la clase PPU y se implementó el método _update_mode()
que calcula el modo actual según el punto en la línea (line_cycles) y LY:
def _update_mode(self) -> None:
"""Actualiza el modo PPU actual según el punto en la línea."""
# Si estamos en V-Blank (líneas 144-153), siempre Mode 1
if self.ly >= VBLANK_START:
self.mode = PPU_MODE_1_VBLANK
return
# Para líneas visibles (0-143), el modo depende de los ciclos dentro de la línea
line_cycles = self.clock
if line_cycles < MODE_2_CYCLES: # 0-79
self.mode = PPU_MODE_2_OAM_SEARCH
elif line_cycles < (MODE_2_CYCLES + MODE_3_CYCLES): # 80-251
self.mode = PPU_MODE_3_PIXEL_TRANSFER
else: # 252-455
self.mode = PPU_MODE_0_HBLANK
El método step() ahora llama a _update_mode() antes y después de procesar líneas completas
para asegurar que el modo siempre refleje el estado actual de la PPU.
2. Registro STAT en MMU
Se añadió interceptación de lectura/escritura del registro STAT (0xFF41) en la MMU:
- Lectura: Llama a
ppu.get_stat()que combina el modo actual (bits 0-1) con los bits configurables (2-6) guardados en memoria. - Escritura: Guarda solo los bits configurables (2-6) en memoria, ignorando los bits 0-1 que son de solo lectura.
El método get_stat() en la PPU lee directamente de mmu._memory[0xFF41] para evitar recursión
infinita (ya que la MMU llama a ppu.get_stat() cuando se lee 0xFF41).
3. Constantes de Modos y Timing
Se añadieron constantes para los modos PPU y los ciclos de cada modo:
PPU_MODE_0_HBLANK = 0 # H-Blank
PPU_MODE_1_VBLANK = 1 # V-Blank
PPU_MODE_2_OAM_SEARCH = 2 # OAM Search
PPU_MODE_3_PIXEL_TRANSFER = 3 # Pixel Transfer
MODE_2_CYCLES = 80 # OAM Search: primeros 80 ciclos
MODE_3_CYCLES = 172 # Pixel Transfer: siguientes 172 ciclos (80-251)
MODE_0_CYCLES = 204 # H-Blank: resto (252-455)
Componentes creados/modificados
- src/gpu/ppu.py (modificado):
- Añadido atributo
modepara almacenar el modo PPU actual - Método
_update_mode(): Calcula el modo según line_cycles y LY - Método
step(): Actualizado para llamar a_update_mode()antes y después de procesar líneas - Método
get_mode(): Devuelve el modo PPU actual - Método
get_stat(): Devuelve el valor del registro STAT combinando modo y bits configurables - Añadidas constantes de modos y timing
- Añadido atributo
- src/memory/mmu.py (modificado):
- Interceptación de lectura de STAT (0xFF41): Llama a
ppu.get_stat() - Interceptación de escritura de STAT (0xFF41): Guarda solo bits configurables (2-6), ignora bits 0-1
- Interceptación de lectura de STAT (0xFF41): Llama a
- tests/test_ppu_modes.py (nuevo):
- 7 tests completos que validan transiciones de modo, V-Blank, lectura/escritura de STAT
Decisiones de diseño
Acceso directo a _memory en get_stat(): Para evitar recursión infinita, get_stat() accede
directamente a mmu._memory[0xFF41] en lugar de usar mmu.read_byte(0xFF41). Esto es un detalle
de implementación necesario, pero está documentado explícitamente en el código.
Actualización de modo antes y después de procesar líneas: El modo se actualiza tanto antes de procesar
líneas completas (para reflejar el estado durante la línea) como después (para reflejar el estado residual si quedan
ciclos). Esto asegura que el modo siempre sea correcto, incluso cuando se procesan múltiples líneas en una sola llamada
a step().
Bits 0-1 de STAT son de solo lectura: Aunque el software puede intentar escribir en los bits 0-1, estos siempre reflejan el modo PPU actual y no pueden ser modificados. La MMU ignora estos bits al escribir, guardando solo los bits configurables (2-6).
Archivos Afectados
src/gpu/ppu.py(modificado):- Añadido atributo
modey constantes de modos/timing - Método
_update_mode(): Calcula el modo PPU actual - Método
step(): Actualizado para mantener el modo sincronizado - Métodos
get_mode()yget_stat(): Nuevos métodos públicos
- Añadido atributo
src/memory/mmu.py(modificado):- Interceptación de lectura/escritura de STAT (0xFF41)
tests/test_ppu_modes.py(nuevo):- 7 tests completos para validar modos PPU y registro STAT
Tests y Verificación
Tests Unitarios (pytest)
Comando ejecutado: pytest -q tests/test_ppu_modes.py
Entorno: Windows 10, Python 3.13.5
Resultado: 7 passed en 0.25s
Qué valida:
- Transiciones de modo durante línea visible: Verifica que los modos cambian correctamente de 2 → 3 → 0 según los ciclos dentro de la línea (0-79: Mode 2, 80-251: Mode 3, 252-455: Mode 0).
- Modo V-Blank: Verifica que las líneas 144-153 están siempre en Mode 1, independientemente de los ciclos.
- Reinicio de modo en nueva línea: Verifica que al inicio de cada nueva línea visible, el modo se reinicia a Mode 2.
- Lectura de STAT: Verifica que el registro STAT devuelve el modo correcto en bits 0-1.
- Escritura en STAT: Verifica que escribir en STAT preserva los bits configurables (2-6) pero ignora bits 0-1.
- Múltiples líneas: Verifica que el ciclo de modos se repite correctamente en múltiples líneas visibles.
Código del test (fragmento esencial):
def test_mode_transitions_visible_line(self) -> None:
"""Test: Los modos cambian correctamente durante una línea visible."""
mmu = MMU(None)
ppu = PPU(mmu)
mmu.set_ppu(ppu)
# Al inicio de la línea, debe ser Mode 2 (OAM Search)
assert ppu.get_mode() == PPU_MODE_2_OAM_SEARCH
# Avanzar 80 ciclos -> debe cambiar a Mode 3
ppu.step(80)
assert ppu.get_mode() == PPU_MODE_3_PIXEL_TRANSFER
# Avanzar 172 ciclos más -> debe cambiar a Mode 0
ppu.step(172)
assert ppu.get_mode() == PPU_MODE_0_HBLANK
Por qué este test demuestra algo del hardware: El hardware de la Game Boy divide cada línea de 456 ciclos en 3 modos distintos (OAM Search, Pixel Transfer, H-Blank) con timing específico. Este test verifica que la emulación respeta estos tiempos exactos, lo cual es crítico porque los juegos hacen polling de STAT para saber cuándo pueden acceder a VRAM de forma segura. Si el timing es incorrecto, los juegos pueden intentar escribir en VRAM durante Pixel Transfer, causando corrupción de datos o comportamiento impredecible.
Validación con ROM Real (Pendiente)
Próximo paso: Ejecutar una ROM real (Tetris DX, Pokémon Red, etc.) para verificar que el juego detecta correctamente los cambios de modo en STAT y puede continuar con la inicialización. Se espera que el juego encienda el LCD (LCDC=0x80 o 0x91) después de detectar que la PPU está en un modo seguro.
Fuentes Consultadas
- Pan Docs: LCD Status Register (STAT) - Descripción completa del registro STAT, bits 0-6, interrupciones basadas en modos
- Pan Docs: PPU Modes - Descripción de los 4 modos PPU (Mode 0, 1, 2, 3), timing de cada modo dentro de una línea
- Pan Docs: LCD Timing - Timing de scanlines (456 T-Cycles por línea), líneas visibles (0-143), V-Blank (144-153)
Integridad Educativa
Lo que Entiendo Ahora
- Máquina de Estados PPU: La PPU no es un componente estático que solo cuenta líneas. Es una máquina de estados que cambia dinámicamente entre 4 modos según el timing de la línea. Los juegos dependen críticamente de estos cambios de modo para saber cuándo pueden acceder a VRAM de forma segura.
- Registro STAT como interfaz de comunicación: STAT no es solo un registro de estado, es una interfaz de comunicación entre la CPU y la PPU. Los juegos leen STAT constantemente para sincronizarse con el renderizado y evitar escribir en VRAM durante Pixel Transfer (que causaría corrupción de datos).
- Bloqueo de acceso a VRAM: Durante Mode 3 (Pixel Transfer), la CPU está bloqueada de acceder a VRAM porque la PPU está leyendo activamente tiles y datos de paleta. Si la CPU intenta escribir durante este modo, puede causar artefactos visuales o comportamiento impredecible. El hardware real bloquea físicamente el acceso, pero en un emulador debemos simular esto actualizando STAT correctamente para que los juegos sepan cuándo no deben escribir.
Lo que Falta Confirmar
- LYC=LY Coincidence Flag (bit 2 de STAT): Aún no está implementado. Este bit se activa cuando LY == LYC (LY Compare, registro 0xFF45). Los juegos pueden usar esto para generar interrupciones en líneas específicas (efectos de scroll, splits de pantalla, etc.).
- Interrupciones basadas en modos STAT: Los bits 3-6 de STAT permiten habilitar interrupciones cuando la PPU entra en un modo específico. Aún no está implementado el sistema de interrupciones STAT, solo el registro es legible/escritable.
- Bloqueo real de acceso a VRAM: Actualmente solo actualizamos STAT, pero no bloqueamos físicamente el acceso a VRAM durante Mode 3. En hardware real, escribir en VRAM durante Pixel Transfer puede causar artefactos. En un emulador preciso, deberíamos detectar estos accesos y manejarlos apropiadamente (ignorar, retrasar, o generar artefactos visuales).
Hipótesis y Suposiciones
Timing de modos: Los tiempos exactos de cada modo (80, 172, 204 ciclos) están basados en Pan Docs, pero no he verificado con hardware real o test ROMs si estos tiempos son exactos o si hay variaciones. En hardware real, el timing puede variar ligeramente según el contenido renderizado (número de sprites, complejidad del tilemap, etc.), pero para un emulador básico, usar tiempos fijos es una aproximación razonable.
Actualización de modo durante step(): Actualizo el modo antes y después de procesar líneas completas para asegurar que siempre refleje el estado actual. Esto puede no ser exactamente cómo funciona el hardware real (que actualiza el modo continuamente), pero es una aproximación suficiente para que los juegos detecten los cambios de modo correctamente.
Próximos Pasos
- [ ] Validar con ROM real (Tetris DX, Pokémon Red) que el juego detecta correctamente los cambios de modo y enciende el LCD
- [ ] Implementar LYC=LY Coincidence Flag (bit 2 de STAT) para permitir interrupciones en líneas específicas
- [ ] Implementar interrupciones basadas en modos STAT (bits 3-6) para que los juegos puedan usar interrupciones H-Blank, OAM Search, etc.
- [ ] Considerar bloqueo real de acceso a VRAM durante Mode 3 (Pixel Transfer) para mayor precisión