This project is educational and Open Source. No code is copied from other emulators. Implementation based solely on technical documentation and permitted tests.
Fix: Unlocking the Main Loop (Cycle Deadlock)
Summary
The emulator was running in the background ("Heartbeat" logs visible) but the window was not appearing or was frozen. The diagnosis revealed that `LY=0` remained constant, indicating that the PPU was not progressing. The root cause was that the scanline loop could get stuck if the CPU repeatedly returned 0 cycles, blocking PPU progress and therefore rendering.
Multiple layers of deadlock protection have been implemented: minimum cycle checking in `_execute_cpu_timer_only()`, safety counter in the scanline loop, and minimum forward forcing when zero or negative cycles are detected.
Hardware Concept
On the real Game Boy, the system clock runs continuously at 4.194304 MHz. Even when the CPU is in HALT (waiting for interrupts) state, the clock continues to run and the subsystems (PPU, Timer) continue to advance. The PPU processes exactly 456 T-Cycles per scanline, advancing from line 0 to 153 (144 visible lines + 10 V-Blank lines).
The problem:If the CPU returns 0 cycles (due to an unimplemented opcode, an error in the HALT state, or a bug in the C++ implementation), the scanline loop can never complete the necessary 456 cycles. This causes a deadlock where:
- PPU never advances (LY stays at 0)
- V-Blank is never reached (line 144)
- `render_frame()` is never called
- The window is never updated (`pygame.display.flip()` is never executed)
- Pygame event loop crashes
The solution:Implement multiple layers of protection that ensure that it is always advanced by at least a few cycles, even if the CPU returns 0. This simulates the behavior of real hardware where the clock never stops.
Implementation
Added three layers of deadlock protection in the main emulation loop:
1. Protection in `_execute_cpu_timer_only()` (C++ and Python)
Improved the `_execute_cpu_timer_only()` method to ensure that it always returns at least 16 T-Cycles (4 M-Cycles * 4), even if the CPU returns 0:
# CRITICAL: Ensure that we always return at least some cycles
# If for some reason t_cycles is 0, force minimum feed
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. Scanline Loop Protection
Added a safety counter and cycle check in the scanline loop to avoid infinite loops:
line_cycles = 0
safety_counter = 0 # Safety counter to avoid infinite loops
max_iterations = 1000 # Maximum limit of iterations per 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: Scanline loop exceeded {max_iterations} iterations. Forcing forward.")
line_cycles = CYCLES_PER_LINE
break
3. Data Type Verification in PPU C++
Verified that the `PPU::step(int cpu_cycles)` method accepts `int`, which is enough to handle the passed cycles (maximum 456 T-Cycles per scanline). No changes to C++ were required.
Design Decisions
- Forced minimum cycles:16 T-Cycles (4 M-Cycles) as a minimum was chosen because it is the time of a NOP instruction, the simplest possible case.
- Iteration limit:1000 iterations were established as the maximum limit per scanline. This allows up to 16,000 T-Cycles (much more than the 456 required) before forcing forward, leaving room for legitimate cases where many short instructions are executed.
- Warning Logging:Warning logging is maintained to diagnose problems, but is not used in the critical loop to avoid impacting performance.
Affected Files
src/viboy.py- Added deadlock protections in `run()` and `_execute_cpu_timer_only()` method
Tests and Verification
Verification was performed by manually running the emulator:
- Command executed:
python main.py roms/mario.gbc - Expected result:The window should appear and LY should advance from 0 to 153
- Diagnostic logs:"Heartbeat" logs should show LY increasing
Compiled C++ module validation:No C++ recompilation was required as the changes were only in Python. However, if the problem persists, it may be necessary to verify that the `.pyd` binary is up to date.
Note:This fix is preventative and should resolve the deadlock even if the C++ CPU has bugs that cause it to return 0 cycles. However, if the problem persists, it may indicate a deeper bug in the C++ CPU that requires additional investigation.
Sources consulted
- Pan Docs: System Clock, Timing, HALT behavior
- Pan Docs: LCD Timing, PPU Modes
Educational Integrity
What I Understand Now
- Deadlock in emulation:An infinite loop can occur if a component repeatedly returns 0 cycles, blocking other subsystems from progressing.
- Layered Protection:Multiple checks at different points in the code (execution method, scanline loop) provide redundancy and make the system more robust.
- Continuous clock:On real hardware, the clock never stops, even during HALT. The emulator must simulate this behavior.
What remains to be confirmed
- Root cause of the problem:If the C++ CPU is really returning 0 cycles, we need to identify why (opcode not implemented, bug in HALT, error in CPU state).
- Fix performance:Verify that the protections do not significantly affect the performance of the emulator.
Hypotheses and Assumptions
Main hypothesis:The C++ CPU may be returning 0 cycles under certain conditions (for example, when it encounters an unimplemented opcode or when it is in a poorly managed HALT state). The fix forces forward to avoid deadlock, but the root cause may require additional investigation.
Assumption about minimum cycles:We assume that 16 T-Cycles (4 M-Cycles) is a safe minimum advance. This is reasonable because it is the time of a NOP instruction, but in real hardware, even during HALT, the clock advances continuously.
Next Steps
- [ ] Verify that the window appears correctly after the fix
- [ ] Monitor logs to detect if the CPU returns 0 cycles (it would indicate a deeper bug)
- [ ] If the problem persists, investigate the C++ CPU implementation to identify the root cause
- [ ] Consider adding unit tests that verify that `_execute_cpu_timer_only()` never returns 0