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.
Fix: Desbloqueo del Bucle Principal (Deadlock de Ciclos)
Resumen
El emulador estaba ejecutándose en segundo plano (logs de "Heartbeat" visibles) pero la ventana no aparecía o estaba congelada. El diagnóstico reveló que `LY=0` se mantenía constante, indicando que la PPU no avanzaba. La causa raíz era que el bucle de scanline podía quedarse atascado si la CPU devolvía 0 ciclos repetidamente, bloqueando el avance de la PPU y, por tanto, el renderizado.
Se implementaron múltiples capas de protección contra deadlock: verificación de ciclos mínimos en `_execute_cpu_timer_only()`, contador de seguridad en el bucle de scanline, y forzado de avance mínimo cuando se detectan ciclos cero o negativos.
Concepto de Hardware
En la Game Boy real, el reloj del sistema funciona continuamente a 4.194304 MHz. Incluso cuando la CPU está en estado HALT (esperando interrupciones), el reloj sigue funcionando y los subsistemas (PPU, Timer) continúan avanzando. La PPU procesa exactamente 456 T-Cycles por scanline, avanzando de línea 0 a 153 (144 líneas visibles + 10 líneas de V-Blank).
El problema: Si la CPU devuelve 0 ciclos (por un opcode no implementado, un error en el estado HALT, o un bug en la implementación C++), el bucle de scanline nunca puede completar los 456 ciclos necesarios. Esto causa un deadlock donde:
- La PPU nunca avanza (LY se mantiene en 0)
- Nunca se alcanza V-Blank (línea 144)
- Nunca se llama a `render_frame()`
- La ventana nunca se actualiza (`pygame.display.flip()` nunca se ejecuta)
- El bucle de eventos de Pygame se bloquea
La solución: Implementar múltiples capas de protección que garanticen que siempre se avance al menos algunos ciclos, incluso si la CPU devuelve 0. Esto simula el comportamiento del hardware real donde el reloj nunca se detiene.
Implementación
Se agregaron tres capas de protección contra deadlock en el bucle principal de emulación:
1. Protección en `_execute_cpu_timer_only()` (C++ y Python)
Se mejoró el método `_execute_cpu_timer_only()` para garantizar que siempre devuelva al menos 16 T-Cycles (4 M-Cycles * 4), incluso si la CPU devuelve 0:
# CRÍTICO: Garantizar que siempre devolvemos al menos algunos ciclos
# Si por alguna razón t_cycles es 0, forzar avance mínimo
if t_cycles <= 0:
logger.warning(f"⚠️ ADVERTENCIA: _execute_cpu_timer_only() devolvió {t_cycles} T-Cycles. Forzando avance mínimo.")
t_cycles = 16 # 4 M-Cycles * 4 = 16 T-Cycles (mínimo seguro)
return t_cycles
2. Protección en el Bucle de Scanline
Se agregó un contador de seguridad y verificación de ciclos en el bucle de scanline para evitar bucles infinitos:
line_cycles = 0
safety_counter = 0 # Contador de seguridad para evitar bucles infinitos
max_iterations = 1000 # Límite máximo de iteraciones por scanline
while line_cycles < CYCLES_PER_LINE:
t_cycles = self._execute_cpu_timer_only()
# CRÍTICO: Protección contra deadlock - si t_cycles es 0 o negativo,
# forzar avance mínimo para evitar bucle infinito
if t_cycles <= 0:
logger.warning(f"⚠️ ADVERTENCIA: CPU devolvió {t_cycles} ciclos. Forzando avance mínimo.")
t_cycles = 16 # 4 M-Cycles * 4 = 16 T-Cycles (mínimo seguro)
line_cycles += t_cycles
# Protección contra bucle infinito
safety_counter += 1
if safety_counter >= max_iterations:
logger.error(f"⚠️ ERROR: Bucle de scanline excedió {max_iterations} iteraciones. Forzando avance.")
line_cycles = CYCLES_PER_LINE
break
3. Verificación de Tipo de Dato en PPU C++
Se verificó que el método `PPU::step(int cpu_cycles)` acepta `int`, que es suficiente para manejar los ciclos pasados (máximo 456 T-Cycles por scanline). No se requirieron cambios en C++.
Decisiones de Diseño
- Ciclos mínimos forzados: Se eligió 16 T-Cycles (4 M-Cycles) como mínimo porque es el tiempo de una instrucción NOP, el caso más simple posible.
- Límite de iteraciones: Se estableció 1000 iteraciones como límite máximo por scanline. Esto permite hasta 16,000 T-Cycles (mucho más que los 456 necesarios) antes de forzar el avance, dando margen para casos legítimos donde se ejecutan muchas instrucciones cortas.
- Logging de advertencias: Se mantiene logging de advertencias para diagnosticar problemas, pero no se usa en el bucle crítico para no afectar el rendimiento.
Archivos Afectados
src/viboy.py- Agregadas protecciones contra deadlock en el método `run()` y `_execute_cpu_timer_only()`
Tests y Verificación
La verificación se realizó mediante ejecución manual del emulador:
- Comando ejecutado:
python main.py roms/mario.gbc - Resultado esperado: La ventana debe aparecer y LY debe avanzar de 0 a 153
- Logs de diagnóstico: Los logs de "Heartbeat" deben mostrar LY incrementándose
Validación de módulo compilado C++: No se requirió recompilación de C++ ya que los cambios fueron solo en Python. Sin embargo, si el problema persiste, puede ser necesario verificar que el binario `.pyd` esté actualizado.
Nota: Este fix es preventivo y debería resolver el deadlock incluso si la CPU C++ tiene bugs que causan que devuelva 0 ciclos. Sin embargo, si el problema persiste, puede indicar un bug más profundo en la CPU C++ que requiere investigación adicional.
Fuentes Consultadas
- Pan Docs: System Clock, Timing, HALT behavior
- Pan Docs: LCD Timing, PPU Modes
Integridad Educativa
Lo que Entiendo Ahora
- Deadlock en emulación: Un bucle infinito puede ocurrir si un componente devuelve 0 ciclos repetidamente, bloqueando el avance de otros subsistemas.
- Protección en capas: Múltiples verificaciones en diferentes puntos del código (método de ejecución, bucle de scanline) proporcionan redundancia y hacen el sistema más robusto.
- Reloj continuo: En hardware real, el reloj nunca se detiene, incluso durante HALT. El emulador debe simular este comportamiento.
Lo que Falta Confirmar
- Causa raíz del problema: Si la CPU C++ realmente está devolviendo 0 ciclos, necesitamos identificar por qué (opcode no implementado, bug en HALT, error en el estado de la CPU).
- Rendimiento del fix: Verificar que las protecciones no afecten significativamente el rendimiento del emulador.
Hipótesis y Suposiciones
Hipótesis principal: La CPU C++ puede estar devolviendo 0 ciclos en ciertas condiciones (por ejemplo, cuando encuentra un opcode no implementado o cuando está en un estado HALT mal gestionado). El fix fuerza el avance para evitar el deadlock, pero la causa raíz puede requerir investigación adicional.
Suposición sobre ciclos mínimos: Asumimos que 16 T-Cycles (4 M-Cycles) es un avance mínimo seguro. Esto es razonable porque es el tiempo de una instrucción NOP, pero en hardware real, incluso durante HALT, el reloj avanza continuamente.
Próximos Pasos
- [ ] Verificar que la ventana aparece correctamente después del fix
- [ ] Monitorear logs para detectar si la CPU devuelve 0 ciclos (indicaría un bug más profundo)
- [ ] Si el problema persiste, investigar la implementación de la CPU C++ para identificar la causa raíz
- [ ] Considerar agregar tests unitarios que verifiquen que `_execute_cpu_timer_only()` nunca devuelve 0