In this chapter, we transition from a theoretical CPU to a system capable of loading and preparing a Game Boy game for execution. This is the pivotal moment where your emulator begins to take on a tangible form, moving from abstract concepts to processing actual game data. We’ll implement the crucial functionality of loading a Game Boy ROM file into our Memory Management Unit (MMU) and setting up the initial state of the CPU, mirroring what happens after the Game Boy’s internal boot ROM completes.

By the end of this milestone, your emulator will be capable of reading a .gb file, storing its contents in memory, and initializing the CPU’s registers to the correct post-boot state. While you won’t see a game running yet, this is the foundational step that enables all subsequent instruction execution and brings us closer to a fully functional Game Boy.

Project Overview

Our overarching goal is to build a functional Game Boy emulator in F#. This involves accurately replicating the behavior of the Game Boy’s CPU (SM83), Memory Management Unit (MMU), Picture Processing Unit (PPU), and other components. Each chapter builds incrementally, adding critical pieces to achieve a working system.

This chapter specifically focuses on the initial system bootstrap. We’re tackling how the emulator first ingests game data (the ROM) and sets up the CPU’s internal state to mimic the power-on sequence of a real Game Boy, but with a practical shortcut to speed up development.

Tech Stack

For this project, we are leveraging the following core technologies:

  • F# (F# 8.0): The primary programming language, chosen for its strong type system, functional paradigms, and excellent interoperability with .NET.
  • .NET SDK (8.0.x): The runtime and development platform providing core libraries and tools. This includes the F# compiler and runtime.
  • SDL2 (via .NET bindings like SDL2-CS or Veldrid): While not directly used in this chapter, this will be our chosen cross-platform graphics and input library for later chapters. We’ll set up the bindings when we reach the PPU implementation.

๐Ÿ“Œ Key Idea: Using a modern .NET SDK ensures we benefit from performance improvements and up-to-date tooling, while F# provides the expressive power for modeling complex hardware states immutably.

Planning & Design

To get a Game Boy game running, we need to handle two primary concerns in this chapter:

  1. ROM Loading: Reading the .gb file from the disk and placing its data into the MMU’s address space. Game Boy ROMs are essentially just byte arrays that contain the game’s executable code and data.
  2. Initial CPU State: The real Game Boy has a small, internal 256-byte boot ROM that runs when the device powers on. This boot ROM initializes various hardware components (like sound, video, and memory) and then transfers control to the cartridge ROM at address 0x100. For simplicity in development, and because we’re not yet emulating the PPU or APU, we will skip emulating the boot ROM and instead directly initialize the CPU’s registers and certain MMU registers to the state they would be in after the boot ROM has finished. This allows us to jump straight into game code.

Why skip the boot ROM? Emulating the boot ROM requires accurate PPU and APU emulation to even display the Nintendo logo and play its sound. By directly setting the post-boot state, we can focus on CPU and MMU logic first, getting to a runnable state faster. This is a common practice in emulator development for iterative progress.

Game Boy Memory Map & ROM Integration

Let’s quickly revisit how the ROM fits into the Game Boy’s memory map, which we discussed in Chapter 3:

  • 0x0000 - 0x3FFF: ROM Bank 0 (fixed, always accessible). This is where the initial game code resides.
  • 0x4000 - 0x7FFF: Switchable ROM Banks (controlled by Memory Bank Controllers, or MBCs, which we’ll cover later).

For now, we’ll load the entire ROM into an internal byte array within our MMU and ensure that at least ROM Bank 0 is copied into the MMUState.Memory array for immediate access.

Architecture Changes

We’ll primarily extend our existing MMU module to include ROM loading capabilities and modify the CPU module to allow for initial state setup. The main application will orchestrate these steps.

flowchart LR User[User] --> MainApp[Main Application] MainApp --> FileSystem[File System] FileSystem --> ROM_File[Game Boy ROM] ROM_File --> MMU[MMU] MMU --> CPU[CPU] CPU --> Initial_State[Set Initial Registers] subgraph Emulator["Emulator Components"] MMU CPU end

This diagram illustrates the data flow: the user provides a ROM path, the Main Application loads it via the File System, the ROM data initializes the MMU, and finally, the CPU is initialized with its post-boot register values. The MMU and CPU are the core Emulator Components.

Step-by-Step Implementation

We’ll start by enhancing our MMU to handle ROM data and then configure our CPU’s initial state.

1. Update MMU to Load ROMs

First, let’s modify src/GameBoyEmulator/MMU.fs to include a way to load a byte array representing the cartridge ROM.

File: src/GameBoyEmulator/MMU.fs

// src/GameBoyEmulator/MMU.fs
module MMU

open System
open System.IO

/// Represents the Game Boy's memory.
type MMUState = {
    /// 64KB main memory array
    Memory : byte array
    /// Cartridge ROM data (full ROM, potentially multiple banks)
    Rom : byte array
}

/// Creates a new MMU state with an empty memory map and loads a ROM.
/// It initializes the memory to a post-boot ROM state.
let create (romPath: string) : MMUState =
    let memory = Array.zeroCreate 0x10000 // 64KB (0x0000 to 0xFFFF)

    let romData =
        if File.Exists(romPath) then
            File.ReadAllBytes(romPath)
        else
            // โš ๏ธ What can go wrong: Incorrect path or missing file.
            // This failwithf will stop execution and provide a clear error.
            failwithf "ROM file not found at %s" romPath

    // Copy ROM Bank 0 (0x0000-0x3FFF) into main memory
    // For now, we only copy the first 16KB. Memory Bank Controllers (MBCs)
    // will handle larger ROMs and bank switching in a future chapter.
    let romBank0Size = min romData.Length 0x4000 // Max 16KB for Bank 0
    Array.blit romData 0 memory 0x0000 romBank0Size

    // ๐Ÿง  Important: These values are critical for skipping the boot ROM.
    // They represent the state of various I/O and hardware registers
    // after the official Game Boy boot ROM has completed its work.
    // These are derived from Game Boy technical documentation (e.g., Pan Docs).
    memory.[0xFF05] <- 0x00uy // TIMA - Timer counter
    memory.[0xFF06] <- 0x00uy // TMA - Timer modulo
    memory.[0xFF07] <- 0x00uy // TAC - Timer control
    memory.[0xFF10] <- 0x80uy // NR10 - Channel 1 sweep
    memory.[0xFF11] <- 0xBFuy // NR11 - Channel 1 length/duty
    memory.[0xFF12] <- 0xF3uy // NR12 - Channel 1 volume/envelope
    memory.[0xFF14] <- 0xBFuy // NR14 - Channel 1 frequency hi
    memory.[0xFF16] <- 0x3Fuy // NR21 - Channel 2 length/duty
    memory.[0xFF17] <- 0x00uy // NR22 - Channel 2 volume/envelope
    memory.[0xFF19] <- 0xBFuy // NR24 - Channel 2 frequency hi
    memory.[0xFF1A] <- 0x7Fuy // NR30 - Channel 3 DAC enable
    memory.[0xFF1B] <- 0xFFuy // NR31 - Channel 3 length
    memory.[0xFF1C] <- 0x9Fuy // NR32 - Channel 3 volume
    memory.[0xFF1E] <- 0xBFuy // NR33 - Channel 3 frequency hi
    memory.[0xFF20] <- 0xFFuy // NR41 - Channel 4 length
    memory.[0xFF21] <- 0x00uy // NR42 - Channel 4 volume/envelope
    memory.[0xFF22] <- 0x00uy // NR43 - Channel 4 polynomial counter
    memory.[0xFF23] <- 0xBFuy // NR44 - Channel 4 frequency hi
    memory.[0xFF24] <- 0x77uy // NR50 - Channel control (volume & VIN)
    memory.[0xFF25] <- 0xF3uy // NR51 - Selection of sound output terminal
    memory.[0xFF26] <- 0xF1uy // NR52 - Sound on/off (DMG specific value)
    memory.[0xFF40] <- 0x91uy // LCDC - LCD Control (important for PPU)
    memory.[0xFF42] <- 0x00uy // SCY - Scroll Y
    memory.[0xFF43] <- 0x00uy // SCX - Scroll X
    memory.[0xFF45] <- 0x00uy // LYC - LY Compare
    memory.[0xFF47] <- 0xFCuy // BGP - BG Palette Data
    memory.[0xFF48] <- 0xFFuy // OBP0 - Obj Palette 0 Data
    memory.[0xFF49] <- 0xFFuy // OBP1 - Obj Palette 1 Data
    memory.[0xFF4A] <- 0x00uy // WY - Window Y
    memory.[0xFF4B] <- 0x00uy // WX - Window X
    memory.[0xFFFF] <- 0x00uy // IE - Interrupt Enable Register

    { Memory = memory; Rom = romData }

/// Reads a byte from memory at the given address.
let readByte (mmu: MMUState) (address: uint16) : byte =
    // โšก Quick Note: Direct array access is highly performant.
    // In later chapters, this will involve more complex logic for I/O registers
    // and memory bank controllers, but the fundamental access pattern remains.
    mmu.Memory.[int address]

/// Writes a byte to memory at the given address.
let writeByte (mmu: MMUState) (address: uint16) (value: byte) : MMUState =
    // โšก Real-world insight: While F# encourages immutability,
    // emulators often deal with large, mutable state (like memory).
    // Modifying a byte array in-place and returning the same record
    // is a pragmatic compromise for performance in such scenarios.
    mmu.Memory.[int address] <- value
    mmu // Return the same MMUState record (as only its internal mutable array changed)

Explanation of Changes:

  • MMUState record: We added a Rom : byte array field to MMUState. This stores the entire cartridge ROM data, which can be larger than 64KB for games with multiple memory banks.
  • create function:
    • Now takes romPath: string as an argument to specify the ROM file to load.
    • It reads all bytes from the specified ROM file using File.ReadAllBytes.
    • Includes a failwithf call for robust error handling if the file isn’t found, preventing unexpected crashes.
    • Array.blit is used to copy the first 16KB (ROM Bank 0, from 0x0000 to 0x3FFF) of the loaded ROM directly into the Memory array. This makes the initial game code immediately accessible to the CPU.
    • Post-Boot ROM Initialization: A series of memory.[address] <- value lines are added. These set specific I/O registers (like those for sound and LCD control) to their default values after the Game Boy’s internal boot ROM has run. This is crucial for skipping the boot ROM and jumping directly into game execution. These values are meticulously derived from Game Boy technical documentation like the Pan Docs.
  • readByte and writeByte: These functions now operate on the MMUState record, accessing its Memory array. Their core logic remains direct array access, which is essential for performance.

2. Set Initial CPU State

Next, we need to initialize our CPU’s registers to the state expected after the boot ROM.

File: src/GameBoyEmulator/CPU.fs

// src/GameBoyEmulator/CPU.fs
module CPU

open System

/// Represents the CPU's 8-bit registers (A, F, B, C, D, E, H, L).
type Registers = {
    A : byte
    F : byte // Flag register
    B : byte
    C : byte
    D : byte
    E : byte
    H : byte
    L : byte
}

/// Represents the CPU's 16-bit registers (SP, PC).
type SpecialRegisters = {
    SP : uint16 // Stack Pointer
    PC : uint16 // Program Counter
}

/// Represents the CPU's current state.
type CPUState = {
    Registers : Registers
    SpecialRegisters : SpecialRegisters
    // Add more fields as needed, e.g., interrupt enable flags, clock cycles
}

/// Creates a new CPU state, initialized to the post-boot ROM values.
let create () : CPUState =
    // ๐Ÿง  Important: These are the CPU register values for the original Game Boy (DMG)
    // after its internal boot ROM has finished execution.
    // Setting these correctly is vital for games to start properly without
    // emulating the boot ROM itself.
    {
        Registers = {
            A = 0x01uy // Accumulator
            F = 0xB0uy // Flags (Z=1, N=0, H=1, C=1 - post-boot ROM state)
            B = 0x00uy
            C = 0x13uy
            D = 0x00uy
            E = 0xD8uy
            H = 0x01uy
            L = 0x4Buy
        }
        SpecialRegisters = {
            SP = 0xFFFEus // Stack Pointer: Initialized to the top of the stack.
            PC = 0x0100us // Program Counter: Critical! This is the entry point
                          // for Game Boy cartridges after the boot ROM.
        }
    }

/// Updates the Z flag (Zero flag) based on the result.
/// The Z flag is set if the result of an operation is zero.
let private updateZFlag (flags: byte) (result: byte) =
    if result = 0x00uy then flags ||| 0x80uy // Set Z flag (bit 7)
    else flags &&& (~~0x80uy) // Clear Z flag

/// Updates the N flag (Subtraction flag).
/// The N flag is set if the last instruction was a subtraction.
let private updateNFlag (flags: byte) (isSub: bool) =
    if isSub then flags ||| 0x40uy // Set N flag (bit 6)
    else flags &&& (~~0x40uy) // Clear N flag

/// Updates the H flag (Half Carry flag).
/// The H flag is set if there's a carry from bit 3 to bit 4. Useful for BCD operations.
let private updateHFlag (flags: byte) (carry: bool) =
    if carry then flags ||| 0x20uy // Set H flag (bit 5)
    else flags &&& (~~0x20uy) // Clear H flag

/// Updates the C flag (Carry flag).
/// The C flag is set if there's a carry from bit 7 (or bit 15 for 16-bit ops).
let private updateCFlag (flags: byte) (carry: bool) =
    if carry then flags ||| 0x10uy // Set C flag (bit 4)
    else flags &&& (~~0x10uy) // Clear C flag

// Placeholder for future instruction execution
let step (cpu: CPUState) (mmu: MMU.MMUState) : CPUState * MMU.MMUState =
    // In future chapters, this will fetch an opcode from MMU at PC,
    // decode it, execute it, update CPU state and MMU state, and increment PC.
    printfn "CPU PC: 0x%04x" cpu.SpecialRegisters.PC
    cpu, mmu // Placeholder: return current state for now

Explanation of Changes:

  • create function: This function now initializes the CPUState with specific values for all registers:
    • A = 0x01, F = 0xB0, B = 0x00, C = 0x13, D = 0x00, E = 0xD8, H = 0x01, L = 0x4B. These are the standard values for the DMG (original Game Boy) CPU registers after the boot ROM has finished executing.
    • SP = 0xFFFE: The stack pointer is initialized to the top of the stack in High RAM.
    • PC = 0x0100: The program counter is set to 0x0100. This is the entry point for Game Boy cartridges, where game code execution begins. This is a critical value for game startup.
  • Flag Helper Functions: The updateZFlag, updateNFlag, updateHFlag, updateCFlag functions are included as a reference. While not used in this chapter, they illustrate the pattern for how flags will be managed later through bitwise operations on the F register.
  • step function: Still a placeholder, but now it prints the current PC, which will be useful for verifying our initial setup.

3. Integrate into the Main Application

Finally, let’s update our main program to use these new initialization functions. This Program.fs will serve as our emulator’s entry point.

File: src/GameBoyEmulator/Program.fs

// src/GameBoyEmulator/Program.fs
open System
open System.IO
open GameBoyEmulator

[<EntryPoint>]
let main argv =
    if argv.Length < 1 then
        printfn "Usage: GameBoyEmulator <path_to_rom.gb>"
        exit 1

    let romPath = argv.[0]
    printfn "Loading ROM: %s" romPath

    try
        // 1. Initialize MMU with the loaded ROM
        let mmu = MMU.create romPath
        printfn "MMU initialized with ROM."

        // 2. Initialize CPU to its post-boot ROM state
        let cpu = CPU.create ()
        printfn "CPU initialized to post-boot state."

        // Verification output for critical registers
        printfn "Initial PC: 0x%04x" cpu.SpecialRegisters.PC
        printfn "Initial SP: 0x%04x" cpu.SpecialRegisters.SP
        printfn "Initial A: 0x%02x, F: 0x%02x" cpu.Registers.A cpu.Registers.F
        printfn "Initial B: 0x%02x, C: 0x%02x" cpu.Registers.B cpu.Registers.C
        printfn "Initial D: 0x%02x, E: 0x%02x" cpu.Registers.D cpu.Registers.E
        printfn "Initial H: 0x%02x, L: 0x%02x" cpu.Registers.H cpu.Registers.L

        // For now, we'll just step once to show the PC.
        // In later chapters, this will become a continuous emulation loop.
        let (nextCpu, nextMmu) = CPU.step cpu mmu

        0 // Exit code indicating success
    with
    | :? System.IO.FileNotFoundException as ex ->
        printfn "Error: ROM file not found. Please check the path. %s" ex.Message
        1 // Exit code indicating error
    | ex ->
        printfn "An unexpected error occurred: %s" ex.Message
        1 // Exit code indicating error

Explanation of Changes:

  • Command Line Argument: The program now expects the path to a ROM file as a command-line argument. This makes it practical and flexible to load different games.
  • MMU.create romPath: This line initializes the MMU, which now handles reading the ROM file from disk and setting up the initial memory state.
  • CPU.create (): This initializes the CPU with the correct post-boot register values, ready to execute game code.
  • Verification Output: We print out the initial values of all CPU registers (PC, SP, A, F, B, C, D, E, H, L) to confirm they are set correctly. This is a crucial debugging step.
  • Error Handling: Added a try...with block to gracefully handle FileNotFoundException if the ROM path is incorrect, and a general exception handler for any other unforeseen issues. This improves the robustness of our application.

Testing & Verification

Now, let’s verify that our emulator correctly loads the ROM and sets the initial CPU state.

  1. Get a Test ROM: You’ll need an actual Game Boy ROM file. A good starting point is a simple, publicly available “hello world” ROM or one of Blargg’s CPU instruction test ROMs. For instance, you can download cpu_instrs/individual/01-special.gb from Blargg’s test ROMs. Create a roms folder in your project root (GameBoyEmulator/roms/) and place the .gb file there.

  2. Build the Project: Open your terminal in the GameBoyEmulator project root and run:

    dotnet build
    
  3. Run the Emulator: From your project root, execute the program, providing the path to your test ROM:

    dotnet run --project src/GameBoyEmulator/GameBoyEmulator.fsproj -- roms/01-special.gb
    

    (Adjust roms/01-special.gb to the actual path if you placed your ROM elsewhere.)

Expected Output:

Loading ROM: roms/01-special.gb
MMU initialized with ROM.
CPU initialized to post-boot state.
Initial PC: 0x0100
Initial SP: 0xFFFE
Initial A: 0x01, F: 0xB0
Initial B: 0x00, C: 0x13
Initial D: 0x00, E: 0xD8
Initial H: 0x01, L: 0x4B
CPU PC: 0x0100
  • MMU initialized with ROM.: Confirms the MMU’s create function ran successfully, implying the ROM file was read and initial memory values were set.
  • Initial PC: 0x0100: This is the most critical check. It confirms your CPU is starting at the correct entry point for Game Boy games, bypassing the boot ROM.
  • Initial SP: 0xFFFE: Confirms the stack pointer is correctly set to the top of the stack.
  • Initial A: 0x01, F: 0xB0 (and other registers): Confirms other key registers are in their expected post-boot state.

If you see these values, your emulator has successfully loaded a ROM and is ready to begin executing instructions!

Production Considerations

  • Robust ROM Loading: While File.ReadAllBytes is sufficient for now, a production emulator would perform more extensive validation on the ROM header (e.g., checking the Nintendo logo checksum, global ROM checksum, cartridge type, ROM size, RAM size). This prevents loading corrupted or unsupported ROMs.
  • Performance of Memory Access: readByte and writeByte operations are in the emulator’s critical path, called millions of times per second. Our current implementation using direct byte array access (mmu.Memory.[int address]) is highly efficient in .NET. Avoiding unnecessary abstractions or bounds checks in inner loops (where F# array access is already optimized) is key.
  • Maintainability and Clarity: Keeping the ROM loading logic within the MMU module maintains a clean separation of concerns. The MMU is explicitly responsible for all memory-related operations, including how ROM data is integrated and accessed.
  • Cross-platform Compatibility: Using System.IO and standard .NET types (byte array, uint16) ensures our core logic remains cross-platform. The only platform-specific concerns will arise later when integrating a graphics library.

๐Ÿ”ฅ Optimization / Pro tip: For extremely performance-sensitive scenarios, especially if you were to target WebAssembly or low-level native targets, you might consider Span<byte> or Memory<byte> for memory access. However, for a desktop .NET emulator, direct byte[] access is typically fast enough due to JIT optimizations.

Common Issues & Solutions

  1. “ROM file not found” Error:
    • Issue: The failwithf "ROM file not found..." is triggered, or the FileNotFoundException is caught.
    • Solution:
      • Verify Path: Double-check the exact path to your .gb file. Ensure it’s relative to where you’re running the dotnet run command or an absolute path. File paths can be tricky across different OSes (e.g., / vs \).
      • File Existence: Make sure the file actually exists at that location and you have read permissions.
      • Working Directory: When running from an IDE, the working directory might be different. Running directly with dotnet run --project ... helps standardize the execution context.
  2. Incorrect Initial Register Values (e.g., PC not 0x0100):
    • Issue: Your output for Initial PC or other registers doesn’t match the expected 0x0100, 0xFFFE, etc.
    • Solution: Review your CPU.fs and MMU.fs create functions carefully. Ensure you’ve copied all the literal byte and uint16 values exactly as specified in the implementation steps. A single typo can lead to unexpected behavior. These values are magic numbers derived from hardware specifications, so precision is key.
  3. No Output or Program Crashes Immediately:
    • Issue: The program exits without printing the expected initialization messages, or crashes with a generic error.
    • Solution:
      • Compilation Errors: First, ensure dotnet build completes without errors.
      • Unhandled Exception: If it compiles but crashes, it’s likely an unhandled exception before our try...with block. Add more printfn statements around different parts of your main function to pinpoint where the crash occurs. For example, print argv.[0] before using romPath to confirm the argument is received correctly.
      • Memory Issues: While unlikely at this stage, ensure your Array.zeroCreate for memory is large enough (0x10000).

๐Ÿง  Check Your Understanding

  • Why do we explicitly initialize CPU registers and specific MMU addresses in the create functions, rather than letting the CPU run from 0x0000?
  • What is the significance of setting the CPU’s PC register to 0x0100?
  • How does the MMU handle the ROM data, and what part of the ROM is immediately accessible after loading?

โšก Mini Task

Modify the Program.fs to also print the byte value at MMU address 0x0100 after initialization. This should be the first byte of the game’s actual executable code (the first opcode).

๐Ÿš€ Scenario

Imagine you’re trying to load a very large Game Boy ROM (e.g., 2MB), but your MMU.create function only copies the first 16KB into the main Memory array. What problem might this cause when the CPU tries to access data beyond 0x3FFF? How would you design the MMU to handle larger ROMs with multiple banks more effectively, without copying the entire 2MB into the 64KB addressable space?

๐Ÿ“Œ TL;DR

  • ROM Loading: The MMU now handles loading a .gb file from disk into a byte array and copies the initial 16KB (ROM Bank 0) into the main addressable memory.
  • Initial State: CPU registers (PC=0x0100, SP=0xFFFE, etc.) and key MMU I/O registers are explicitly set to their post-boot ROM values.
  • Skipping Boot ROM: We bypass emulating the Game Boy’s internal boot ROM by directly setting the system to its state after the boot sequence, allowing us to jump straight into game code.

๐Ÿง  Core Flow

  1. The Main Application receives the ROM file path as a command-line argument.
  2. MMU.create reads the ROM file from disk, stores the full ROM data, copies the first 16KB into the main 64KB memory map, and initializes critical I/O registers to post-boot values.
  3. CPU.create sets the CPU’s general-purpose and special registers (PC, SP, A, F, etc.) to their specific post-boot ROM values.
  4. The Main Application verifies these initial states by printing register values and performs a single CPU.step (currently a placeholder).

๐Ÿš€ Key Takeaway

Successfully loading a ROM and initializing the system state to a known, post-boot configuration is the critical first step in emulator development. It enables the CPU to begin executing actual game code from the correct starting point, forming the bedrock for all subsequent emulation logic.

References

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