⚠️ Clean-Room / Educational

This project is educational and Open Source. No code is copied from other emulators. Implementation based solely on technical documentation and permitted tests.

HALT Architecture (Phase 2): The Disruption Wake

Date:2025-12-20 StepID:0173 State: ✅ VERIFIED

Summary

The emulator was crashing due to incomplete implementation of the logicHALTin the main loop. Although the CPU correctly entered a low-power state, our Python orchestrator did not give it a chance to wake up on interrupts, creating adeadlockin which time moved forward but the CPU remained asleep eternally. This Step fixes the main loop so that while the CPU is inHALT, keep callingcpu.step()at each time cycle, allowing the CPU's internal interrupt mechanism to wake it up.

Hardware Concept: Awakening from HALT

A CPU in stateHALTIt's not dead, it's waiting. It remains connected to the interrupt bus. The actual hardware works like this:

  1. The CPU executesHALT. The PC stops moving.
  2. The rest of the system (PPU, Timer) continues to work.
  3. The PPU reaches V-Blank and raises a flag in the registryI.F.(Interrupt Flag).
  4. In itnext clock cycle, the CPU checks its interrupt pins. Detects that there is a pending interrupt ((IE & IF) != 0).
  5. CPU wakes up (halted = false), and ifIMEis active, processes the interrupt. If not, simply continue with the next instruction afterHALT.

The Problem with Our Previous Implementation

In Step 0172, we implemented "fast forward" when the CPU went into sleep.HALT. The code detected thatm_cycles == -1and time advanced to the end of the scanline. However, there was a critical problem:

if m_cycles == -1:
    # Advance time to the end of the scanline
    remaining_cycles = CYCLES_PER_SCANLINE - cycles_this_scanline
    cycles_this_scanline += remaining_cycles
    # ❌ PROBLEM: We don't call cpu.step() again

In the next iteration of the scanline loop, the CPUstill in statushalted. Our code does not call backcpu.step()to give him a chance to "wake up." Just go back to see that it is inHALTand time moves forward again. The CPU stays asleep forever. never runshandle_interrupts()which is the only mechanism that can wake her up.

The Analogy:We have put the worker to sleep, and instead of setting an alarm clock (the interruption), we simply advance the clock on the wall over and over again while he is still in bed.

Fountain:Pan Docs - HALT behavior, Interrupts, System Clock

Implementation

The fix is ​​simple but critical:we should always callcpu.step(), even when the CPU is inHALT. The methodstep()internally callshandle_interrupts(), which is the only mechanism that can wake up the CPU.

A. Correct the Main Loop inviboy.py

We replaced the logic inside the scanline loop so that it always callscpu.step()but manage time differently if you're inHALT:

while cycles_this_scanline< CYCLES_PER_SCANLINE:
    # Siempre llamamos a step() para que la CPU pueda procesar interrupciones y despertar.
    m_cycles = self._cpu.step() 
    
    # Verificar si la CPU está en HALT usando el flag (no el código de retorno)
    if self._use_cpp:
        is_halted = self._cpu.get_halted()
    else:
        is_halted = self._cpu.halted
    
    if is_halted:
        # Si la CPU está en HALT, no consumió ciclos de instrucción,
        # pero el tiempo debe avanzar. Avanzamos en la unidad mínima
        # de tiempo (1 M-Cycle = 4 T-Cycles).
        # cpu.step() ya se ha encargado de comprobar si debe despertar.
        t_cycles = 4 
    else:
        # Si no está en HALT, la instrucción consumió ciclos reales.
        t_cycles = m_cycles * 4
    
    cycles_this_scanline += t_cycles

Key changes:

  • We eliminate the logic ofm_cycles == -1. It is no longer necessary.
  • We always callcpu.step()in each iteration of the loop.
  • We use the flagcpu.halted(eithercpu.get_halted()in C++) to determine how to handle time.
  • If it is inHALT, we advance 4 T-Cycles (1 M-Cycle) per iteration, allowinghandle_interrupts()runs in each cycle.

B. Update C++ Code for Consistency

We modifyCPU::step()to return1rather-1when it is inHALT, since now we use the flaghalted_directly:

// ========== PHASE 2: HALT Management ==========
// If the CPU is in HALT, do not execute instructions
// We consume 1 M-Cycle (the clock keeps running) and return 1.
// The orchestrator must use the flag halted_ (get_halted()) to determine
// how to handle the time, not the return code.
if (halted_) {
    cycles_ += 1;
    return 1;  // HALT consumes 1 M-Cycle per tick (active wait)
}

We also update the opcode case0x76(HALT) to return1rather-1.

Modified Components

  • src/viboy.py: Fixed main loop inrun()so that you always callcpu.step()and use the flaghaltedto manage time.
  • src/core/cpp/CPU.cpp: Updated to return1rather-1when it is inHALT.

Affected Files

  • src/viboy.py- Fixed main loop to handle HALT correctly
  • src/core/cpp/CPU.cpp- Updated to return 1 instead of -1 in HALT
  • tests/test_emulator_halt_wakeup.py- New integration test to validate the complete HALT cycle

Tests and Verification

A new integration test was created that verifies the complete cycle:HALT→ Interrupt → Wake up.

Integration Test:test_halt_wakeup_integration

This test validates that:

  1. The CPU executesHALTand enters low consumption state.
  2. The PPU generates a V-Blank interrupt.
  3. CPU wakes up from stateHALTwhen it detects the interruption.
def test_halt_wakeup_integration():
    """
    Step 0173: Integration test that verifies the complete cycle:
    1. CPU executes HALT.
    2. PPU generates a V-Blank interrupt.
    3. The CPU wakes up from the HALT state.
    """
    # Initialize emulator
    viboy = Viboy(rom_path=None, use_cpp_core=True)
    cpu = viboy.get_cpu()
    mmu = viboy.get_mmu()
    
    # Configure interrupts
    mmu.write(0xFFFF, 0x01) # Enable V-Blank
    cpu.ime = True
    
    # Write program: HALT
    mmu.write(0x0100, 0x76) # HALT
    regs = viboy.registers
    regs.pc = 0x0100
    
    # Run HALT
    viboy.tick()
    assert cpu.get_halted() == 1, "CPU must be HALT"
    
    # Simulate run up to V-Blank
    for _ in range(CYCLES_PER_FRAME):
        viboy.tick()
        if cpu.get_halted() == 0:
            break
    
    # Verify that the CPU woke up
    assert cpu.get_halted() == 0, "CPU must have been woken up"

Command executed: pytest tests/test_emulator_halt_wakeup.py -v

Expected result:All tests pass, validating that the entire HALT cycle is working correctly.

Native Validation

This test validates the compiled C++ module and the full integration between CPU, MMU, PPU and the Python orchestrator.

Sources consulted

Educational Integrity

What I Understand Now

  • HALT is not "death":The CPU in HALT is still connected to the interrupt bus and must check for interrupts every clock cycle.
  • The orchestrator is critical:The main loop should always callcpu.step(), even when the CPU is in HALT, to allowhandle_interrupts()be executed.
  • The flag vs. the return code:It is better to use the flaghalteddirectly instead of special return codes, as it is more explicit and less error-prone.

What remains to be confirmed

  • Performance:Verify that the new loop does not introduce significant overhead when the CPU is in HALT.
  • Behavior with multiple interrupts:Validate that wakeup works correctly when multiple interrupts are pending.

Hypotheses and Assumptions

We assume that the behavior ofhandle_interrupts()in the C++ code is correct and that it wakes up the CPU when there are pending interrupts, even ifIMEis disabled. This is based on the Pan Docs documentation, but needs to be validated with additional tests.

Next Steps

  • [ ] Run the emulator with a real ROM (ex:tetris.gb) and verify that the Nintendo logo appears correctly
  • [ ] Validate that performance is not degraded with the new HALT loop
  • [ ] Add additional tests for edge cases (multiple interruptions, IME disabled, etc.)