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 (
EIinstruction) or cleared (DIinstruction).
๐ง 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:
- Execute CPU instructions: Each instruction takes a specific number of CPU cycles.
- 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.
- 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.
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), andIF(Interrupt Flag Register) toCpuState. IMEis a boolean flag,IEandIFare bytes.- On initialization,
IMEisfalse(interrupts are globally disabled until anEIinstruction is executed), andIE/IFare0x00.
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
handleInterruptsfunction takes the currentCpuStateandMmuState. - It first checks
cpu.IME. Iffalse, no interrupts are serviced. - It calculates
pendingInterruptsby bitwise ANDingcpu.IFandcpu.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
IFis cleared (using a bitwise AND with a mask). - The current
PCis pushed onto the stack (high byte then low byte). This requires writing to MMU. - The
SP(Stack Pointer) is decremented by 2. IMEis set tofalseto prevent nested interrupts.- The
PCis set to the interrupt’s vector address. - The updated
CpuStateandMmuStateare returned.
- The corresponding bit in
- If no enabled and pending interrupt is found, the original
CpuStateandMmuStateare 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:
TimerStateholds the four timer registers (DIV,TIMA,TMA,TAC) and internal counters (InternalDivCounter,InternalTimaCounter) to track sub-cycle increments.init()sets initial values.getTimaClockSpeedtranslates theTACregister’s clock select bits into the number of CPU cycles per TIMA increment.updateTimeris the core logic:- It takes
cycles(from CPU) and updatesInternalDivCounterandInternalTimaCounter. DIVincrements every 256 CPU cycles.TIMAincrements based onTAC’s clock speed. IfTIMAoverflows, it’s reloaded fromTMA, and the Timer interrupt bit (bit 2) in theIFregister (0xFF0F) is set.- Notice the use of
mutableforcurrentTimerandcurrentMmuwithinupdateTimer. 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.
- It takes
โก 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:
updatePpuincrementsCyclesThisFrameby the CPUcycles.- When
CyclesThisFrameexceedsTOTAL_CYCLES_PER_FRAME(70224 cycles), it means a frame has finished. - At this point, the VBlank interrupt (bit 0) in
IF(0xFF0F) is requested. CyclesThisFrameis 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.stepfunction 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
handleInterruptsreturns aCpuStatewith a changedPC, 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
HaltandStopstates, which consume a minimal number of cycles. - Crucially, we’ve added the
EI(0xFB) andDI(0xF3) opcodes to set/clear theIMEflag. These are essential for controlling the interrupt system. - The return type of
stepisCpuResult * MmuStateto 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:
EmulatorStateis a record that aggregates the states of all major components.initcreates an initialEmulatorStateby initializing each component and loading the ROM into the MMU.runis a recursive function that forms our main emulator loop:- It calls
Cpu.stepto execute one CPU instruction (or handle an interrupt). - It then uses the
cyclesreturned byCpu.stepto updatePpu.updatePpuandTimer.updateTimer. This ensures all components advance by the same amount of time. - The
MmuStateis passed around and updated by each component that modifies memory (likeCpupushing to stack,Ppuwriting toIF,Timerwriting toIF). This reflects how hardware interacts with shared memory. - The loop continues as long as
IsRunningis true.
- It calls
๐ฅ 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
mainfunction now takes the ROM path as a command-line argument. - It loads the ROM bytes.
- It initializes the
EmulatorStatewith these bytes. - It then calls
Emulator.runto start the main loop.
Testing & Verification
With the main loop and basic interrupt system in place, we can start observing the emulator’s behavior.
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.gbInitially, the emulator will likely just print “Emulator finished.” if
IsRunningis never set to false, or crash on an unimplemented opcode. This is expected. The goal is to observe the internal state.Debugging Interrupts:
- Set breakpoints: Place breakpoints in
Cpu.handleInterrupts,Ppu.updatePpu(where VBlank is requested), andTimer.updateTimer(where Timer interrupt is requested). - Inspect
IFandIE: During execution, inspect the values ofemulator.Cpu.IFandemulator.Cpu.IE. You should seeIFbits being set byPpu.updatePpuandTimer.updateTimer. - Verify
PCjump: WhenCpu.handleInterruptsis triggered, check ifcpu.PCcorrectly jumps to0x0040(for VBlank) or0x0050(for Timer) and ifcpu.IMEbecomesfalse. - Test ROMs: Use simple Blargg’s test ROMs like
cpu_instrs/individual/01-special.gbwhich specifically testDI/EIinstructions and interrupt behavior. You’ll need to implement more opcodes for these to run fully, but you can trace theIMEflag changes.
- Set breakpoints: Place breakpoints in
Debugging Timing:
- Add
printfnstatements insideEmulator.runto printcpuResult.Cycles,emulator.Ppu.CyclesThisFrame, andemulator.Timer.DivRegisterperiodically. - Observe how these values increment.
Ppu.CyclesThisFrameshould eventually reach70224and then reset, triggering a VBlank interrupt. - The
Timer.DivRegistershould increment much slower, at 16384 Hz.
- Add
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
runloop 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 (withsyntax) create new records, which can generate some garbage. For extreme performance, you might considermutablefields orrefcells 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 eachMmu.writeBytecreates a newMmuStatewith a new (or copied) internal array. For better performance with large mutable data structures like memory, consider anArray<byte>wrapped in arefcell or aMemory<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
Interrupts Not Firing:
- Issue: The Game Boy screen is blank, or games relying on VBlank don’t progress.
- Cause:
IMEmight befalse,IEmight not have the correct bit set, orIFbits are never being set by the hardware components (PPU, Timer). - Solution: Debug
Cpu.handleInterrupts. Check the values ofcpu.IME,cpu.IE, andcpu.IFat runtime. EnsurePpu.updatePpuandTimer.updateTimercorrectly set theIFbits. VerifyEIandDIopcodes are correctly implemented and being used by the ROM.
Incorrect Timing / Game Runs Too Fast/Slow:
- Issue: Game music plays too fast/slow, animations are off, or frame rate is inconsistent.
- Cause: The
cyclesreturned byCpu.stepare incorrect for some opcodes, or theupdatePpu/updateTimerfunctions 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_FRAMEis accurate. Profile therunloop and verify that each component correctly consumes thecyclesprovided.
Stack Overflow (F#
runloop):- Issue: Your emulator crashes with a stack overflow exception after running for some time.
- Cause: The
loop ()recursive call inEmulator.runis 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 theloopfunction. If you need to do something after, restructure the loop or use an explicitwhileloop (which F# can translate from specific recursive patterns). Forrunas written, it should be tail-recursive if nothing else follows theloop ()call. If it’s still an issue, you might need to convert it to an iterativewhileloop 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, andIFregisters/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.initfunction to start withIEandIFhaving specific bits set (e.g.,IE = 0x01uyfor VBlank,IF = 0x01uyfor a pending VBlank). Then, debug and observe ifhandleInterruptscorrectly services the VBlank interrupt on the first CPU step (assumingIMEis enabled by anEIinstruction 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), andIF(Flagged Requests) to manage interrupts. - The Main Execution Loop synchronizes CPU, PPU, and Timer by advancing them based on CPU cycles consumed.
๐ง Core Flow
- CPU executes an instruction, returning cycles consumed.
- PPU and Timer components advance their internal state by the same number of cycles.
- If PPU or Timer events occur, they set corresponding bits in the
IFregister. - CPU checks
IFandIE(ifIMEis 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
- Pan Docs: https://gbdev.io/pandocs/
- Game Boy CPU (SM83) instruction set: https://gbdev.io/pandocs/CPU_Instruction_Set.html
- Game Boy Interrupts: https://gbdev.io/pandocs/Interrupts.html
- Blargg’s Game Boy Test ROMs (often found in emulator source repositories, e.g., via a quick search for “Blargg Game Boy test ROMs GitHub”)
- F# Language Reference: https://learn.microsoft.com/en-us/dotnet/fsharp/
- .NET SDK: https://dotnet.microsoft.com/download