Introduction

In the previous chapters, we laid the groundwork for our Game Boy emulator by implementing the CPU’s core instruction set and a basic Memory Management Unit. However, a real system isn’t just a CPU executing instructions sequentially. Hardware components like the display, timer, and input devices need to signal the CPU when an event occurs, and the CPU needs a way to respond. This is where interrupts come in.

This chapter will guide you through understanding and implementing the Game Boy’s interrupt system. More critically, we’ll build the main CPU execution loop, the heart of our emulator that orchestrates the CPU, PPU, and other components to run in a synchronized, cycle-accurate manner. By the end of this chapter, your emulator will have a functional, albeit basic, timing mechanism and the ability to respond to hardware events, bringing us closer to a fully interactive system.

Planning & Design

Building a Game Boy emulator requires careful synchronization of several independent “hardware” components. The CPU executes instructions, the Picture Processing Unit (PPU) renders graphics, the Timer keeps track of time, and the Audio Processing Unit (APU) generates sound. All these components operate based on the same underlying clock cycles.

The Game Boy Interrupt System

The Game Boy CPU (a custom Sharp SM83) uses a simple interrupt mechanism. When a hardware event occurs (e.g., the screen finishes drawing a frame, or the timer overflows), the corresponding hardware component “requests” an interrupt. The CPU then checks if that interrupt is enabled and if overall interrupts are master enabled. If both conditions are met, the CPU temporarily pauses its current execution, saves its current program counter (PC) to the stack, jumps to a specific memory address (the interrupt vector), and clears the master interrupt enable flag.

The Game Boy has five primary interrupt sources, each with its own vector address:

  • VBlank (Vertical Blanking Interrupt): Triggered when the PPU finishes drawing a frame and is in the vertical blanking period. This is crucial for synchronizing screen updates and game logic. Vector: 0x0040.
  • LCD Stat (LCD Status Interrupt): Triggered by various events related to the PPU’s LCD status register, such as specific scanline conditions. Vector: 0x0048.
  • Timer Interrupt: Triggered when the internal timer overflows. Vector: 0x0050.
  • Serial Interrupt: Triggered when a byte has been transferred via the serial cable. Vector: 0x0058.
  • Joypad Interrupt: Triggered when a button state changes (pressed or released). Vector: 0x0060.

To manage these, the CPU uses two key registers and one internal flag:

  • Interrupt Enable (IE) Register (0xFFFF): A byte where each bit corresponds to an interrupt source. If a bit is set, that interrupt source is enabled.
  • Interrupt Flag (IF) Register (0xFF0F): A byte where each bit corresponds to an interrupt source. Hardware components set these bits when an interrupt is requested. The CPU clears them when servicing.
  • Interrupt Master Enable (IME) Flag: An internal CPU flag (boolean) that globally enables or disables all interrupts. It can be set (EI instruction) or cleared (DI instruction).

๐Ÿง  Important: An interrupt is only serviced if its corresponding bit in IF is set, its corresponding bit in IE is set, AND the IME flag is true.

The Main Execution Loop

The Game Boy’s CPU runs at approximately 4.19 MHz. The PPU updates the screen at 60 frames per second (FPS). To accurately emulate this, our main loop must:

  1. Execute CPU instructions: Each instruction takes a specific number of CPU cycles.
  2. Advance other components: Based on the cycles consumed by the CPU, we must update the PPU, Timer, and APU by the same number of cycles.
  3. Check and handle interrupts: After each instruction (or at specific points in the loop), we check for pending interrupts.

A typical Game Boy frame consists of exactly 70224 CPU cycles. Our main loop will aim to complete this many cycles within one frame, updating the screen once per frame.

flowchart TD StartNode[Start Emulator] --> MainLoop(Main Loop) MainLoop --> ExecuteCPU[Execute CPU Instruction] ExecuteCPU --> UpdatePeripherals[Update Peripherals] UpdatePeripherals --> CheckInterrupts{Check Interrupts} CheckInterrupts -->|Yes| HandleInterrupt[Handle Interrupt] HandleInterrupt --> CheckFrame CheckInterrupts -->|No| CheckFrame CheckFrame{Frame Complete} -->|No| MainLoop CheckFrame -->|Yes| RenderFrame[Render Frame] RenderFrame --> MainLoop

The diagram above illustrates the continuous cycle of our emulator. The “Update PPU” and “Update Timer” steps will consume the same number of CPU cycles that the CPU instruction just took, ensuring all components stay synchronized.

Step-by-Step Implementation

We’ll start by enhancing our CpuState to manage interrupts, then create a basic Timer component, and finally assemble the main execution loop in an Emulator module.

1. Update CPU State for Interrupts

First, let’s add the necessary fields to our CpuState record in Src/GameBoy/Cpu.fs.

// Src/GameBoy/Cpu.fs

module GameBoy.Cpu

open GameBoy.Mmu

/// Represents the CPU's current state.
type CpuState =
    {
        Registers : Registers
        PC : uint16 // Program Counter
        SP : uint16 // Stack Pointer
        IME : bool // Interrupt Master Enable
        IE : byte // Interrupt Enable Register (0xFFFF)
        IF : byte // Interrupt Flag Register (0xFF0F)
        Halt : bool // CPU is halted
        Stop : bool // CPU is stopped
        CyclesThisInstruction : int // Cycles consumed by the last instruction
    }

// ... existing code ...

/// Initializes a new CPU state.
let init () : CpuState =
    {
        Registers = Registers.init ()
        PC = 0x0100us // Game Boy boots at 0x0100
        SP = 0xFFFEus
        IME = false // Interrupts disabled by default on boot
        IE = 0x00uy // No interrupts enabled initially
        IF = 0x00uy // No interrupts pending initially
        Halt = false
        Stop = false
        CyclesThisInstruction = 0
    }

// ... rest of Cpu.fs ...

Explanation:

  • We added IME (Interrupt Master Enable), IE (Interrupt Enable Register), and IF (Interrupt Flag Register) to CpuState.
  • IME is a boolean flag, IE and IF are bytes.
  • On initialization, IME is false (interrupts are globally disabled until an EI instruction is executed), and IE/IF are 0x00.

2. Implement Interrupt Handling Logic

Now, let’s add a function handleInterrupts to Cpu.fs that will check for and service interrupts. This function will return the updated CpuState and the number of cycles taken to handle the interrupt.

// Src/GameBoy/Cpu.fs

module GameBoy.Cpu

open GameBoy.Mmu

// ... CpuState and init function ...

/// Represents the status of the CPU after an operation.
type CpuResult = { State: CpuState; Cycles: int }

// ... existing opcode definitions and helper functions ...

/// Handles pending interrupts if IME is enabled and an interrupt is requested and enabled.
let handleInterrupts (cpu: CpuState) (mmu: MmuState) : CpuResult * MmuState =
    if cpu.IME then
        // Check each interrupt in priority order (VBlank -> LCD Stat -> Timer -> Serial -> Joypad)
        let pendingInterrupts = cpu.IF &&& cpu.IE

        // VBlank Interrupt (Bit 0)
        if (pendingInterrupts &&& 0x01uy) <> 0x00uy then
            let newMmu = Mmu.writeByte mmu 0xFF0F (cpu.IF &&& (0xFEuy)) // Clear VBlank bit in IF
            let newMmu' = Mmu.writeByte newMmu (cpu.SP - 1us) (byte (cpu.PC >>> 8)) // Push PC high byte
            let newMmu'' = Mmu.writeByte newMmu' (cpu.SP - 2us) (byte cpu.PC) // Push PC low byte
            ( { cpu with PC = 0x0040us; SP = cpu.SP - 2us; IME = false; IF = (cpu.IF &&& (0xFEuy)) }, newMmu'' ) // 5 cycles for interrupt handling
        // LCD Stat Interrupt (Bit 1)
        else if (pendingInterrupts &&& 0x02uy) <> 0x00uy then
            let newMmu = Mmu.writeByte mmu 0xFF0F (cpu.IF &&& (0xFDu)) // Clear LCD Stat bit in IF
            let newMmu' = Mmu.writeByte newMmu (cpu.SP - 1us) (byte (cpu.PC >>> 8))
            let newMmu'' = Mmu.writeByte newMmu' (cpu.SP - 2us) (byte cpu.PC)
            ( { cpu with PC = 0x0048us; SP = cpu.SP - 2us; IME = false; IF = (cpu.IF &&& (0xFDu)) }, newMmu'' )
        // Timer Interrupt (Bit 2)
        else if (pendingInterrupts &&& 0x04uy) <> 0x00uy then
            let newMmu = Mmu.writeByte mmu 0xFF0F (cpu.IF &&& (0xFBuy)) // Clear Timer bit in IF
            let newMmu' = Mmu.writeByte newMmu (cpu.SP - 1us) (byte (cpu.PC >>> 8))
            let newMmu'' = Mmu.writeByte newMmu' (cpu.SP - 2us) (byte cpu.PC)
            ( { cpu with PC = 0x0050us; SP = cpu.SP - 2us; IME = false; IF = (cpu.IF &&& (0xFBuy)) }, newMmu'' )
        // Serial Interrupt (Bit 3)
        else if (pendingInterrupts &&& 0x08uy) <> 0x00uy then
            let newMmu = Mmu.writeByte mmu 0xFF0F (cpu.IF &&& (0xF7uy)) // Clear Serial bit in IF
            let newMmu' = Mmu.writeByte newMmu (cpu.SP - 1us) (byte (cpu.PC >>> 8))
            let newMmu'' = Mmu.writeByte newMmu' (cpu.SP - 2us) (byte cpu.PC)
            ( { cpu with PC = 0x0058us; SP = cpu.SP - 2us; IME = false; IF = (cpu.IF &&& (0xF7uy)) }, newMmu'' )
        // Joypad Interrupt (Bit 4)
        else if (pendingInterrupts &&& 0x10uy) <> 0x00uy then
            let newMmu = Mmu.writeByte mmu 0xFF0F (cpu.IF &&& (0xEFuy)) // Clear Joypad bit in IF
            let newMmu' = Mmu.writeByte newMmu (cpu.SP - 1us) (byte (cpu.PC >>> 8))
            let newMmu'' = Mmu.writeByte newMmu' (cpu.SP - 2us) (byte cpu.PC)
            ( { cpu with PC = 0x0060us; SP = cpu.SP - 2us; IME = false; IF = (cpu.IF &&& (0xEFuy)) }, newMmu'' )
        else
            // No enabled and pending interrupts
            ( cpu, mmu )
    else
        // IME is disabled, no interrupts serviced
        ( cpu, mmu )

Explanation:

  • The handleInterrupts function takes the current CpuState and MmuState.
  • It first checks cpu.IME. If false, no interrupts are serviced.
  • It calculates pendingInterrupts by bitwise ANDing cpu.IF and cpu.IE. This gives us a byte where only the bits corresponding to both requested and enabled interrupts are set.
  • It then checks these bits in priority order (VBlank has highest priority).
  • If an interrupt is found:
    • The corresponding bit in IF is cleared (using a bitwise AND with a mask).
    • The current PC is pushed onto the stack (high byte then low byte). This requires writing to MMU.
    • The SP (Stack Pointer) is decremented by 2.
    • IME is set to false to prevent nested interrupts.
    • The PC is set to the interrupt’s vector address.
    • The updated CpuState and MmuState are returned.
  • If no enabled and pending interrupt is found, the original CpuState and MmuState are returned.

โšก Quick Note: The CpuResult type we defined previously is still relevant for Cpu.step, but handleInterrupts directly returns the updated CPU and MMU states because it’s tightly coupled with MMU writes for the stack.

3. Basic Timer Implementation

The Game Boy’s timer is a simple but important component. Let’s create Src/GameBoy/Timer.fs.

// Src/GameBoy/Timer.fs

module GameBoy.Timer

open GameBoy.Mmu
open GameBoy.Cpu // Needed to set IF bit

/// Represents the state of the Game Boy's internal timer.
type TimerState =
    {
        DivRegister : uint16 // Divider Register (0xFF04) - increments at 16384 Hz
        TimaRegister : byte // Timer Counter (0xFF05) - increments based on TAC
        TmaRegister : byte // Timer Modulo (0xFF06) - value TIMA is reloaded with
        TacRegister : byte // Timer Control (0xFF07) - controls timer enable and clock select
        InternalDivCounter : int // Internal counter for DIV register
        InternalTimaCounter : int // Internal counter for TIMA register
    }

/// Initializes a new Timer state.
let init () : TimerState =
    {
        DivRegister = 0x0000us
        TimaRegister = 0x00uy
        TmaRegister = 0x00uy
        TacRegister = 0x00uy
        InternalDivCounter = 0
        InternalTimaCounter = 0
    }

/// Calculates the clock speed for TIMA based on TAC.
let private getTimaClockSpeed (tac: byte) : int =
    match tac &&& 0x03uy with // Bits 0-1 of TAC
    | 0x00uy -> 1024 // 4096 Hz (CPU clock / 1024)
    | 0x01uy -> 16 // 262144 Hz (CPU clock / 16)
    | 0x02uy -> 64 // 65536 Hz (CPU clock / 64)
    | 0x03uy -> 256 // 16384 Hz (CPU clock / 256)
    | _ -> failwith "Invalid TAC clock select" // Should not happen

/// Updates the timer state based on CPU cycles.
let updateTimer (timer: TimerState) (mmu: MmuState) (cycles: int) : TimerState * MmuState =
    let mutable currentTimer = timer
    let mutable currentMmu = mmu

    // Update DIV register
    currentTimer <- { currentTimer with InternalDivCounter = currentTimer.InternalDivCounter + cycles }
    while currentTimer.InternalDivCounter >= 256 do // DIV increments every 256 CPU cycles (4.19MHz / 256 = 16384 Hz)
        currentTimer <- { currentTimer with InternalDivCounter = currentTimer.InternalDivCounter - 256 }
        currentTimer <- { currentTimer with DivRegister = currentTimer.DivRegister + 1us }

    // Update TIMA register if enabled
    if (currentTimer.TacRegister &&& 0x04uy) <> 0x00uy then // Bit 2 of TAC is Timer Enable
        let clockSpeed = getTimaClockSpeed currentTimer.TacRegister
        currentTimer <- { currentTimer with InternalTimaCounter = currentTimer.InternalTimaCounter + cycles }
        while currentTimer.InternalTimaCounter >= clockSpeed do
            currentTimer <- { currentTimer with InternalTimaCounter = currentTimer.InternalTimaCounter - clockSpeed }
            let newTima = currentTimer.TimaRegister + 1uy
            if newTima > 0xFFuy then // Overflow
                currentTimer <- { currentTimer with TimaRegister = currentTimer.TmaRegister } // Reload from TMA
                // Request Timer Interrupt (Bit 2 of IF)
                currentMmu <- Mmu.writeByte currentMmu 0xFF0F (currentMmu.Memory.[0xFF0F] ||| 0x04uy)
            else
                currentTimer <- { currentTimer with TimaRegister = newTima }
    
    (currentTimer, currentMmu)

Explanation:

  • TimerState holds the four timer registers (DIV, TIMA, TMA, TAC) and internal counters (InternalDivCounter, InternalTimaCounter) to track sub-cycle increments.
  • init() sets initial values.
  • getTimaClockSpeed translates the TAC register’s clock select bits into the number of CPU cycles per TIMA increment.
  • updateTimer is the core logic:
    • It takes cycles (from CPU) and updates InternalDivCounter and InternalTimaCounter.
    • DIV increments every 256 CPU cycles.
    • TIMA increments based on TAC’s clock speed. If TIMA overflows, it’s reloaded from TMA, and the Timer interrupt bit (bit 2) in the IF register (0xFF0F) is set.
    • Notice the use of mutable for currentTimer and currentMmu within updateTimer. While F# favors immutability, in performance-critical inner loops where state is frequently updated (like counters), using mutable locals can sometimes be a pragmatic choice, especially when the mutable state is confined to a single function. This balances functional purity with practical performance.

โšก Real-world insight: Accurate timer emulation is critical for many games, especially those with precise timing-based mechanics or sound effects. A slight deviation here can cause games to run too fast, too slow, or break entirely.

4. Placeholder PPU Update

For now, our PPU doesn’t render much, but it needs to track cycles and request the VBlank interrupt. Let’s add a simple updatePpu function to Src/GameBoy/Ppu.fs.

// Src/GameBoy/Ppu.fs

module GameBoy.Ppu

open GameBoy.Mmu
open GameBoy.Cpu // Needed to set IF bit

// ... existing PpuState and init ...

/// Total CPU cycles per frame (approx. 4.19 MHz / 60 FPS)
let private TOTAL_CYCLES_PER_FRAME = 70224

/// Updates the PPU state based on CPU cycles.
let updatePpu (ppu: PpuState) (mmu: MmuState) (cycles: int) : PpuState * MmuState =
    let mutable currentPpu = ppu
    let mutable currentMmu = mmu

    currentPpu <- { currentPpu with CyclesThisFrame = currentPpu.CyclesThisFrame + cycles }

    // Check for VBlank interrupt
    if currentPpu.CyclesThisFrame >= TOTAL_CYCLES_PER_FRAME then
        // Request VBlank interrupt (Bit 0 of IF)
        currentMmu <- Mmu.writeByte currentMmu 0xFF0F (currentMmu.Memory.[0xFF0F] ||| 0x01uy)
        currentPpu <- { currentPpu with CyclesThisFrame = currentPpu.CyclesThisFrame - TOTAL_CYCLES_PER_FRAME } // Reset for next frame
        // For now, we also clear the screen here (in a real PPU, this would be a more complex rendering step)
        // currentPpu <- { currentPpu with ScreenBuffer = Array.create (160 * 144) 0x00FF0000u } // Example: clear to green

    (currentPpu, currentMmu)

Explanation:

  • updatePpu increments CyclesThisFrame by the CPU cycles.
  • When CyclesThisFrame exceeds TOTAL_CYCLES_PER_FRAME (70224 cycles), it means a frame has finished.
  • At this point, the VBlank interrupt (bit 0) in IF (0xFF0F) is requested.
  • CyclesThisFrame is then decremented to account for the cycles “overflowing” into the next frame.

5. Integrate Interrupt Handling into Cpu.step

Now, we need to modify our Cpu.step function to check for interrupts before executing an instruction and after. This is crucial for handling cases where IME is enabled or disabled by an instruction.

// Src/GameBoy/Cpu.fs

module GameBoy.Cpu

open GameBoy.Mmu
open GameBoy.Registers

// ... CpuState, init, CpuResult, handleInterrupts ...

/// Executes a single CPU instruction.
let step (cpu: CpuState) (mmu: MmuState) : CpuResult * MmuState =
    let mutable currentCpu = cpu
    let mutable currentMmu = mmu
    let mutable cyclesConsumed = 0

    // Check for and handle interrupts if IME is enabled and pending
    // This check is performed before fetching an instruction
    let (cpuAfterInterruptCheck, mmuAfterInterruptCheck) = handleInterrupts currentCpu currentMmu
    if cpuAfterInterruptCheck.PC <> currentCpu.PC then // If PC changed, an interrupt was serviced
        // An interrupt was handled, 5 cycles are consumed for the ISR entry
        ( { State = cpuAfterInterruptCheck; Cycles = 5 }, mmuAfterInterruptCheck )
    else
        currentCpu <- cpuAfterInterruptCheck
        currentMmu <- mmuAfterInterruptCheck

        // If CPU is halted, just consume cycles until an interrupt occurs
        if currentCpu.Halt then
            // In halt mode, CPU consumes cycles but doesn't execute instructions
            // It waits for an interrupt to occur. We'll simply return 4 cycles for now.
            // A more accurate model would check IF/IE for pending interrupts every cycle.
            ( { State = currentCpu; Cycles = 4 }, currentMmu ) // Consume minimal cycles while halted
        else if currentCpu.Stop then
            // In stop mode, CPU and LCD are off. Only external reset or joypad interrupt can wake it.
            // For now, just consume 4 cycles.
            ( { State = currentCpu; Cycles = 4 }, currentMmu ) // Consume minimal cycles while stopped
        else
            // Fetch opcode
            let opcode = Mmu.readByte currentMmu currentCpu.PC
            currentCpu <- { currentCpu with PC = currentCpu.PC + 1us }

            // Decode and execute opcode
            let (nextCpu, nextMmu, instructionCycles) =
                match opcode with
                // 0x00 NOP
                | 0x00uy -> ({ currentCpu with CyclesThisInstruction = 4 }, currentMmu, 4)
                // 0x01 LD BC,d16
                | 0x01uy ->
                    let val1 = Mmu.readByte currentMmu currentCpu.PC
                    let val2 = Mmu.readByte currentMmu (currentCpu.PC + 1us)
                    let value = (uint16 val2 <<< 8) ||| (uint16 val1)
                    let newRegisters = Registers.setBC currentCpu.Registers value
                    ({ currentCpu with Registers = newRegisters; PC = currentCpu.PC + 2us; CyclesThisInstruction = 12 }, currentMmu, 12)
                // ... (add all other opcodes here) ...

                // 0xFB EI - Enable Interrupts
                | 0xFBuy -> ({ currentCpu with IME = true; CyclesThisInstruction = 4 }, currentMmu, 4)
                // 0xF3 DI - Disable Interrupts
                | 0xF3uy -> ({ currentCpu with IME = false; CyclesThisInstruction = 4 }, currentMmu, 4)

                // Example RST 0x00 (0xC7) - Call 0x0000
                | 0xC7uy ->
                    let newMmu = Mmu.writeByte currentMmu (currentCpu.SP - 1us) (byte (currentCpu.PC >>> 8))
                    let newMmu' = Mmu.writeByte newMmu (currentCpu.SP - 2us) (byte currentCpu.PC)
                    ({ currentCpu with PC = 0x0000us; SP = currentCpu.SP - 2us; CyclesThisInstruction = 16 }, newMmu', 16)

                // Unknown opcode
                | _ ->
                    printfn "Unknown opcode: 0x%02x at 0x%04x" opcode currentCpu.PC
                    failwithf "Unknown opcode: 0x%02x at 0x%04x" opcode currentCpu.PC

            cyclesConsumed <- instructionCycles // cyclesConsumed was 0 if no interrupt was handled
            ( { State = nextCpu; Cycles = cyclesConsumed }, nextMmu )

Explanation:

  • The Cpu.step function now includes a check for interrupts before fetching and executing an instruction. This handles cases where an interrupt occurs while the CPU is halted or waiting.
  • If handleInterrupts returns a CpuState with a changed PC, it means an interrupt was serviced. We then return immediately with the updated state and the 5 cycles overhead for the interrupt.
  • If no interrupt was serviced, we proceed to normal instruction execution.
  • We’ve added placeholder logic for Halt and Stop states, which consume a minimal number of cycles.
  • Crucially, we’ve added the EI (0xFB) and DI (0xF3) opcodes to set/clear the IME flag. These are essential for controlling the interrupt system.
  • The return type of step is CpuResult * MmuState to propagate the MMU state changes (e.g., from pushing to stack).

โš ๏ธ What can go wrong: The timing of interrupt checks and instruction execution is extremely subtle in real Game Boy hardware. Our current step function checks interrupts before an instruction. Some sources suggest checks might happen after an instruction, or even mid-instruction for certain events. If games behave strangely, this is a prime area to investigate for timing inaccuracies. Pan Docs is your friend here.

6. Create the Emulator Loop

Finally, let’s create a top-level Emulator module that orchestrates all components. Create Src/GameBoy/Emulator.fs.

// Src/GameBoy/Emulator.fs

module GameBoy.Emulator

open GameBoy.Cpu
open GameBoy.Mmu
open GameBoy.Ppu
open GameBoy.Timer

/// Represents the overall state of the Game Boy emulator.
type EmulatorState =
    {
        Cpu : CpuState
        Mmu : MmuState
        Ppu : PpuState
        Timer : TimerState
        IsRunning : bool
    }

/// Initializes a new Emulator state.
let init (romBytes: byte array) : EmulatorState =
    let initialMmu = Mmu.init romBytes
    {
        Cpu = Cpu.init ()
        Mmu = initialMmu
        Ppu = Ppu.init ()
        Timer = Timer.init ()
        IsRunning = true
    }

/// The main emulator loop, stepping all components.
let run (initialState: EmulatorState) =
    let mutable emulator = initialState

    let rec loop () =
        if emulator.IsRunning then
            // Step CPU and get cycles consumed
            let (cpuResult, newMmuFromCpu) = Cpu.step emulator.Cpu emulator.Mmu
            emulator <- { emulator with Cpu = cpuResult.State; Mmu = newMmuFromCpu }
            let cycles = cpuResult.Cycles

            // Update PPU based on CPU cycles
            let (newPpu, newMmuFromPpu) = Ppu.updatePpu emulator.Ppu emulator.Mmu cycles
            emulator <- { emulator with Ppu = newPpu; Mmu = newMmuFromPpu }

            // Update Timer based on CPU cycles
            let (newTimer, newMmuFromTimer) = Timer.updateTimer emulator.Timer emulator.Mmu cycles
            emulator <- { emulator with Timer = newTimer; Mmu = newMmuFromTimer }

            // Potentially add APU update here later

            // Recursive call for the next step
            loop ()
        else
            printfn "Emulator stopped."

    loop ()

Explanation:

  • EmulatorState is a record that aggregates the states of all major components.
  • init creates an initial EmulatorState by initializing each component and loading the ROM into the MMU.
  • run is a recursive function that forms our main emulator loop:
    • It calls Cpu.step to execute one CPU instruction (or handle an interrupt).
    • It then uses the cycles returned by Cpu.step to update Ppu.updatePpu and Timer.updateTimer. This ensures all components advance by the same amount of time.
    • The MmuState is passed around and updated by each component that modifies memory (like Cpu pushing to stack, Ppu writing to IF, Timer writing to IF). This reflects how hardware interacts with shared memory.
    • The loop continues as long as IsRunning is true.

๐Ÿ”ฅ Optimization / Pro tip: While a simple recursive loop is good for clarity, in a real production emulator, you might batch CPU instruction execution (e.g., run CPU for a fixed number of cycles, then update PPU/Timer once) or use a cycle-accurate scheduler to avoid deep recursion and overhead. For now, this step-by-step approach is sufficient.

7. Update Program.fs to use the Emulator

Finally, let’s modify our Program.fs to load a ROM and start the emulator.

// Program.fs

open System.IO
open GameBoy.Emulator

[<EntryPoint>]
let main argv =
    if argv.Length = 0 then
        printfn "Usage: dotnet run <rom_path>"
        1 // Indicate error
    else
        let romPath = argv.[0]
        if not (File.Exists romPath) then
            printfn "Error: ROM file not found at %s" romPath
            1 // Indicate error
        else
            printfn "Loading ROM: %s" romPath
            let romBytes = File.ReadAllBytes romPath
            printfn "ROM loaded. Size: %d bytes." romBytes.Length

            let emulator = Emulator.init romBytes
            printfn "Starting emulator..."
            Emulator.run emulator
            printfn "Emulator finished."
            0 // Indicate success

Explanation:

  • The main function now takes the ROM path as a command-line argument.
  • It loads the ROM bytes.
  • It initializes the EmulatorState with these bytes.
  • It then calls Emulator.run to start the main loop.

Testing & Verification

With the main loop and basic interrupt system in place, we can start observing the emulator’s behavior.

  1. Compile and Run: Navigate to your project root in the terminal and run:

    dotnet build
    dotnet run -- <path_to_your_rom.gb>
    

    For example:

    dotnet run -- ../roms/tetris.gb
    

    Initially, the emulator will likely just print “Emulator finished.” if IsRunning is never set to false, or crash on an unimplemented opcode. This is expected. The goal is to observe the internal state.

  2. Debugging Interrupts:

    • Set breakpoints: Place breakpoints in Cpu.handleInterrupts, Ppu.updatePpu (where VBlank is requested), and Timer.updateTimer (where Timer interrupt is requested).
    • Inspect IF and IE: During execution, inspect the values of emulator.Cpu.IF and emulator.Cpu.IE. You should see IF bits being set by Ppu.updatePpu and Timer.updateTimer.
    • Verify PC jump: When Cpu.handleInterrupts is triggered, check if cpu.PC correctly jumps to 0x0040 (for VBlank) or 0x0050 (for Timer) and if cpu.IME becomes false.
    • Test ROMs: Use simple Blargg’s test ROMs like cpu_instrs/individual/01-special.gb which specifically test DI/EI instructions and interrupt behavior. You’ll need to implement more opcodes for these to run fully, but you can trace the IME flag changes.
  3. Debugging Timing:

    • Add printfn statements inside Emulator.run to print cpuResult.Cycles, emulator.Ppu.CyclesThisFrame, and emulator.Timer.DivRegister periodically.
    • Observe how these values increment. Ppu.CyclesThisFrame should eventually reach 70224 and then reset, triggering a VBlank interrupt.
    • The Timer.DivRegister should increment much slower, at 16384 Hz.

Production Considerations

  • Cycle Accuracy: This is paramount for emulator accuracy. Every component must advance by the exact number of CPU cycles. Small discrepancies will lead to desynchronization, visual glitches, or broken game logic. Consult Pan Docs thoroughly for cycle counts of each instruction and hardware event.
  • Performance: The run loop is the most performance-critical part of your emulator. Every function called within this loop (Cpu.step, Ppu.updatePpu, Timer.updateTimer) needs to be as efficient as possible. F# record updates (with syntax) create new records, which can generate some garbage. For extreme performance, you might consider mutable fields or ref cells for very hot counters or state, but only if profiling proves it’s a bottleneck.
  • Memory Management: Continuously passing and updating MmuState (which contains the entire memory array) can be expensive if not handled carefully. F# records are immutable, so each Mmu.writeByte creates a new MmuState with a new (or copied) internal array. For better performance with large mutable data structures like memory, consider an Array<byte> wrapped in a ref cell or a Memory<byte>/Span<byte> if you want to optimize for .NET’s low-level memory access. For now, the current approach is acceptable for correctness.

Common Issues & Solutions

  1. Interrupts Not Firing:

    • Issue: The Game Boy screen is blank, or games relying on VBlank don’t progress.
    • Cause: IME might be false, IE might not have the correct bit set, or IF bits are never being set by the hardware components (PPU, Timer).
    • Solution: Debug Cpu.handleInterrupts. Check the values of cpu.IME, cpu.IE, and cpu.IF at runtime. Ensure Ppu.updatePpu and Timer.updateTimer correctly set the IF bits. Verify EI and DI opcodes are correctly implemented and being used by the ROM.
  2. Incorrect Timing / Game Runs Too Fast/Slow:

    • Issue: Game music plays too fast/slow, animations are off, or frame rate is inconsistent.
    • Cause: The cycles returned by Cpu.step are incorrect for some opcodes, or the updatePpu/updateTimer functions are not advancing by the correct amount of cycles.
    • Solution: Double-check all opcode cycle counts against reliable documentation (Pan Docs). Ensure TOTAL_CYCLES_PER_FRAME is accurate. Profile the run loop and verify that each component correctly consumes the cycles provided.
  3. Stack Overflow (F# run loop):

    • Issue: Your emulator crashes with a stack overflow exception after running for some time.
    • Cause: The loop () recursive call in Emulator.run is not tail-recursive. F# can optimize tail-recursive calls into iterative loops, but if any operation happens after the recursive call, it breaks tail recursion.
    • Solution: Ensure the loop () call is the very last operation in the loop function. If you need to do something after, restructure the loop or use an explicit while loop (which F# can translate from specific recursive patterns). For run as written, it should be tail-recursive if nothing else follows the loop () call. If it’s still an issue, you might need to convert it to an iterative while loop explicitly.

Summary & Next Step

In this chapter, you’ve equipped your Game Boy emulator with the crucial ability to handle hardware interrupts, allowing external components to signal the CPU. You’ve also built the foundational main execution loop, synchronizing the CPU, PPU, and Timer components by advancing them based on consumed CPU cycles. This is a significant leap towards a functional emulator, providing the timing backbone for all subsequent features.

While our PPU and Timer implementations are still basic, they now correctly interact with the CPU’s interrupt system. The emulator can conceptually “run” through instructions and advance internal hardware states in a synchronized manner.

Next, we’ll dive deeper into the Picture Processing Unit (PPU), implementing the logic to actually render the Game Boy’s graphics, from tiles and backgrounds to sprites and LCD control.

๐Ÿง  Check Your Understanding

  • What are the five main interrupt sources on the Game Boy, and what is their priority order?
  • Explain the purpose of the IME, IE, and IF registers/flags in the Game Boy’s interrupt system.
  • Why is cycle accuracy crucial for an emulator’s main execution loop, and what happens if it’s not maintained?

โšก Mini Task

  • Modify the Cpu.init function to start with IE and IF having specific bits set (e.g., IE = 0x01uy for VBlank, IF = 0x01uy for a pending VBlank). Then, debug and observe if handleInterrupts correctly services the VBlank interrupt on the first CPU step (assuming IME is enabled by an EI instruction in your ROM).

๐Ÿš€ Scenario

You’re running a Game Boy ROM, and the screen remains completely black, but the CPU appears to be executing instructions. You suspect a problem with the PPU’s ability to request VBlank interrupts, or the CPU’s ability to service them. Outline your debugging strategy, focusing on the components and registers introduced in this chapter, to pinpoint the issue.

๐Ÿ“Œ TL;DR

  • Interrupts allow hardware to signal the CPU, crucial for events like VBlank and Timer.
  • The Game Boy uses IME (Master Enable), IE (Enabled Masks), and IF (Flagged Requests) to manage interrupts.
  • The Main Execution Loop synchronizes CPU, PPU, and Timer by advancing them based on CPU cycles consumed.

๐Ÿง  Core Flow

  1. CPU executes an instruction, returning cycles consumed.
  2. PPU and Timer components advance their internal state by the same number of cycles.
  3. If PPU or Timer events occur, they set corresponding bits in the IF register.
  4. CPU checks IF and IE (if IME is true) to service any pending, enabled interrupts.

๐Ÿš€ Key Takeaway

Accurate cycle-based synchronization and robust interrupt handling are the backbone of any functional emulator, ensuring faithful reproduction of hardware behavior and enabling games to run as intended.


This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.

References