Introduction
Welcome to the foundational stage of our Game Boy emulator! In this chapter, we’re going to construct the very brain of our system: the Central Processing Unit (CPU). The Game Boy uses a custom 8-bit CPU, often referred to as the “SM83,” which is a hybrid between a Zilog Z80 and Intel 8080. Understanding and accurately emulating its behavior is paramount to running any Game Boy software.
This milestone is critical because every single action within a Game Boy gameβfrom moving a character to calculating damageβis ultimately a sequence of CPU instructions. Building the CPU core correctly is non-negotiable for a functional emulator. By the end of this chapter, you will have a functional, albeit minimal, CPU core capable of storing its state (registers and flags) and executing a few fundamental instructions. This forms the bedrock upon which we’ll build memory access, graphics, and more complex logic.
Project Overview: Building a Game Boy Emulator
The overarching goal of this project is to build a functional Game Boy emulator from first principles using F#. This involves recreating the behavior of the original hardware components: the CPU, Memory Management Unit (MMU), Picture Processing Unit (PPU), Audio Processing Unit (APU), and input system. We’ll start with the CPU, as it dictates the flow of execution for all other components.
Tech Stack Deep Dive
For this project, we’re leveraging the following technologies:
- F# (Version 8.0): Our primary programming language. F# is a functional-first language on the .NET platform, offering strong typing, immutability by default, and powerful abstractions that are well-suited for modeling complex hardware components and their interactions.
- .NET SDK (Version 8.0): The runtime and development tools for F#. As of 2026-05-05, .NET 8.0 is the latest stable Long-Term Support (LTS) release, providing excellent performance and cross-platform capabilities.
- SDL Bindings (e.g., Silk.NET.SDL, Veldrid): While not directly used in this chapter, a cross-platform graphics library will be essential for rendering the Game Boy’s display later. We will explore modern .NET bindings for SDL (like
Silk.NET.SDL) or other low-level graphics libraries when we reach the PPU implementation.
Milestones and Build Plan
This chapter focuses on getting the CPU’s fundamental state management and instruction execution working. Here’s our plan:
- Model CPU Registers and Flags: Define F# records to represent the CPU’s internal state.
- Implement Flag Management Helpers: Create functions to precisely set, clear, and check individual flags within the
Fregister. This is critical for accurate arithmetic and logic operations. - Simulate Memory Access: Introduce a basic
IMemoryinterface and aMockMemoryimplementation. This allows us to test CPU instructions without needing a full Memory Management Unit yet. - Implement Basic Instructions: Code the
fetch-decode-executecycle for a handful of simple, representative Game Boy opcodes. - Verify Execution: Run a small test program to observe register and flag changes, confirming our CPU’s initial correctness.
By the end of this chapter, you’ll have a working CPU core that can interpret and execute basic instructions, laying the groundwork for more complex Game Boy emulation.
Planning & Design: Modeling the CPU State
Before we write any code, let’s outline how we’ll represent the CPU. The SM83 CPU has a set of registers, which are small, fast storage locations used for calculations and addressing memory. It also has a set of flags that indicate the results of operations (e.g., if a result was zero, or if an overflow occurred).
CPU Registers
The Game Boy CPU has the following primary registers:
- General-Purpose 8-bit Registers:
A,B,C,D,E,H,L. These can also be paired for 16-bit operations:BC,DE,HL. - Accumulator (
A): The primary register for arithmetic and logic operations. - Flags Register (
F): An 8-bit register where individual bits represent specific flags. - Stack Pointer (
SP): A 16-bit register pointing to the current top of the stack in memory. - Program Counter (
PC): A 16-bit register pointing to the memory address of the next instruction to be executed.
CPU Flags (within the F register)
The F register is special. Only the upper four bits are used, representing four important flags. The lower four bits (0-3) are always zero.
- Z (Zero Flag - Bit 7): Set if the result of an operation is zero.
- N (Subtract Flag - Bit 6): Set if the last instruction was a subtraction.
- H (Half Carry Flag - Bit 5): Set if there was a carry from bit 3 to bit 4 (useful for BCD arithmetic).
- C (Carry Flag - Bit 4): Set if there was a carry from bit 7 or a borrow.
F# Representation
In F#, we’ll model the CPU’s state using immutable records. This aligns perfectly with functional programming principles, ensuring that state changes are explicit and predictable.
For registers, we’ll create a Registers record. For the overall CPU state, we’ll have a CpuState record that includes Registers and a placeholder for memory.
CPU Core Data Flow
The basic operation of our CPU will follow a classic fetch-decode-execute cycle:
The CPU constantly repeats this cycle, processing one instruction at a time. This continuous loop is the heart of any emulator.
File Structure
We’ll start with a single Cpu.fs file to encapsulate all CPU-related logic for now. As the project grows, we might split it further (e.g., Registers.fs, Opcodes.fs).
GameBoyEmulator/
βββ GameBoyEmulator.fsproj
βββ Program.fs
βββ Cpu.fs
Step-by-Step Implementation
Let’s start by defining our CPU’s core data structures.
1. Define Registers and CPU State
Open your GameBoyEmulator.fsproj file and ensure it targets .NET 8.0.
<!-- GameBoyEmulator.fsproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<WarnOn>FS3391;FS3392</WarnOn>
</PropertyGroup>
<ItemGroup>
<Compile Include="Cpu.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
</Project>
Explanation: This standard .fsproj setup targets .NET 8.0, which is the latest stable long-term support (LTS) release as of 2026-05-05. The <WarnOn> element helps catch common F# code quality issues, promoting better F# coding practices.
Next, create a new file named Cpu.fs in your project root.
// Cpu.fs
module GameBoyEmulator.Cpu
/// Represents the 8-bit general-purpose registers and the 16-bit program counter (PC)
/// and stack pointer (SP) of the Game Boy's SM83 CPU.
/// The 'F' register holds the CPU flags.
type Registers = {
A : byte
F : byte // Flags register (Z N H C 0 0 0 0)
B : byte
C : byte
D : byte
E : byte
H : byte
L : byte
SP : uint16 // Stack Pointer
PC : uint16 // Program Counter
}
/// Represents the full state of the Game Boy CPU.
/// This will be expanded to include memory and other components later.
type CpuState = {
Registers : Registers
// Cycles : int64 // We'll add cycle counting later
// Memory : byte array // Placeholder for memory, will be replaced by MMU
}
/// Initializes the CPU registers to their power-up state.
/// This is based on common Game Boy boot ROM behavior.
let createInitialRegisters () : Registers =
{
A = 0x01uy // Often 0x01 for DMG, or 0x11 for CGB
F = 0xA0uy // ZNHC flags: Z=1, N=0, H=1, C=0 (0b10100000). Lower 4 bits are always 0.
B = 0x00uy
C = 0x13uy
D = 0x00uy
E = 0xD8uy
H = 0x01uy
L = 0x4Duy
SP = 0xFFFEus // Stack pointer typically starts high
PC = 0x0100us // PC usually starts after the boot ROM
}
/// Initializes the CPU state.
let createInitialCpuState () : CpuState =
{
Registers = createInitialRegisters ()
}
Explanation:
module GameBoyEmulator.Cpu: Defines a module to organize our CPU-related code. Modules help prevent name collisions and improve code organization within larger projects.type Registers = { ... }: This F# record defines the structure for our CPU registers. Each field is immutable by default, which is a core F# principle.byteis used for 8-bit registers, anduint16for 16-bit ones. Usinguint16forPCandSPis important as Game Boy addresses are 16-bit.type CpuState = { ... }: This record will hold the entire CPU state. For now, it just containsRegisters. We’ve added comments forCyclesandMemoryas a reminder of future additions, demonstrating forward-thinking design.let createInitialRegisters (): This function provides the initial power-up values for the Game Boy’s CPU registers. These are specific values that the hardware starts with, crucial for compatibility with the boot ROM. We useuyandussuffixes to denote unsigned byte and unsigned short literals, respectively. TheFregister is set to0xA0uy(10100000 in binary), which means the Zero (Z) flag is set, Half-Carry (H) is set, and Subtract (N) and Carry (C) flags are cleared. The lower 4 bits are always 0.let createInitialCpuState (): A simple function to create an initialCpuStateby callingcreateInitialRegisters.
π Key Idea: Using immutable records for Registers and CpuState means that every operation that changes the CPU’s state will return a new CpuState instance, rather than modifying an existing one. This makes state transitions explicit and easier to reason about.
2. Flag Management
Manipulating individual bits within the F register requires bitwise operations. Let’s define helper functions for setting, clearing, and checking flags. By encapsulating these operations, we make our code cleaner and less error-prone.
Add the following functions to Cpu.fs after createInitialCpuState.
// Cpu.fs (continued)
// Flag masks for the F register
let FlagZ = 0x80uy // Zero Flag (Bit 7)
let FlagN = 0x40uy // Subtract Flag (Bit 6)
let FlagH = 0x20uy // Half Carry Flag (Bit 5)
let FlagC = 0x10uy // Carry Flag (Bit 4)
/// Sets a specific flag in the F register.
let setFlag flag (registers: Registers) : Registers =
{ registers with F = registers.F ||| flag }
/// Clears a specific flag in the F register.
let clearFlag flag (registers: Registers) : Registers =
{ registers with F = registers.F &&& (~~~flag) }
/// Checks if a specific flag is set in the F register.
let isFlagSet flag (registers: Registers) : bool =
(registers.F &&& flag) = flag
/// Updates the Z flag based on a value.
let updateZFlag value (registers: Registers) : Registers =
if value = 0x00uy then setFlag FlagZ registers
else clearFlag FlagZ registers
/// Updates the N flag based on whether the last operation was a subtraction.
let updateNFlag isSubtraction (registers: Registers) : Registers =
if isSubtraction then setFlag FlagN registers
else clearFlag FlagN registers
/// Updates the H flag based on an 8-bit addition or subtraction.
/// For addition, checks if bit 3 carried to bit 4.
/// For subtraction, checks if bit 3 borrowed from bit 4.
let updateHFlag8bit isSubtraction val1 val2 (registers: Registers) : Registers =
let halfCarry =
if isSubtraction then
// Half borrow check: if lower nibble of val1 is less than lower nibble of val2
((val1 &&& 0x0Fu) < (val2 &&& 0x0Fu))
else
// Half carry check: if sum of lower nibbles overflows into bit 4
(((val1 &&& 0x0Fu) + (val2 &&& 0x0Fu)) &&& 0x10u) = 0x10u
if halfCarry then setFlag FlagH registers
else clearFlag FlagH registers
/// Updates the C flag based on an 8-bit addition or subtraction.
/// For addition, checks if bit 7 carried out.
/// For subtraction, checks if there was a borrow.
let updateCFlag8bit isSubtraction val1 val2 (registers: Registers) : Registers =
let carry =
if isSubtraction then
// Borrow check: if val1 is less than val2
(val1 < val2)
else
// Carry check: if adding val2 to val1 results in overflow
(uint16 val1 + uint16 val2) > 0xFFu
if carry then setFlag FlagC registers
else clearFlag FlagC registers
Explanation:
- Flag Masks: We define
FlagZ,FlagN,FlagH,FlagCasbyteconstants. These are bitmasks to isolate or manipulate the specific flag bits within theFregister. setFlag/clearFlag: These functions use bitwise OR (|||) to set a bit and bitwise AND (&&&) with the bitwise NOT (~~~) of the flag to clear a bit. They return a newRegistersrecord with the updatedFfield, preserving immutability.isFlagSet: Checks if a flag is currently set.updateZFlag,updateNFlag,updateHFlag8bit,updateCFlag8bit: These are crucial for correctly updating flags after arithmetic operations. The logic for half-carry and carry flags in 8-bit operations is specific to the SM83 and needs careful implementation based on documentation.isSubtractionparameter is vital as flag logic often differs between addition and subtraction.updateHFlag8bitchecks for carry/borrow between the lower and upper nibble (bits 3 and 4).updateCFlag8bitchecks for overflow/borrow for the full 8-bit value.
π§ Important: The exact logic for flag updates can be tricky and is a common source of bugs in emulators. Always consult detailed SM83 documentation (like Pan Docs) for precise behavior. The Game Boy CPU’s flag behavior is not always intuitive and differs from other CPUs like the Z80 in subtle ways. Incorrect flag emulation can lead to subtle bugs in game logic that are extremely hard to debug later.
β‘ Real-world insight: The ||| (bitwise OR), &&& (bitwise AND), and ~~~ (bitwise NOT) operators are fundamental for low-level system programming. Understanding how to manipulate individual bits efficiently is a core skill for emulator development.
3. Basic Instruction Execution
Now, let’s implement the execute function for a few simple instructions. For this chapter, we’ll hardcode a few opcodes directly. Later, we’ll read them from memory via a proper Memory Management Unit (MMU).
First, we need a way to simulate reading from memory for our CPU. For now, we’ll use a very basic mock.
Add the following type and function to Cpu.fs (after the flag functions).
// Cpu.fs (continued)
/// A simple type to represent our memory interface.
/// In Chapter 3, this will be replaced by a full Memory Management Unit (MMU).
type IMemory =
abstract member ReadByte : uint16 -> byte
abstract member WriteByte : uint16 -> byte -> unit
/// A mock memory implementation for initial CPU testing.
/// It's just a byte array for now, representing 64KB of addressable space.
type MockMemory (size : int) =
let data = Array.zeroCreate<byte> size
interface IMemory with
member _.ReadByte addr =
if addr < uint16 data.Length then
data[int addr]
else
0xFFuy // Default for unmapped memory reads on Game Boy is 0xFF
member _.WriteByte addr value =
if addr < uint16 data.Length then
data[int addr] <- value
// else ignore write to unmapped memory for now, a real MMU would handle this
/// The main CPU execution function.
/// It takes the current CPU state and a memory interface,
/// fetches an opcode, decodes it, executes it, and returns the new state.
let executeCycle (cpuState: CpuState) (memory: IMemory) : CpuState =
let currentRegisters = cpuState.Registers
let pc = currentRegisters.PC
let opcode = memory.ReadByte pc // Fetch the opcode at the current Program Counter
// Increment PC for the next instruction. Most instructions are 1 byte.
// Multi-byte instructions will increment PC further during their execution.
let registersAfterFetch = { currentRegisters with PC = pc + 1us }
// Decode and Execute
match opcode with
| 0x00uy -> // NOP: No Operation
// Pan Docs: "No operation. Does nothing."
// Cycles: 4
// Flags: None affected
{ cpuState with Registers = registersAfterFetch }
| 0x06uy -> // LD B, n: Load 8-bit immediate into B
// Pan Docs: "Load 8-bit immediate value n into register B."
// Cycles: 8
// Flags: None affected
let value = memory.ReadByte (registersAfterFetch.PC) // Read the immediate value
let newRegisters = { registersAfterFetch with B = value; PC = registersAfterFetch.PC + 1us } // Increment PC past the immediate
{ cpuState with Registers = newRegisters }
| 0x0Euy -> // LD C, n: Load 8-bit immediate into C
// Pan Docs: "Load 8-bit immediate value n into register C."
// Cycles: 8
// Flags: None affected
let value = memory.ReadByte (registersAfterFetch.PC)
let newRegisters = { registersAfterFetch with C = value; PC = registersAfterFetch.PC + 1us }
{ cpuState with Registers = newRegisters }
| 0x04uy -> // INC B: Increment register B
// Pan Docs: "Increment register B. Flags: Z 0 H -"
// Cycles: 4
let oldValue = registersAfterFetch.B
let newValue = oldValue + 1uy
let newRegisters =
registersAfterFetch
|> (updateZFlag newValue) // Update Z flag based on newValue
|> (updateNFlag false) // N flag is always cleared for INC
|> (updateHFlag8bit false oldValue 1uy) // Check half carry for addition (increment by 1)
|> (fun r -> { r with B = newValue }) // Update B register
{ cpuState with Registers = newRegisters }
| 0x05uy -> // DEC B: Decrement register B
// Pan Docs: "Decrement register B. Flags: Z 1 H -"
// Cycles: 4
let oldValue = registersAfterFetch.B
let newValue = oldValue - 1uy
let newRegisters =
registersAfterFetch
|> (updateZFlag newValue) // Update Z flag based on newValue
|> (updateNFlag true) // N flag is always set for DEC
|> (updateHFlag8bit true oldValue 1uy) // Check half carry for subtraction (decrement by 1)
|> (fun r -> { r with B = newValue }) // Update B register
{ cpuState with Registers = newRegisters }
// Default case for unimplemented opcodes
| _ ->
// For now, we'll just throw an exception for unknown opcodes.
// Later, we'll implement a full opcode map or a more robust error handling mechanism.
failwithf "Unimplemented opcode: 0x%02X at PC: 0x%04X" opcode pc
Explanation:
IMemoryandMockMemory: We introduce a simpleIMemoryinterface and aMockMemoryimplementation. This allows us to pass a memory abstraction to our CPU, decoupling it from the actual memory management unit (which we’ll build in the next chapter). For now,MockMemoryis just a byte array representing the Game Boy’s 64KB address space.ReadBytereturns0xFFfor out-of-bounds reads, which is common Game Boy behavior. This is a classic example of the Dependency Inversion Principle, making our code more modular and testable.executeCyclefunction: This is the core of our CPU.- It takes
cpuStateandmemoryas input and returns a newCpuState. This functional approach ensures state changes are explicit. - Fetch:
memory.ReadByte pcfetches the opcode at the currentPC. - Increment PC:
registersAfterFetchupdates thePCto point to the next byte. Most instructions are 1 byte, butLD r, n(load immediate) is 2 bytes, soPCneeds an additional increment within thematcharm. - Decode & Execute: The
match opcode withstatement handles different opcodes.NOP(0x00): Does nothing except advancePC.LD B, n(0x06) /LD C, n(0x0E): These are 2-byte instructions. The first byte is the opcode, the second is the immediate valuen. We readnfromPC + 1and then incrementPCagain to move pastn.INC B(0x04): Increments registerB. Crucially, it updates theZ,N, andHflags. TheCflag is not affected by 8-bitINC/DECoperations, which is an important detail for the SM83. We use the pipe-forward operator|>to chain flag updates, making the sequence of operations clear and readable.DEC B(0x05): Decrements registerB. Similar toINC, it updatesZ,N, andHflags, butNis set for decrement.
- Return New State: Each branch of the
matchreturns a newCpuStaterecord, reflecting the changes made by the instruction.
- It takes
failwithf: For any unimplemented opcode, we’ll throw an exception. This helps us identify missing instructions during development.
β‘ Quick Note: The cycle counts (e.g., Cycles: 4) mentioned in the comments are for the Game Boy’s internal clock (M-cycles). We’ll use these later for proper timing and synchronization with the PPU and APU. For now, they are informational but critical for future performance modeling.
β οΈ What can go wrong: Forgetting to increment PC correctly for multi-byte instructions is a very common bug. If PC isn’t advanced past the immediate value in LD r, n, the CPU will try to interpret the data byte as an opcode, leading to immediate crashes or incorrect execution.
4. Integrate into Program.fs
Let’s modify our Program.fs to create a CPU and execute a few cycles using our MockMemory.
// Program.fs
open System
open GameBoyEmulator.Cpu
[<EntryPoint>]
let main argv =
printfn "Starting Game Boy Emulator CPU Test..."
// Create a mock memory of 64KB (typical Game Boy memory space)
let memory = MockMemory(0x10000) :> IMemory
// Load a simple test program into mock memory
// This sequence means:
// 0x0100: NOP
// 0x0101: LD B, 0x0A (Load 0x0A into B)
// 0x0103: INC B (B becomes 0x0B)
// 0x0104: DEC B (B becomes 0x0A)
// 0x0105: NOP
let program = [| 0x00uy; 0x06uy; 0x0Auy; 0x04uy; 0x05uy; 0x00uy |]
for i = 0 to program.Length - 1 do
(memory :?> MockMemory).data[0x0100 + i] <- program[i] // Directly access internal array for mock memory
// Initialize CPU state
let mutable cpuState = createInitialCpuState ()
printfn "Initial PC: 0x%04X, B: 0x%02X, F: 0x%02X" cpuState.Registers.PC cpuState.Registers.B cpuState.Registers.F
// Execute a few cycles
for i = 1 to 5 do // Execute 5 instructions
printfn "\n--- Cycle %d ---" i
let oldPc = cpuState.Registers.PC
let opcode = memory.ReadByte oldPc
printfn "Executing opcode 0x%02X at PC 0x%04X" opcode oldPc
cpuState <- executeCycle cpuState memory
printfn "New PC: 0x%04X, B: 0x%02X, F: 0x%02X" cpuState.Registers.PC cpuState.Registers.B cpuState.Registers.F
// Check flags after relevant operations
if opcode = 0x04uy || opcode = 0x05uy then
printfn " Flags: Z=%b N=%b H=%b C=%b"
(isFlagSet FlagZ cpuState.Registers)
(isFlagSet FlagN cpuState.Registers)
(isFlagSet FlagH cpuState.Registers)
(isFlagSet FlagC cpuState.Registers)
0 // Return an exit code
Explanation:
open GameBoyEmulator.Cpu: Imports our CPU module, making its functions and types available.MockMemory: We instantiate ourMockMemorywith 64KB, which is the full 16-bit addressable range of the Game Boy.program: A simple array of bytes representing a short sequence of Game Boy opcodes. We manually load this into ourMockMemorystarting at address0x0100, which is where the CPU’sPCtypically starts after the boot ROM. This simulates a very basic game program.mutable cpuState: We declarecpuStateasmutablebecause ourexecuteCyclefunction returns a new state, and we need to reassign it in the loop. This is a common pattern when combining functional updates with iterative processes in F#.- Execution Loop: We loop a few times, calling
executeCycleand printing the CPU state (PC,B,Fregisters, and flags) after each instruction. This allows us to observe the CPU’s behavior and verify its correctness.
Testing & Verification
Now, let’s run our program and verify the CPU’s behavior.
Build and Run: Open your terminal in the
GameBoyEmulatordirectory and run:dotnet runExpected Output and Verification: You should see output similar to this:
Starting Game Boy Emulator CPU Test... Initial PC: 0x0100, B: 0x00, F: 0xA0 --- Cycle 1 --- Executing opcode 0x00 at PC 0x0100 New PC: 0x0101, B: 0x00, F: 0xA0 --- Cycle 2 --- Executing opcode 0x06 at PC 0x0101 New PC: 0x0103, B: 0x0A, F: 0xA0 --- Cycle 3 --- Executing opcode 0x04 at PC 0x0103 New PC: 0x0104, B: 0x0B, F: 0x00 Flags: Z=False N=False H=False C=False --- Cycle 4 --- Executing opcode 0x05 at PC 0x0104 New PC: 0x0105, B: 0x0A, F: 0x40 Flags: Z=False N=True H=False C=False --- Cycle 5 --- Executing opcode 0x00 at PC 0x0105 New PC: 0x0106, B: 0x0A, F: 0x40Key points to verify:
- PC Increment: Does
PCcorrectly increment after each instruction (by 1 forNOP, by 2 forLD r, n)? LD B, n: DoesBregister correctly load0x0A?INC B(0x04):Bincrements from0x0Ato0x0B.Zflag:0x0Bis not zero, soZis cleared (from initial1).Nflag:INCclearsN.Hflag:0x0A + 1(0b1010 + 0b0001) does not cause a half-carry (no carry from bit 3 to bit 4), soHis cleared (from initial1).Cflag:INCdoes not affectC, so it remains cleared (from initial0).- Resulting
Fshould be0b00000000=0x00.
DEC B(0x05):Bdecrements from0x0Bto0x0A.Zflag:0x0Ais not zero, soZis cleared.Nflag:DECsetsN.Hflag:0x0B - 1(0b1011 - 0b0001) does not cause a half-borrow (no borrow from bit 4 to bit 3), soHis cleared.Cflag:DECdoes not affectC, so it remains cleared.- Resulting
Fshould be0b01000000=0x40.
This verification step is crucial. If your output differs, carefully re-check your
executeCycleimplementation and flag logic against the Game Boy’s CPU documentation.- PC Increment: Does
Operations & Performance Considerations
- Performance: The
executeCyclefunction will be called millions of times per second (Game Boy CPU runs at ~4.19 MHz). While F# records are immutable, creating new records on every state change can introduce overhead. For now, this is acceptable for clarity and correctness. Later, in performance-critical sections (like the main emulation loop), we might explore usingmutablefields orrefcells judiciously, or optimizing specific instruction paths using techniques likeinlineor highly optimized loops. The goal is accurate emulation first, then optimization. - Maintainability: By encapsulating CPU logic in
Cpu.fsand using a clearRegistersrecord, we ensure a modular and understandable codebase. TheIMemoryinterface is a great example of dependency inversion, making our CPU testable without a full MMU. This separation of concerns is a key principle in building complex systems. - Testing: Unit tests are absolutely crucial for CPU emulation. Every opcode, every flag update needs to be verified. The small
programarray we used is a manual form of an integration test. Later, we’ll use dedicated test ROMs like Blargg’s CPU instruction tests, which are specifically designed to validate CPU behavior against known correct hardware behavior.
π₯ Optimization / Pro tip: For very performance-sensitive parts of an emulator, you might consider using mutable arrays or ref cells for memory and registers directly, even in F#. The key is to isolate these mutable parts and manage them carefully, perhaps within a single Emulator record that wraps all mutable components. This balances functional purity with raw speed.
Common Issues & Solutions
- Incorrect Flag Updates: This is by far the most common pitfall in emulator development. The
H(Half Carry) flag logic, in particular, can be tricky as it depends on operations on the lower nibble and differs for addition and subtraction.- Solution: Create dedicated unit tests for flag calculations. Isolate flag logic into pure functions as we did. Consult authoritative documentation (like Pan Docs) rigorously.
- PC Increment Errors: For multi-byte instructions, forgetting to increment
PCby the correct amount will lead to reading incorrect opcodes or data, causing the emulator to quickly desynchronize.- Solution: Carefully read the instruction’s byte length from documentation. For each
matchbranch, ensurePCis advanced correctly, accounting for the opcode itself and any immediate operands.
- Solution: Carefully read the instruction’s byte length from documentation. For each
- Misinterpreting Documentation: The SM83 is not a standard CPU, and its documentation is often reverse-engineered. Minor details can have significant impacts.
- Solution: Cross-reference multiple sources (Pan Docs, other emulator implementations) and use test ROMs as the ultimate source of truth for behavior. Emulator development is an iterative process of reading docs, implementing, testing, and debugging.
π§ Check Your Understanding
- What is the purpose of the
Fregister, and why are only its upper four bits significant? - Explain the concept of immutability in F# records and how it applies to our
CpuStateupdates. - Why did we introduce an
IMemoryinterface even though we’re using a simpleMockMemoryfor now?
β‘ Mini Task
- Implement the
LD B, A(opcode0x47) instruction inexecuteCycle. This instruction copies the value from registerAto registerB. It does not affect any flags and is a 1-byte instruction.
π Scenario
You’ve implemented the ADD A, n (opcode 0xC6) instruction, which takes an 8-bit immediate n and adds it to register A. This instruction affects Z, N, H, and C flags. Describe how you would update executeCycle for this, specifically focusing on how you would use the updateZFlag, updateNFlag (which should be cleared), updateHFlag8bit, and updateCFlag8bit functions. Consider the number of bytes this instruction consumes.
π TL;DR
- The Game Boy’s SM83 CPU uses 8-bit registers (A, B, C, D, E, H, L) and 16-bit registers (SP, PC).
- The
Fregister stores four critical flags: Zero (Z), Subtract (N), Half Carry (H), and Carry (C). The lower 4 bits are unused. - We model CPU state using immutable F# records for clarity and functional purity, returning new state with each instruction.
- CPU operation follows a fetch-decode-execute cycle, updating the
CpuStatewith new register values. - Precise flag updates and correct
PCincrements are crucial for accurate emulation.
π§ Core Flow
- Define
RegistersandCpuStaterecords inCpu.fsto model CPU state. - Implement helper functions for managing individual CPU flags using bitwise operations.
- Create an
IMemoryinterface and aMockMemoryfor basic, decoupled memory access. - Implement
executeCyclewith amatchstatement for basic opcodes (NOP,LD r, n,INC r,DEC r), ensuring correct flag updates andPCadvancement. - Integrate and test the CPU in
Program.fsby loading a small program intoMockMemoryand stepping through execution, observing register and flag changes.
π Key Takeaway
Building an emulator starts with a meticulously crafted CPU core. Every instruction’s effect on registers, program counter, and especially flags, must be precisely emulated. F#’s strong typing and immutability help manage this complex state with confidence, but requires careful attention to detail and thorough verification against hardware documentation.
References
- F# Language Reference: https://learn.microsoft.com/en-us/dotnet/fsharp/
- .NET Documentation: https://learn.microsoft.com/en-us/dotnet/
- Pan Docs (Game Boy Technical Reference): https://gbdev.io/pandocs/
- Game Boy CPU Instruction Set (gbdev.io): https://gbdev.io/pandocs/CPU_Instruction_Set.html
This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.