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-CSorVeldrid): 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:
- ROM Loading: Reading the
.gbfile 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. - 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.
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:
MMUStaterecord: We added aRom : byte arrayfield toMMUState. This stores the entire cartridge ROM data, which can be larger than 64KB for games with multiple memory banks.createfunction:- Now takes
romPath: stringas an argument to specify the ROM file to load. - It reads all bytes from the specified ROM file using
File.ReadAllBytes. - Includes a
failwithfcall for robust error handling if the file isn’t found, preventing unexpected crashes. Array.blitis used to copy the first 16KB (ROM Bank 0, from0x0000to0x3FFF) of the loaded ROM directly into theMemoryarray. This makes the initial game code immediately accessible to the CPU.- Post-Boot ROM Initialization: A series of
memory.[address] <- valuelines 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.
- Now takes
readByteandwriteByte: These functions now operate on theMMUStaterecord, accessing itsMemoryarray. 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:
createfunction: This function now initializes theCPUStatewith 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 to0x0100. 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,updateCFlagfunctions 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 theFregister. stepfunction: Still a placeholder, but now it prints the currentPC, 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...withblock to gracefully handleFileNotFoundExceptionif 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.
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.gbfrom Blargg’s test ROMs. Create aromsfolder in your project root (GameBoyEmulator/roms/) and place the.gbfile there.- Blargg’s CPU Test ROMs: https://github.com/retroworks/gb-test-roms/tree/main/cpu_instrs
Build the Project: Open your terminal in the
GameBoyEmulatorproject root and run:dotnet buildRun 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.gbto 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’screatefunction 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.ReadAllBytesis 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:
readByteandwriteByteoperations are in the emulator’s critical path, called millions of times per second. Our current implementation using directbyte arrayaccess (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
MMUmodule maintains a clean separation of concerns. TheMMUis explicitly responsible for all memory-related operations, including how ROM data is integrated and accessed. - Cross-platform Compatibility: Using
System.IOand 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
- “ROM file not found” Error:
- Issue: The
failwithf "ROM file not found..."is triggered, or theFileNotFoundExceptionis caught. - Solution:
- Verify Path: Double-check the exact path to your
.gbfile. Ensure it’s relative to where you’re running thedotnet runcommand 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.
- Verify Path: Double-check the exact path to your
- Issue: The
- Incorrect Initial Register Values (e.g., PC not 0x0100):
- Issue: Your output for
Initial PCor other registers doesn’t match the expected0x0100,0xFFFE, etc. - Solution: Review your
CPU.fsandMMU.fscreatefunctions carefully. Ensure you’ve copied all the literalbyteanduint16values 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.
- Issue: Your output for
- 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 buildcompletes without errors. - Unhandled Exception: If it compiles but crashes, it’s likely an unhandled exception before our
try...withblock. Add moreprintfnstatements around different parts of yourmainfunction to pinpoint where the crash occurs. For example, printargv.[0]before usingromPathto confirm the argument is received correctly. - Memory Issues: While unlikely at this stage, ensure your
Array.zeroCreatefor memory is large enough (0x10000).
- Compilation Errors: First, ensure
๐ง Check Your Understanding
- Why do we explicitly initialize CPU registers and specific MMU addresses in the
createfunctions, rather than letting the CPU run from0x0000? - What is the significance of setting the CPU’s
PCregister to0x0100? - How does the
MMUhandle 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
MMUnow handles loading a.gbfile from disk into abyte arrayand 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
- The
Main Applicationreceives the ROM file path as a command-line argument. MMU.createreads 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.CPU.createsets the CPU’s general-purpose and special registers (PC,SP,A,F, etc.) to their specific post-boot ROM values.- The
Main Applicationverifies these initial states by printing register values and performs a singleCPU.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
- Pan Docs: https://gbdev.io/pandocs/
- Microsoft F# Language Reference: https://learn.microsoft.com/en-us/dotnet/fsharp/
- Microsoft .NET Documentation: https://learn.microsoft.com/en-us/dotnet/
- Blargg’s Game Boy CPU Test ROMs: https://github.com/retroworks/gb-test-roms/tree/main/cpu_instrs
This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.