The CPU you started building in the last chapter is blind without memory. It can execute instructions, but it can’t load programs, store data, or interact with any of the Game Boy’s peripherals like the screen or sound chip. This is where the Memory Management Unit (MMU) comes in.

This chapter guides you through creating the Game Boy’s core memory system, the Memory Management Unit (MMU). You’ll learn about the Game Boy’s memory map, how to model different memory regions, and implement the fundamental readByte and writeByte operations crucial for any emulator. By the end, your emulator will be able to load a Game Boy ROM into its virtual memory, a significant step towards running actual games.

Introduction

In any computer system, the CPU doesn’t directly access every component. Instead, it interacts with a unified address space, and a component called the MMU translates those addresses to the correct physical memory location or hardware register. For the Game Boy, this system is relatively simple but absolutely critical.

This milestone focuses on building a functional Memory module that encapsulates the Game Boy’s 64KB (0x0000-0xFFFF) address space. You’ll define the different memory regions, implement logic to handle reads and writes to these regions, and crucially, enable loading a cartridge ROM into the system.

By the end of this chapter, your emulator will:

  • Have a structured representation of the Game Boy’s memory.
  • Be able to load a Game Boy ROM file into the designated memory region.
  • Support basic readByte and writeByte operations across various memory areas, allowing the CPU to fetch instructions and interact with RAM.

Planning & Design

The Game Boy’s CPU (a custom Sharp SM83) has a 16-bit address bus, meaning it can address 2^16 = 65,536 bytes (64KB) of memory. This 64KB is divided into distinct regions, each serving a specific purpose:

Game Boy Memory Map (Simplified Overview):

Address RangeSizeDescriptionAccess
0x0000 - 0x7FFF32KBCartridge ROM (Bank 0 and Switchable Banks)Read Only
0x8000 - 0x9FFF8KBVRAM (Video RAM)Read/Write
0xA000 - 0xBFFF8KBExternal RAM (Cartridge RAM, if present)Read/Write
0xC000 - 0xDFFF8KBWRAM (Work RAM)Read/Write
0xE000 - 0xFDFF7.5KBEcho RAM (Mirror of 0xC000-0xDFFF)Read/Write
0xFE00 - 0xFE9F160 bytesOAM (Object Attribute Memory, for sprites)Read/Write
0xFEA0 - 0xFEFF96 bytesUnusable/ForbiddenNo Access
0xFF00 - 0xFF7F128 bytesI/O Registers (LCD, Joypad, Sound, Timers)Read/Write
0xFF80 - 0xFFFE127 bytesHRAM (High RAM)Read/Write
0xFFFF1 byteInterrupt Enable RegisterRead/Write

๐Ÿ“Œ Key Idea: The MMU’s job is to act as a switchboard. When the CPU asks for data at address 0x1000, the MMU knows to fetch it from the cartridge ROM. If it asks for 0xD000, it goes to WRAM.

Designing the Memory Module

We’ll model the MMU as an F# record type holding byte arrays for each distinct, addressable memory region. This approach offers clear separation and allows us to use F#’s powerful pattern matching for efficient address decoding.

flowchart TD CPU[CPU] -->|Address Data Control| MMU[Memory Management Unit] MMU -->|Access ROM| CartridgeROM[Cartridge ROM] MMU -->|Access Video RAM| VRAM[Video RAM] MMU -->|Access External RAM| ExternalRAM[External RAM] MMU -->|Access Work RAM| WorkRAM[Work RAM] MMU -->|Access Peripherals| SystemPeripherals[System Peripherals]

File Structure

We’ll create a new file, Mmu.fs, to house our memory logic. We might also create a MemoryMap.fs for constants, though for now, we can keep constants within Mmu.fs for simplicity.

GameboyEmulator/
โ”œโ”€โ”€ src/
โ”‚   โ”œโ”€โ”€ GameboyEmulator/
โ”‚   โ”‚   โ”œโ”€โ”€ Cpu.fs
โ”‚   โ”‚   โ”œโ”€โ”€ Mmu.fs         <-- New file for Memory Management Unit
โ”‚   โ”‚   โ”œโ”€โ”€ Program.fs
โ”‚   โ”‚   โ””โ”€โ”€ GameboyEmulator.fsproj

Step-by-Step Implementation

1. Define Memory Constants

First, let’s define the memory region boundaries as constants. These will be crucial for dispatching read/write operations.

Create a new file src/GameboyEmulator/Mmu.fs and add the following:

// src/GameboyEmulator/Mmu.fs

module Mmu

/// Defines the Game Boy's memory map boundaries and sizes.
module MemoryMap =
    let RomStart      = 0x0000u
    let RomEnd        = 0x7FFFu // 32KB
    let VramStart     = 0x8000u
    let VramEnd       = 0x9FFFu // 8KB
    let ExtRamStart   = 0xA000u
    let ExtRamEnd     = 0xBFFFu // 8KB
    let WramStart     = 0xC000u
    let WramEnd       = 0xDFFFu // 8KB
    let EchoRamStart  = 0xE000u
    let EchoRamEnd    = 0xFDFFu // 7.5KB - mirror of WRAM
    let OamStart      = 0xFE00u
    let OamEnd        = 0xFE9Fu // 160 bytes
    let UnusableStart = 0xFEA0u
    let UnusableEnd   = 0xFEFFu // 96 bytes - should not be accessed
    let IoStart       = 0xFF00u
    let IoEnd         = 0xFF7Fu // 128 bytes
    let HramStart     = 0xFF80u
    let HramEnd       = 0xFFFEu // 127 bytes
    let IeRegister    = 0xFFFFu // 1 byte

    let WramSize      = (WramEnd - WramStart + 1u) |> int
    let VramSize      = (VramEnd - VramStart + 1u) |> int
    let OamSize       = (OamEnd - OamStart + 1u) |> int
    let IoSize        = (IoEnd - IoStart + 1u) |> int
    let HramSize      = (HramEnd - HramStart + 1u) |> int
    // Note: ROM size varies based on cartridge. We'll handle a default 32KB for now.
    // External RAM also varies. We'll reserve 8KB for now.
    let ExtRamSize    = (ExtRamEnd - ExtRamStart + 1u) |> int

Explanation:

  • module Mmu and module MemoryMap: We’re using nested modules to organize our code, keeping memory-related constants within MemoryMap for clarity.
  • u suffix: Denotes an unsigned 16-bit integer (uint16), which is appropriate for Game Boy addresses.
  • int conversion: Used when calculating array sizes, as F# arrays expect int for their length.

2. Define the Memory Record

Now, let’s define the Memory record type. This record will hold the byte arrays representing our different memory regions.

Append this to src/GameboyEmulator/Mmu.fs:

// src/GameboyEmulator/Mmu.fs (continued)

/// Represents the Game Boy's entire memory space.
type Memory = {
    rom       : byte array // Cartridge ROM (first 32KB, more with MBCs)
    vram      : byte array // Video RAM
    extRam    : byte array // External RAM (on cartridge)
    wram      : byte array // Work RAM
    oam       : byte array // Object Attribute Memory
    io        : byte array // I/O Registers
    hram      : byte array // High RAM
    ie        : byte       // Interrupt Enable Register
    // Add other components as they are implemented
}

Explanation:

  • type Memory = { ... }: This F# record defines the state of our MMU. Each field is a byte array for memory regions or a single byte for specific registers.
  • rom: This will initially hold the first 32KB of the cartridge. Later, we’ll introduce Memory Bank Controllers (MBCs) to swap in larger ROMs.
  • ie: This is a single byte register, so it’s directly represented as byte.

3. Initialize Memory

We need a way to create an initial Memory state, typically filled with default values (like 0xFF for uninitialized memory, or 0x00).

Append this to src/GameboyEmulator/Mmu.fs:

// src/GameboyEmulator/Mmu.fs (continued)

/// Creates an initial Memory state, typically with all bytes set to 0xFF.
let init () : Memory =
    {
        rom       = Array.create (MemoryMap.RomEnd - MemoryMap.RomStart + 1u |> int) 0x00uy // Default to 0x00 for ROM, will be overwritten
        vram      = Array.create MemoryMap.VramSize 0xFFuy
        extRam    = Array.create MemoryMap.ExtRamSize 0xFFuy
        wram      = Array.create MemoryMap.WramSize 0xFFuy
        oam       = Array.create MemoryMap.OamSize 0xFFuy
        io        = Array.create MemoryMap.IoSize 0xFFuy
        hram      = Array.create MemoryMap.HramSize 0xFFuy
        ie        = 0x00uy // Interrupt Enable Register typically starts at 0
    }

Explanation:

  • let init () : Memory: A function that returns a new Memory record.
  • Array.create size value: F# function to create a byte array of a given size, initialized with value.
  • 0xFFuy: The uy suffix denotes an unsigned byte. Game Boy memory is typically initialized to 0xFF unless specified otherwise, representing an “open bus” state. We initialize ROM to 0x00 as it will be immediately overwritten.

4. Load Cartridge ROM

The core purpose of the MMU is to load the game itself. This function takes the raw bytes of a Game Boy ROM file and places them into the rom region of our Memory state.

Append this to src/GameboyEmulator/Mmu.fs:

// src/GameboyEmulator/Mmu.fs (continued)

/// Loads a Game Boy ROM byte array into the memory's ROM region.
let loadRom (romData: byte array) (memory: Memory) : Memory =
    let romSize = min romData.Length memory.rom.Length // Don't write beyond allocated ROM
    Array.blit romData 0 memory.rom 0 romSize
    { memory with rom = memory.rom } // Return new memory state with updated ROM

Explanation:

  • loadRom (romData: byte array) (memory: Memory) : Memory: This function takes the ROM data and the current Memory state, returning a new Memory state with the ROM loaded. This adheres to functional programming principles of immutability.
  • Array.blit: Efficiently copies a section of one array to another.
    • romData: Source array.
    • 0: Source start index.
    • memory.rom: Destination array.
    • 0: Destination start index.
    • romSize: Number of elements to copy.
  • { memory with rom = memory.rom }: This is F#’s record update syntax. It creates a new Memory record, copying all fields from the original memory but explicitly setting rom to the (now modified) memory.rom array. While the array itself is mutable, the Memory record reference is immutable, which is a common F# pattern for performance-critical mutable data structures within immutable records.

5. Implement readByte

This is the central function for fetching data from memory. Given an address, it determines which memory region that address falls into and returns the byte from that region.

Append this to src/GameboyEmulator/Mmu.fs:

// src/GameboyEmulator/Mmu.fs (continued)

/// Reads a single byte from the specified memory address.
let readByte (addr: uint16) (memory: Memory) : byte =
    match addr with
    | a when a >= MemoryMap.RomStart && a <= MemoryMap.RomEnd ->
        memory.rom.[int (a - MemoryMap.RomStart)]
    | a when a >= MemoryMap.VramStart && a <= MemoryMap.VramEnd ->
        memory.vram.[int (a - MemoryMap.VramStart)]
    | a when a >= MemoryMap.ExtRamStart && a <= MemoryMap.ExtRamEnd ->
        memory.extRam.[int (a - MemoryMap.ExtRamStart)]
    | a when a >= MemoryMap.WramStart && a <= MemoryMap.WramEnd ->
        memory.wram.[int (a - MemoryMap.WramStart)]
    | a when a >= MemoryMap.EchoRamStart && a <= MemoryMap.EchoRamEnd ->
        // Echo RAM is a mirror of WRAM (0xC000-0xDFFF)
        let wramAddr = a - MemoryMap.EchoRamStart + MemoryMap.WramStart
        memory.wram.[int (wramAddr - MemoryMap.WramStart)]
    | a when a >= MemoryMap.OamStart && a <= MemoryMap.OamEnd ->
        memory.oam.[int (a - MemoryMap.OamStart)]
    | a when a >= MemoryMap.UnusableStart && a <= MemoryMap.UnusableEnd ->
        // Reading from unusable area returns 0xFF
        0xFFuy
    | a when a >= MemoryMap.IoStart && a <= MemoryMap.IoEnd ->
        memory.io.[int (a - MemoryMap.IoStart)]
    | a when a >= MemoryMap.HramStart && a <= MemoryMap.HramEnd ->
        memory.hram.[int (a - MemoryMap.HramStart)]
    | a when a = MemoryMap.IeRegister ->
        memory.ie
    | _ ->
        // Unknown or unhandled address, typically returns 0xFF (open bus)
        // In a real emulator, this might log a warning or be more specific.
        0xFFuy // Default for unmapped memory

Explanation:

  • match addr with | a when ... ->: This is F#’s powerful pattern matching. We match the addr against ranges defined in MemoryMap.
  • a - MemoryMap.RomStart: We subtract the base address of the region to get the correct offset within that region’s dedicated byte array.
  • int (...): Converts the uint16 offset to an int for array indexing.
  • Echo RAM: This region (0xE000-0xFDFF) is a direct mirror of WRAM (0xC000-0xDFFF). Reads/writes to Echo RAM directly affect WRAM. Our readByte logic correctly redirects the access.
  • Unusable/Unknown: For regions that shouldn’t be accessed or are not yet implemented, we return 0xFFuy, which is common behavior for “open bus” or uninitialized memory in hardware.

6. Implement writeByte

Similar to readByte, this function handles writing data to the correct memory region based on the address.

Append this to src/GameboyEmulator/Mmu.fs:

// src/GameboyEmulator/Mmu.fs (continued)

/// Writes a single byte to the specified memory address.
let writeByte (addr: uint16) (value: byte) (memory: Memory) : Memory =
    match addr with
    | a when a >= MemoryMap.RomStart && a <= MemoryMap.RomEnd ->
        // ROM is read-only, writes are typically ignored or modify MBC registers
        // For now, we'll just ignore it.
        memory // Return unchanged memory
    | a when a >= MemoryMap.VramStart && a <= MemoryMap.VramEnd ->
        memory.vram.[int (a - MemoryMap.VramStart)] <- value
        memory // Return unchanged memory (array modified in place)
    | a when a >= MemoryMap.ExtRamStart && a <= MemoryMap.ExtRamEnd ->
        memory.extRam.[int (a - MemoryMap.ExtRamStart)] <- value
        memory
    | a when a >= MemoryMap.WramStart && a <= MemoryMap.WramEnd ->
        memory.wram.[int (a - MemoryMap.WramStart)] <- value
        memory
    | a when a >= MemoryMap.EchoRamStart && a <= MemoryMap.EchoRamEnd ->
        // Echo RAM is a mirror of WRAM
        let wramAddr = a - MemoryMap.EchoRamStart + MemoryMap.WramStart
        memory.wram.[int (wramAddr - MemoryMap.WramStart)] <- value
        memory
    | a when a >= MemoryMap.OamStart && a <= MemoryMap.OamEnd ->
        memory.oam.[int (a - MemoryMap.OamStart)] <- value
        memory
    | a when a >= MemoryMap.UnusableStart && a <= MemoryMap.UnusableEnd ->
        // Writes to unusable area are ignored
        memory
    | a when a >= MemoryMap.IoStart && a <= MemoryMap.IoEnd ->
        memory.io.[int (a - MemoryMap.IoStart)] <- value
        memory
    | a when a >= MemoryMap.HramStart && a <= MemoryMap.HramEnd ->
        memory.hram.[int (a - MemoryMap.HramStart)] <- value
        memory
    | a when a = MemoryMap.IeRegister ->
        { memory with ie = value } // Return new memory state with updated IE register
    | _ ->
        // Unknown or unhandled address, ignore write for now
        memory

Explanation:

  • writeByte (addr: uint16) (value: byte) (memory: Memory) : Memory: Takes the address, value to write, and current Memory state, returning a new Memory state.
  • <-: This is the F# mutable assignment operator. Since byte array is a mutable reference type, we can modify its contents directly.
  • { memory with ie = value }: For the ie (Interrupt Enable) register, which is a single byte field in the Memory record, we use the record update syntax to create a new Memory record with the updated ie value. This maintains immutability for the record itself.
  • ROM Write: Writes to ROM (0x0000-0x7FFF) are generally ignored by the hardware, or they control Memory Bank Controllers (MBCs) which we’ll implement later. For now, we simply return the original memory unchanged.
  • Unusable/Unknown: Writes to these areas are also ignored.

Testing & Verification

Now that we have our Memory module, let’s verify its basic functionality. We’ll add some simple tests to Program.fs to ensure ROM loading and memory R/W operations work as expected.

Open src/GameboyEmulator/Program.fs and modify it:

// src/GameboyEmulator/Program.fs

open System
open System.IO
open Mmu // Import our new Mmu module
open Cpu // Assuming you have Cpu.fs from the previous chapter

// Represents the overall state of the Game Boy system
type GameBoy = {
    cpu    : Cpu.State
    memory : Mmu.Memory
    // Other components will be added here (PPU, APU, etc.)
}

[<EntryPoint>]
let main argv =
    printfn "Starting Game Boy Emulator..."

    // --- MMU Testing ---
    let initialMemory = Mmu.init ()
    printfn "Memory initialized."

    // Create a dummy ROM for testing
    let dummyRom = [| 0xC3uy; 0x00uy; 0x01uy; 0xDEuy; 0xADuy; 0xBEuy; 0xEFuy |] // Simple JR 0x01, then some data
    let memoryWithRom = Mmu.loadRom dummyRom initialMemory
    printfn "Dummy ROM loaded."

    // Verify ROM read
    let romByte0 = Mmu.readByte 0x0000u memoryWithRom
    let romByte1 = Mmu.readByte 0x0001u memoryWithRom
    let romByte2 = Mmu.readByte 0x0002u memoryWithRom
    printfn "Read from ROM: 0x%02X, 0x%02X, 0x%02X (Expected: 0xC3, 0x00, 0x01)" romByte0 romByte1 romByte2
    // Expected: 0xC3, 0x00, 0x01

    // Verify WRAM write/read
    let addressWram = Mmu.MemoryMap.WramStart + 0x10u // Example address in WRAM
    let memoryAfterWramWrite = Mmu.writeByte addressWram 0xAAuy memoryWithRom
    let readWram = Mmu.readByte addressWram memoryAfterWramWrite
    printfn "Wrote 0xAA to WRAM at 0x%04X, Read: 0x%02X (Expected: 0xAA)" addressWram readWram
    // Expected: 0xAA

    // Verify Echo RAM write/read (should affect WRAM)
    let addressEchoRam = Mmu.MemoryMap.EchoRamStart + 0x10u // Echo of WRAM 0xC010
    let memoryAfterEchoWrite = Mmu.writeByte addressEchoRam 0xBBuy memoryAfterWramWrite
    let readEchoRam = Mmu.readByte addressEchoRam memoryAfterEchoWrite
    let readWramViaEcho = Mmu.readByte addressWram memoryAfterEchoWrite // Read WRAM directly
    printfn "Wrote 0xBB to Echo RAM at 0x%04X, Read: 0x%02X (Expected: 0xBB)" addressEchoRam readEchoRam
    printfn "WRAM at 0x%04X now contains: 0x%02X (Expected: 0xBB)" addressWram readWramViaEcho
    // Expected: 0xBB for both

    // Verify ROM write attempt (should be ignored)
    let memoryAfterRomWriteAttempt = Mmu.writeByte 0x0000u 0xFFuy memoryAfterEchoWrite
    let romByte0AfterWrite = Mmu.readByte 0x0000u memoryAfterRomWriteAttempt
    printfn "Attempted to write 0xFF to ROM at 0x0000. Read back: 0x%02X (Expected: 0xC3)" romByte0AfterWrite
    // Expected: 0xC3 (original value)

    // Verify IE register write/read
    let memoryAfterIeWrite = Mmu.writeByte Mmu.MemoryMap.IeRegister 0x01uy memoryAfterRomWriteAttempt
    let ieValue = Mmu.readByte Mmu.MemoryMap.IeRegister memoryAfterIeWrite
    printfn "Wrote 0x01 to IE register, Read: 0x%02X (Expected: 0x01)" ieValue
    // Expected: 0x01

    // Instantiate the GameBoy state
    let gameBoyState = {
        cpu = Cpu.init ()
        memory = memoryAfterIeWrite // Use the memory state after all tests
    }

    // You can now access gameBoyState.cpu and gameBoyState.memory
    printfn "GameBoy system state initialized."

    0 // return an integer exit code

Verification Steps:

  1. Save all files (Cpu.fs, Mmu.fs, Program.fs).
  2. Open your terminal in the src/GameboyEmulator/ directory.
  3. Run dotnet run.

Expected Output (console):

Starting Game Boy Emulator...
Memory initialized.
Dummy ROM loaded.
Read from ROM: 0xC3, 0x00, 0x01 (Expected: 0xC3, 0x00, 0x01)
Wrote 0xAA to WRAM at 0xC010, Read: 0xAA (Expected: 0xAA)
Wrote 0xBB to Echo RAM at 0xE010, Read: 0xBB (Expected: 0xBB)
WRAM at 0xC010 now contains: 0xBB (Expected: 0xBB)
Attempted to write 0xFF to ROM at 0x0000. Read back: 0xC3 (Expected: 0xC3)
Wrote 0x01 to IE register, Read: 0x01 (Expected: 0x01)
GameBoy system state initialized.

If your output matches, congratulations! Your basic MMU is working. The CPU can now fetch instructions and interact with memory.

Production Considerations

Performance

readByte and writeByte are arguably the most frequently called functions in an emulator’s core loop. Every CPU instruction fetch and data access goes through them.

  • F# Pattern Matching: While expressive, using match statements with many ranges can introduce a slight overhead compared to direct if/elif chains or lookup tables. For now, this is perfectly acceptable and readable. If profiling shows this as a bottleneck later, we can optimize.
  • Array Access: F# arrays (byte array) are efficient for raw byte storage.
  • Immutability vs. Mutability: We’ve used a hybrid approach. The Memory record itself is treated immutably (functions return new records), but the underlying byte array instances are mutable. This is a pragmatic choice in F# when performance is critical for large, stateful data structures. Creating new byte arrays on every write would be prohibitively slow.

Maintainability

  • Clear Memory Map: Defining constants in Mmu.MemoryMap makes the code readable and easy to update if specific address ranges need adjustment.
  • Modular Design: Keeping MMU logic in Mmu.fs separates concerns, making it easier to reason about and extend (e.g., adding MBCs).

Future Enhancements: Memory Bank Controllers (MBCs)

  • Larger ROMs: Many Game Boy games are larger than 32KB. They use MBCs, which are specialized chips on the cartridge that swap different “banks” of ROM (and sometimes external RAM) into the 0x0000-0x7FFF and 0xA000-0xBFFF regions. Our current rom array only holds 32KB. Implementing MBCs will be a significant future step, requiring writeByte to specific addresses in the ROM region to trigger bank switching.
  • Boot ROM: The Game Boy actually has a small 256-byte internal boot ROM that runs on startup. This ROM is mapped to 0x0000-0x00FF initially, then unmapped after execution. We’ll address this when we set up the system’s boot process.

Common Issues & Solutions

  1. Off-by-one Errors in Address Ranges:

    • Issue: Incorrectly calculating array offsets (e.g., a - MemoryMap.RomStart - 1u). This can lead to IndexOutOfRangeException.
    • Solution: Double-check your subtraction logic. Remember that RomStart is the base, so a - RomStart gives the 0-indexed offset. The + 1u in size calculations ensures the end address is included. Use explicit uint16 types to prevent implicit integer conversions that might hide issues.
  2. Incorrect Handling of Read-Only Regions:

    • Issue: Accidentally allowing writes to ROM or unusable areas, which can corrupt the game’s code or lead to unexpected behavior.
    • Solution: Ensure your writeByte function explicitly handles read-only regions by doing nothing or logging a warning. Our current implementation ignores writes to ROM, which is generally correct for the hardware’s behavior.
  3. Performance Bottlenecks with readByte/writeByte:

    • Issue: If your emulator is slow, and profiling points to these functions, the current match statement might be too slow for extremely tight loops.
    • Solution: For now, optimize only if profiling indicates it’s a problem. Future optimizations might include:
      • A single byte array representing the entire 64KB, with direct indexing (though this makes managing distinct regions harder).
      • Pre-calculating a lookup table for faster address-to-region mapping.
      • Using inline for these functions in F# to reduce call overhead (though the compiler often does this automatically).

Summary & Next Step

You’ve successfully built the foundation for the Game Boy’s memory system!

  • You’ve mapped out the Game Boy’s 64KB address space.
  • You’ve implemented a Memory record in F# to hold different memory regions.
  • Crucially, you’ve created readByte and writeByte functions that correctly dispatch memory accesses to the appropriate regions.
  • Your emulator can now load a basic ROM and interact with its internal RAM.

This MMU is the glue that connects the CPU to the rest of the Game Boy’s hardware. With this in place, the CPU can now fetch instructions and data, bringing us closer to executing real Game Boy code.

The next step will be to integrate this Memory module with our Cpu module. We’ll modify the CPU to use Mmu.readByte to fetch opcodes and operands, and Mmu.writeByte to store results, finally enabling our CPU to execute a small program.


๐Ÿง  Check Your Understanding

  • Why is an MMU necessary in a system like the Game Boy, rather than the CPU directly addressing components?
  • Explain the difference between WRAM and Echo RAM in the Game Boy’s memory map. Why are they implemented this way?
  • In our F# Memory record, why do we return a new Memory record when updating ie, but return the same memory record when modifying memory.wram.[...]?

โšก Mini Task

Modify the writeByte function to print a console warning (printfn "Warning: Write to unhandled address 0x%04X" addr) if an address falls into the _ -> (catch-all) case. This can be useful for debugging later.

๐Ÿš€ Scenario

You are debugging a Game Boy ROM that occasionally crashes with an IndexOutOfRangeException when trying to access memory. You suspect it’s related to an incorrect address calculation in your readByte function. The crash occurs when the CPU tries to read from 0x8000.

  • Which memory region is 0x8000 supposed to be in?
  • What specific line of code in readByte should you investigate for potential off-by-one errors or incorrect range checks for that region?
  • How would you add a temporary logging statement to pinpoint the exact value of (a - MemoryMap.VramStart) right before the array access?

๐Ÿ“Œ TL;DR

  • The Game Boy MMU maps the CPU’s 64KB address space to various hardware components.
  • Key memory regions include ROM, VRAM, WRAM, OAM, I/O, and HRAM.
  • readByte and writeByte functions are critical for all CPU memory interactions.

๐Ÿง  Core Flow

  1. Define memory map constants for address ranges.
  2. Create an F# Memory record holding byte arrays for each region.
  3. Implement init to set up initial memory state.
  4. Implement loadRom to copy cartridge data into the ROM region.
  5. Implement readByte and writeByte using pattern matching over address ranges to dispatch to correct memory regions.

๐Ÿš€ Key Takeaway

A well-designed MMU is the foundational abstraction layer for any emulator. It manages the complex interplay between the CPU and diverse memory-mapped hardware, providing a consistent interface for the CPU while abstracting away the underlying physical layout.

References

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