This chapter builds upon our foundational Picture Processing Unit (PPU) work, where we established background tile rendering. Now, we’ll introduce the dynamic elements that bring games to life: sprites (movable objects), background scrolling, and the crucial LCD Control Register, which dictates how the display operates. By the end of this milestone, your emulator will be able to render basic sprites, scroll the background, and respond to fundamental display settings, making it capable of running more visually complex Game Boy ROMs.

Understanding these components is vital because they represent the core mechanisms by which games animate characters, display scores, and create immersive environments. Incorrect implementation of any of these can lead to visual glitches, tearing, or even unplayable games. We’ll focus on accurately reflecting the Game Boy’s hardware behavior using F#’s expressive types.

Project Overview

This project is about building a Game Boy emulator from scratch in F#. We’re taking a ground-up approach to understand low-level system design and computer architecture. Each chapter focuses on emulating a specific hardware component, verifying its behavior, and integrating it into the larger system. This chapter specifically enhances the visual output, moving beyond static backgrounds to dynamic game elements.

Tech Stack

For this chapter, we continue to leverage:

  • F# (version 8.0): The primary language for its strong type system, functional paradigms, and conciseness, which helps in modeling hardware states immutably.
  • .NET SDK (version 8.0): Provides the runtime and tooling for F# development, ensuring cross-platform compatibility.
  • Silk.NET.Windowing (version 2.19.0): A modern, cross-platform library used for creating windows and handling graphics, serving as our display layer. It is a robust alternative to older SDL.NET bindings.

We aim to use the latest stable releases as of 2026-05-05 to ensure we’re building with current best practices.

Build Plan

To achieve dynamic visuals, our plan for this chapter is structured into several key milestones:

  1. Define Sprite Structure: Model Game Boy sprites and their attributes using F# records.
  2. Extend PPU State: Incorporate new registers like LCDC, SCX, SCY, OBP0, OBP1 into our existing PPU record.
  3. Implement MMU Register Access: Add logic to the MMU to correctly handle CPU reads and writes to these new PPU registers, including critical access restrictions.
  4. Refine PPU Timing and Modes: Update stepPPU to respond to LCDC enable/disable, LYC=LY coincidence, and STAT register interrupt flags.
  5. Enhance drawScanline for Background Scrolling: Implement SCX and SCY offsets to accurately render the scrolled background.
  6. Implement Sprite Rendering:
    • Scan Object Attribute Memory (OAM) for sprites visible on the current scanline.
    • Sort sprites based on Game Boy’s priority rules (X-coordinate, OAM index).
    • Render each sprite, applying its attributes (flips, palettes, background priority).

Architecture

The Game Boy’s PPU operates as a state machine, processing scanlines sequentially. Our F# implementation models this by updating an immutable PPU state within the MMU (Memory Management Unit). The CPU interacts with the PPU by reading and writing to its memory-mapped registers via the MMU.

PPU Rendering Flow with Sprites and Scrolling

The core rendering loop, which processes one scanline at a time, will be enhanced. For each pixel on a scanline:

  • First, determine the background pixel, accounting for SCX and SCY.
  • Then, identify relevant sprites that overlap with the current scanline.
  • For each relevant sprite, calculate its pixel.
  • Finally, combine the background and sprite pixels based on sprite priority rules (X-coordinate, OAM index, and background priority bit).
flowchart TD Start[Start Scanline] --> ProcessBG[Process Background] ProcessBG --> ProcessSprites[Process Sprites] ProcessSprites --> Combine[Combine Pixels] Combine --> Output[Output to LCD] Output --> End[End Scanline]

๐Ÿง  Important: The Game Boy processes pixels from left to right for each scanline. Sprite rendering needs to consider this order, along with their X-coordinates and other attributes, to correctly layer them over the background.

Data Structures and Register Mapping

We’ll extend our PPU state to include the new registers and a way to represent sprite data.

  • PPU State: Add scx, scy, lcdc, stat, lyc, obp0, obp1, dma and oam fields.

  • Sprite Structure: A record to hold parsed OAM data for each sprite.

  • OAM (Object Attribute Memory): Located at 0xFE00-0xFE9F, this 160-byte region holds attributes for up to 40 sprites. Each sprite uses 4 bytes.

    • Byte 0: Y-position (minus 16)
    • Byte 1: X-position (minus 8)
    • Byte 2: Tile Index (0-255)
    • Byte 3: Attributes (Palette, X/Y Flip, BG Priority, CGB Palette)

Register Overview

  • LCDC (LCD Control): 0xFF40

    • Bit 7: LCD and PPU enable (0=Off, 1=On)
    • Bit 6: Window Tile Map Display Select (0=9800-9BFF, 1=9C00-9FFF)
    • Bit 5: Window Display Enable (0=Off, 1=On)
    • Bit 4: BG & Window Tile Data Select (0=8800-97FF, 1=8000-8FFF)
    • Bit 3: BG Tile Map Display Select (0=9800-9BFF, 1=9C00-9FFF)
    • Bit 2: OBJ (Sprite) Size (0=8x8, 1=8x16)
    • Bit 1: OBJ (Sprite) Display Enable (0=Off, 1=On)
    • Bit 0: BG and Window Display Enable (0=Off, 1=On)
  • SCY (Scroll Y): 0xFF42 (8-bit) - Y position of the top-left pixel of the background map to be displayed.

  • SCX (Scroll X): 0xFF43 (8-bit) - X position of the top-left pixel of the background map to be displayed.

  • STAT (LCD Status): 0xFF41 - Contains PPU mode, LYC=LY coincidence flag, and interrupt enable flags.

  • LYC (LY Compare): 0xFF45 - Compared against LY (current scanline).

  • OBP0/OBP1 (Object Palettes): 0xFF48, 0xFF49 - Define colors for sprites.

  • DMA (DMA Transfer): 0xFF46 - Triggers a transfer from main memory to OAM.

Step-by-Step Implementation

We’ll start by defining our sprite structure and then integrate sprite rendering, scrolling, and LCD control into our existing PPU logic.

1. Define PpuMode and Sprite Structure

First, ensure PpuMode is explicitly defined with the correct Game Boy hardware values, typically in Types.fs. Then, update PPU.fs with the Sprite record and the extended PPU state.

// src/GameBoyEmulator/Types.fs

module GameBoyEmulator.Types

// ... (existing types like PixelColor, ColorPalette)

/// Game Boy PPU Modes as defined by hardware (STAT register bits 0-1)
type PpuMode =
    | HBlank = 0x00
    | VBlank = 0x01
    | OamScan = 0x02
    | Drawing = 0x03

// ... (other shared types)

Explanation: The PpuMode discriminated union maps directly to the 2-bit mode field in the STAT register, providing clear, type-safe representation of the PPU’s operational state.

Now, let’s define the Sprite record and update the PPU state.

// src/GameBoyEmulator/PPU.fs

module GameBoyEmulator.PPU

open System
open GameBoyEmulator.MMU // Assuming MMU is needed for memory access
open GameBoyEmulator.Types // Assuming shared types like PixelColor, ColorPalette, PpuMode

// ... (existing types like PixelColor, ColorPalette, Tile)

/// Represents a single Game Boy sprite (Object)
type Sprite =
    { Y          : byte
      X          : byte
      TileIndex  : byte
      PaletteNum : byte // DMG palette is 0 or 1 (OBP0 or OBP1)
      XFlip      : bool
      YFlip      : bool
      BgPriority : bool // True if sprite is behind BG/Window (color 1-3)
      OamIndex   : int  // Original index in OAM for priority tie-breaking
    }

/// PPU state, updated with new registers
type PPU =
    { scanline  : int // Current PPU scanline (0-153)
      cycles    : int // PPU cycles accumulated for current mode
      mode      : PpuMode // Current PPU operating mode
      lcdBuffer : PixelColor array // The frame buffer (160 * 144 pixels)
      vram      : byte array // Video RAM (0x8000-0x9FFF)
      // New PPU registers
      lcdc      : byte // LCD Control Register (0xFF40)
      stat      : byte // LCD Status Register (0xFF41)
      scy       : byte // Scroll Y Register (0xFF42)
      scx       : byte // Scroll X Register (0xFF43)
      ly        : byte // LCD Y-Coordinate Register (0xFF44), already present
      lyc       : byte // LY Compare Register (0xFF45)
      bgp       : byte // BG Palette Data (0xFF47), already present
      obp0      : byte // OBJ Palette 0 Data (0xFF48)
      obp1      : byte // OBJ Palette 1 Data (0xFF49)
      dma       : byte // DMA Transfer and Start Address Register (0xFF46) - will be handled by MMU
      mutable oam : byte array // Object Attribute Memory (0xFE00-0xFE9F)
      // ... other existing fields
    }

// ... (existing functions)

Explanation:

  • Sprite record: Directly mirrors the 4-byte OAM entry, but parsed into more readable fields. OamIndex is added for priority resolution, as sprites with the same X-coordinate are prioritized by their OAM index.
  • PPU record: We’ve added lcdc, stat, scy, scx, lyc, obp0, obp1, dma, and oam to the PPU’s internal state. While dma is primarily handled by the MMU, the PPU needs access to oam for sprite data. We make oam mutable because DMA transfers directly into it, and the CPU can write to it, which is a common pattern for hardware registers in an emulator.

2. Initialize PPU State

Update your initPPU function to set initial values for these new registers. This ensures the PPU starts in a well-defined state, mimicking a real Game Boy’s power-on behavior.

// src/GameBoyEmulator/PPU.fs

// ... (inside PPU module)

let initPPU (cartridge: Cartridge.Cartridge) : PPU = // Assuming cartridge is passed for completeness
    { scanline = 0
      cycles = 0
      mode = PpuMode.OamScan // Start in OAM Scan mode
      lcdBuffer = Array.init (160 * 144) (fun _ -> White) // Clear screen to white
      vram = Array.zeroCreate 0x2000 // 8KB VRAM
      lcdc = 0x91uy // Default power-on value for LCDC
      stat = 0x80uy // Default power-on value for STAT (Bit 7 always 1)
      scy = 0x00uy
      scx = 0x00uy
      lyc = 0x00uy
      ly = 0x00uy
      bgp = 0xFCuy // Default palette (White, LightGray, DarkGray, Black)
      obp0 = 0xFFuy // Default sprite palette 0
      obp1 = 0xFFuy // Default sprite palette 1
      dma = 0x00uy
      oam = Array.zeroCreate 0xA0 // 160 bytes for OAM (40 sprites * 4 bytes)
      // ... other existing fields
    }

Explanation:

  • lcdc = 0x91uy: This is the typical power-on value for the Game Boy’s LCDC register, meaning LCD is on, BG/Window display is on, 8x8 sprites, etc.
  • stat = 0x80uy: Bit 7 is always 1 as per hardware spec.
  • oam = Array.zeroCreate 0xA0: Allocates 160 bytes for OAM.

3. Implement Register Read/Write in MMU

The CPU interacts with PPU registers through the MMU. We need to add logic to MMU.fs to handle reads and writes to 0xFF40-0xFF49. This is a critical step for the CPU to be able to control the PPU.

// src/GameBoyEmulator/MMU.fs

module GameBoyEmulator.MMU

open System
open GameBoyEmulator.Types
open GameBoyEmulator.PPU
open GameBoyEmulator.Cartridge

// ... (existing types like MMU)

let writeByte (mmu: MMU) (addr: word) (value: byte) : MMU =
    match addr with
    // ... existing memory regions
    | addr when addr >= 0x8000us && addr <= 0x9FFFus -> // VRAM
        // CPU can only access VRAM outside PPU drawing mode (Mode 3)
        if mmu.ppu.mode <> PpuMode.Drawing then
            { mmu with ppu = { mmu.ppu with vram = (mmu.ppu.vram |> Array.mapi (fun i v -> if i = (int addr - 0x8000) then value else v)) } }
        else mmu // Ignore write if PPU is drawing
    | addr when addr >= 0xFE00us && addr <= 0xFE9Fus -> // OAM
        // CPU can only access OAM outside PPU OAM Scan (Mode 2) and Drawing (Mode 3) modes
        if mmu.ppu.mode <> PpuMode.OamScan && mmu.ppu.mode <> PpuMode.Drawing then
            { mmu with ppu = { mmu.ppu with oam = (mmu.ppu.oam |> Array.mapi (fun i v -> if i = (int addr - 0xFE00) then value else v)) } }
        else mmu // Ignore write if PPU is scanning OAM or drawing
    | 0xFF40us -> { mmu with ppu = { mmu.ppu with lcdc = value } } // LCDC
    | 0xFF41us -> // STAT (only bits 3-6 writable by CPU, bits 0-2 for mode, bit 7 for LYC=LY coincidence)
        { mmu with ppu = { mmu.ppu with stat = (value &&& 0x78uy) ||| (mmu.ppu.stat &&& 0x87uy) } }
    | 0xFF42us -> { mmu with ppu = { mmu.ppu with scy = value } } // SCY
    | 0xFF43us -> { mmu with ppu = { mmu.ppu with scx = value } } // SCX
    | 0xFF45us -> { mmu with ppu = { mmu.ppu with lyc = value } } // LYC
    | 0xFF46us -> // DMA Transfer
        // This is a special register. Writing to it triggers a DMA transfer from (value * 0x100) to OAM
        let sourceAddr = word (value) <<< 8 // Source address is value * 0x100
        let newMmu = { mmu with ppu = { mmu.ppu with dma = value } } // Store DMA value
        // Perform DMA transfer: copy 160 bytes from sourceAddr to OAM (0xFE00-0xFE9F)
        let oamData = Array.init 0xA0 (fun i -> newMmu |> readByte (sourceAddr + word i))
        { newMmu with ppu = { newMmu.ppu with oam = oamData } }
    | 0xFF47us -> { mmu with ppu = { mmu.ppu with bgp = value } } // BGP
    | 0xFF48us -> { mmu with ppu = { mmu.ppu with obp0 = value } } // OBP0
    | 0xFF49us -> { mmu with ppu = { mmu.ppu with obp1 = value } } // OBP1
    // ... other I/O registers
    | _ -> mmu // Default: no change for unhandled addresses


let readByte (mmu: MMU) (addr: word) : byte =
    match addr with
    // ... existing memory regions
    | 0xFF40us -> mmu.ppu.lcdc
    | 0xFF41us -> // STAT register read. Bits 0-2 contain the current PPU mode.
                  // Bit 7 is always 1. Bits 3-6 are interrupt enable flags.
        let modeBits = byte mmu.ppu.mode // Get the byte value of the current PpuMode
        (mmu.ppu.stat &&& 0xFCuy) ||| modeBits // Clear bits 0-1 (mode) and OR in the current mode
    | 0xFF42us -> mmu.ppu.scy
    | 0xFF43us -> mmu.ppu.scx
    | 0xFF44us -> mmu.ppu.ly // LY is read-only
    | 0xFF45us -> mmu.ppu.lyc
    | 0xFF46us -> mmu.ppu.dma
    | 0xFF47us -> mmu.ppu.bgp
    | 0xFF48us -> mmu.ppu.obp0
    | 0xFF49us -> mmu.ppu.obp1
    | addr when addr >= 0xFE00us && addr <= 0xFE9Fus -> // OAM
        // CPU can only access OAM outside PPU OAM Scan and Drawing modes
        if mmu.ppu.mode <> PpuMode.OamScan && mmu.ppu.mode <> PpuMode.Drawing then
            mmu.ppu.oam.[int addr - 0xFE00]
        else 0xFFuy // Return 0xFF during restricted OAM access
    // ... other I/O registers
    | _ -> // For other addresses, read from internal RAM or cartridge
        // ... existing read logic
        if addr < 0x8000us then mmu.cartridge.readByte addr // Read from ROM
        elif addr >= 0xC000us && addr <= 0xDFFFus then mmu.ram.[int addr] // WRAM
        elif addr >= 0xE000us && addr <= 0xFDFFus then mmu.ram.[int addr - 0x2000] // Echo RAM
        elif addr >= 0xFF00us && addr <= 0xFF7Fus then mmu.ram.[int addr] // IO Registers (some handled above)
        elif addr >= 0xFF80us && addr <= 0xFFFEus then mmu.ram.[int addr] // HRAM
        else 0xFFuy // Fallback for unhandled addresses or invalid reads

Explanation:

  • VRAM/OAM Access Restrictions: Critical for accuracy! The CPU cannot access VRAM or OAM during certain PPU modes (Drawing, OAM Scan). Writes are ignored, reads return 0xFF. This prevents visual tearing or glitches that would occur if the CPU modified display data while the PPU was actively reading it.
  • STAT Register (0xFF41us):
    • Write: Only bits 3-6 are writable by the CPU (interrupt enable flags). Bits 0-2 (mode) and 7 (LYC=LY coincidence flag) are read-only or set by the PPU itself. The write logic masks out the mode and coincidence bits before applying the new value.
    • Read: The Game Boy’s STAT register always has bit 7 set to 1. Bits 0-2 reflect the current PPU mode. The new logic (mmu.ppu.stat &&& 0xFCuy) ||| (byte mmu.ppu.mode) correctly combines the stored stat flags (bits 2-7) with the current PPU mode (bits 0-1).
  • DMA Transfer (0xFF46): Writing a value N to 0xFF46 initiates a Direct Memory Access transfer. 160 bytes from N * 0x100 are copied into OAM (0xFE00-0xFE9F). This typically takes 160 CPU cycles and halts the CPU, but for simplicity, we perform the copy instantly for now. A more accurate emulator would simulate the cycle cost and CPU halt.
  • Register Mapping: Each PPU-related register is now mapped to update or read from the mmu.ppu state.

4. Update stepPPU for LCDC and LYC

Modify stepPPU to check lcdc for LCD enable/disable and lyc for coincidence. This ensures the PPU correctly responds to CPU commands regarding display state and interrupt conditions.

// src/GameBoyEmulator/PPU.fs

// ... (inside PPU module)

let stepPPU (mmu: MMU.MMU) (cycles: int) : MMU.MMU * Interrupt =
    let mutable currentMmu = mmu
    let mutable ppu = currentMmu.ppu
    let mutable interrupt = NoInterrupt

    // Check if LCD is enabled (Bit 7 of LCDC)
    if (ppu.lcdc &&& 0x80uy) = 0x00uy then // LCD is off
        // When LCD is off, PPU is reset
        if ppu.ly <> 0uy || ppu.cycles <> 0 || ppu.mode <> PpuMode.HBlank then // Only reset if not already in reset state
            ppu <- { ppu with ly = 0uy; cycles = 0; mode = PpuMode.HBlank } // PPU stays in HBlank (mode 0)
            currentMmu <- { currentMmu with ppu = ppu }
        // No rendering or STAT interrupts when LCD is off
        (currentMmu, NoInterrupt)
    else
        // ... (existing cycle accumulation)
        ppu <- { ppu with cycles = ppu.cycles + cycles }

        // LYC = LY Coincidence Check
        let lycEqLy = ppu.ly = ppu.lyc
        let statCoincidenceBit = if lycEqLy then 0x04uy else 0x00uy // Bit 2 of STAT
        let oldStat = ppu.stat
        ppu <- { ppu with stat = (oldStat &&& 0xFBuy) ||| statCoincidenceBit } // Update bit 2 of STAT (clear then set)

        // If LYC=LY coincidence interrupt is enabled (STAT bit 6) and coincidence occurs
        if lycEqLy && ((oldStat &&& 0x40uy) <> 0uy) then
            // Only trigger interrupt if the coincidence bit just became true (edge-triggered)
            if (oldStat &&& 0x04uy) = 0uy then
                interrupt <- Interrupt.set Interrupt.LcdStat interrupt

        // PPU Modes and Line Rendering
        let newPpu, newInterrupt =
            match ppu.mode with
            | PpuMode.OamScan -> // Mode 2: OAM Read (80 cycles)
                if ppu.cycles >= 80 then
                    let nextPpu = { ppu with cycles = ppu.cycles - 80; mode = PpuMode.Drawing }
                    // Trigger STAT interrupt if enabled for Mode 2 (OAM Scan)
                    let currentInterrupt = if ((ppu.stat &&& 0x20uy) <> 0uy) then (Interrupt.set Interrupt.LcdStat interrupt) else interrupt
                    (nextPpu, currentInterrupt)
                else (ppu, interrupt)

            | PpuMode.Drawing -> // Mode 3: VRAM Read (172 cycles)
                if ppu.cycles >= 172 then // This is where we'll actually draw the line
                    let ppuAfterDraw = drawScanline ppu currentMmu.cartridge
                    let nextPpu = { ppuAfterDraw with cycles = ppuAfterDraw.cycles - 172; mode = PpuMode.HBlank }
                    // Trigger STAT interrupt if enabled for Mode 3 (Drawing) - not typical, but possible
                    let currentInterrupt = if ((ppu.stat &&& 0x10uy) <> 0uy) then (Interrupt.set Interrupt.LcdStat interrupt) else interrupt
                    (nextPpu, currentInterrupt)
                else (ppu, interrupt)

            | PpuMode.HBlank -> // Mode 0: H-Blank (204 cycles)
                if ppu.cycles >= 204 then
                    let ppuAfterHBlank = { ppu with cycles = ppu.cycles - 204; ly = ppu.ly + 1uy }
                    // Trigger STAT interrupt if enabled for Mode 0 (HBlank)
                    let currentInterrupt = if ((ppu.stat &&& 0x08uy) <> 0uy) then (Interrupt.set Interrupt.LcdStat interrupt) else interrupt

                    if ppuAfterHBlank.ly = 144uy then
                        // Enter VBlank
                        let ppuVBlank = { ppuAfterHBlank with mode = PpuMode.VBlank }
                        // Trigger VBlank interrupt
                        let vblankInterrupt = Interrupt.set Interrupt.VBlank currentInterrupt
                        // Trigger STAT interrupt if enabled for Mode 1 (VBlank)
                        let statVblankInterrupt = if ((ppu.stat &&& 0x10uy) <> 0uy) then (Interrupt.set Interrupt.LcdStat vblankInterrupt) else vblankInterrupt
                        (ppuVBlank, statVblankInterrupt)
                    else
                        // Next scanline, back to OAM Scan
                        let ppuOamScan = { ppuAfterHBlank with mode = PpuMode.OamScan }
                        // Trigger STAT interrupt if enabled for Mode 2 (OAM Scan)
                        let statOamScanInterrupt = if ((ppu.stat &&& 0x20uy) <> 0uy) then (Interrupt.set Interrupt.LcdStat currentInterrupt) else currentInterrupt
                        (ppuOamScan, statOamScanInterrupt)
                else (ppu, interrupt)

            | PpuMode.VBlank -> // Mode 1: V-Blank (4560 cycles total for lines 144-153)
                if ppu.cycles >= 456 then // Each VBlank line takes 456 cycles
                    let ppuVBlankLine = { ppu with cycles = ppu.cycles - 456; ly = ppu.ly + 1uy }
                    if ppuVBlankLine.ly > 153uy then // Last VBlank line (line 153 ends, next is line 0)
                        let ppuReset = { ppuVBlankLine with ly = 0uy; mode = PpuMode.OamScan }
                        // Trigger STAT interrupt if enabled for Mode 2 (OAM Scan)
                        let statOamScanInterrupt = if ((ppu.stat &&& 0x20uy) <> 0uy) then (Interrupt.set Interrupt.LcdStat interrupt) else interrupt
                        (ppuReset, statOamScanInterrupt)
                    else (ppuVBlankLine, interrupt) // Continue VBlank lines
                else (ppu, interrupt)

        currentMmu <- { currentMmu with ppu = newPpu }
        (currentMmu, newInterrupt)

Explanation:

  • LCD Enable Check: The first thing stepPPU does is check lcdc bit 7. If the LCD is off, the PPU halts, LY resets to 0, and no rendering or interrupts occur. This is crucial for games that toggle the display.
  • LYC=LY Coincidence: We now compare ppu.ly with ppu.lyc and update bit 2 of the stat register accordingly. If stat bit 6 is also set, and a coincidence just occurred (meaning the LYC=LY flag changed from false to true), a LcdStat interrupt is requested. This is an edge-triggered interrupt.
  • Mode STAT Interrupts: Added checks for stat bits 3, 4, 5, which enable LcdStat interrupts for HBlank, VBlank, and OAM Scan modes respectively. This is vital for timing-sensitive games. The newPpu, newInterrupt pattern helps manage state updates and potential interrupt requests more functionally.

5. Implement drawScanline with Scrolling and Sprites

This is the core of our PPU update. We’ll modify drawScanline to incorporate SCX, SCY, LCDC and render sprites. The readTile function signature is updated as requested.

// src/GameBoyEmulator/PPU.fs

// ... (inside PPU module)

/// Helper to read a palette color from a byte and index
let getPaletteColor (paletteByte: byte) (index: int) : PixelColor =
    let shift = index * 2
    let colorId = (paletteByte >>> shift) &&& 0x03uy
    match colorId with
    | 0uy -> White
    | 1uy -> LightGray
    | 2uy -> DarkGray
    | 3uy -> Black
    | _   -> White // Should not happen


/// Reads a tile from VRAM, considering tile data selection (0x8000-0x8FFF or 0x8800-0x97FF)
let readTile (ppu: PPU) (tileIndex: byte) (yOffset: int) : byte array =
    let signedTileIndex = sbyte tileIndex
    let tileAddress =
        if (ppu.lcdc &&& 0x10uy) = 0x10uy then // LCDC bit 4: BG & Window Tile Data Select (0=8800-97FF, 1=8000-8FFF)
            // Use 0x8000-0x8FFF range (unsigned addressing)
            0x8000us + word (tileIndex * 16uy)
        else
            // Use 0x8800-0x97FF range (signed addressing), base address is 0x9000
            0x9000us + word (signedTileIndex * 16sbyte)

    let lineAddress = tileAddress + word (byte (yOffset * 2)) // Each line takes 2 bytes
    let byte1 = ppu.vram.[int (lineAddress - 0x8000us)]
    let byte2 = ppu.vram.[int (lineAddress - 0x8000us + 1us)]
    [| byte1; byte2 |]

/// Extracts pixel data from a tile line (2 bytes)
let getTileLinePixels (tileLineBytes: byte array) : byte array =
    let byte1 = tileLineBytes.[0]
    let byte2 = tileLineBytes.[1]
    Array.init 8 (fun i ->
        let bit1 = (byte1 >>> (7 - i)) &&& 0x01uy
        let bit2 = (byte2 >>> (7 - i)) &&& 0x01uy
        (bit2 <<< 1) ||| bit1 // Combine into 2-bit color index
    )

/// Draws a single scanline, incorporating background scrolling and sprites
let private drawScanline (ppu: PPU) (cartridge: Cartridge.Cartridge) : PPU =
    let ly = int ppu.ly
    let mutable pixels = Array.zeroCreate 160 // 160 pixels for the current scanline

    // Check LCD Enable (Bit 7 of LCDC)
    if (ppu.lcdc &&& 0x80uy) = 0x00uy then
        // If LCD is off, clear the line to white
        for x = 0 to 159 do
            pixels.[x] <- White
        { ppu with lcdBuffer = ppu.lcdBuffer |> Array.mapi (fun i v -> if i / 160 = ly then pixels.[i % 160] else v) }
    else
        // --- Background Rendering ---
        // Check BG Display Enable (Bit 0 of LCDC)
        let bgDisplayEnabled = (ppu.lcdc &&& 0x01uy) = 0x01uy

        if bgDisplayEnabled then
            let tileMapBaseAddr =
                if (ppu.lcdc &&& 0x08uy) = 0x08uy then // LCDC bit 3: BG Tile Map Display Select (0=9800-9BFF, 1=9C00-9FFF)
                    0x9C00us // 0x9C00-0x9FFF
                else
                    0x9800us // 0x9800-0x9BFF

            let bgPalette = ppu.bgp

            let scrolledY = (ly + int ppu.scy) &&& 0xFF // Apply SCY and wrap around 256
            let tileRow = scrolledY / 8

            for x = 0 to 159 do
                let scrolledX = (x + int ppu.scx) &&& 0xFF // Apply SCX and wrap around 256
                let tileCol = scrolledX / 8

                let tileMapAddr = tileMapBaseAddr + word (byte (tileRow * 32 + tileCol))
                let tileIndex = ppu.vram.[int (tileMapAddr - 0x8000us)]

                let tileYOffset = scrolledY % 8 // Y offset within the tile
                let tileLineBytes = readTile ppu tileIndex tileYOffset
                let tileLinePixels = getTileLinePixels tileLineBytes
                let pixelColorIndex = tileLinePixels.[scrolledX % 8]

                pixels.[x] <- getPaletteColor bgPalette (int pixelColorIndex)
        else
            // If BG is disabled, fill with white (color 0)
            for x = 0 to 159 do
                pixels.[x] <- White

        // --- Sprite Rendering ---
        // Check OBJ Display Enable (Bit 1 of LCDC)
        let objDisplayEnabled = (ppu.lcdc &&& 0x02uy) = 0x02uy
        if objDisplayEnabled then
            let objSize = if (ppu.lcdc &&& 0x04uy) = 0x04uy then 16 else 8 // LCDC bit 2: OBJ Size (0=8x8, 1=8x16)

            // Collect up to 10 sprites for the current scanline (Game Boy hardware limit)
            let mutable visibleSprites = List.empty<Sprite>
            for i = 0 to 39 do // 40 possible sprites
                let oamOffset = i * 4
                let spriteY = int ppu.oam.[oamOffset] - 16 // Y position minus 16
                let spriteX = int ppu.oam.[oamOffset + 1] - 8 // X position minus 8

                // Check if sprite is on current scanline
                if ly >= spriteY && ly < (spriteY + objSize) then
                    let attributes = ppu.oam.[oamOffset + 3]
                    let sprite =
                        { Y = ppu.oam.[oamOffset]
                          X = ppu.oam.[oamOffset + 1]
                          TileIndex = ppu.oam.[oamOffset + 2]
                          PaletteNum = (attributes >>> 4) &&& 0x01uy // DMG only has OBP0/OBP1
                          XFlip = ((attributes >>> 5) &&& 0x01uy) = 0x01uy
                          YFlip = ((attributes >>> 6) &&& 0x01uy) = 0x01uy
                          BgPriority = ((attributes >>> 7) &&& 0x01uy) = 0x01uy
                          OamIndex = i
                        }
                    visibleSprites <- sprite :: visibleSprites
                    if List.length visibleSprites >= 10 then break // Hardware limit of 10 sprites per line

            // Sort sprites by X-coordinate (lower X = higher priority), then OAM index (lower index = higher priority)
            let sortedSprites =
                visibleSprites
                |> List.sortBy (fun s -> (s.X, s.OamIndex)) // F# sorts tuples lexicographically

            // Render sprites onto the pixel buffer
            for sprite in sortedSprites do
                let spriteY = int sprite.Y - 16
                let spriteX = int sprite.X - 8
                let tileIndex = if objSize = 16 then sprite.TileIndex &&& 0xFEuy else sprite.TileIndex // 8x16 sprites use two consecutive tiles, top tile index is even

                let tileYInSprite = ly - spriteY
                let actualTileYOffset = if sprite.YFlip then objSize - 1 - tileYInSprite else tileYInSprite

                let currentTileIndex =
                    if objSize = 16 then
                        if actualTileYOffset < 8 then tileIndex
                        else tileIndex + 1uy
                    else tileIndex

                let tileLineOffset = actualTileYOffset % 8

                let tileLineBytes = readTile ppu currentTileIndex tileLineOffset
                let tileLinePixels = getTileLinePixels tileLineBytes

                let objPalette = if sprite.PaletteNum = 0uy then ppu.obp0 else ppu.obp1

                for px = 0 to 7 do
                    let screenX = spriteX + px
                    if screenX >= 0 && screenX < 160 then
                        let tilePixelX = if sprite.XFlip then 7 - px else px
                        let pixelColorIndex = tileLinePixels.[tilePixelX]

                        // Only draw if pixel is not transparent (color index 0)
                        if pixelColorIndex <> 0uy then
                            // Sprite priority check
                            let currentBgPixelColor = pixels.[screenX]
                            let bgPixelColorIndex =
                                match currentBgPixelColor with
                                | White -> 0uy
                                | LightGray -> 1uy
                                | DarkGray -> 2uy
                                | Black -> 3uy
                                | _ -> 0uy // Should not happen

                            let drawSprite =
                                if sprite.BgPriority then // Sprite is behind BG colors 1-3
                                    bgPixelColorIndex = 0uy
                                else // Sprite is always in front of BG
                                    true

                            if drawSprite then
                                pixels.[screenX] <- getPaletteColor objPalette (int pixelColorIndex)

        // Update the LCD buffer with the rendered scanline
        { ppu with lcdBuffer = ppu.lcdBuffer |> Array.mapi (fun i v -> if i / 160 = ly then pixels.[i % 160] else v) }

Explanation:

  • readTile refinement: The readTile function now correctly uses LCDC bit 4 to determine if tiles are fetched from 0x8000-0x8FFF (unsigned indices) or 0x8800-0x97FF (signed indices relative to 0x9000). This is a crucial detail for games using both tile data regions.
  • LCD Enable: The first check ensures no rendering occurs if the LCD is disabled, preventing unnecessary computation and accurately reflecting hardware behavior.
  • Background Display Enable: LCDC bit 0 controls whether the background is displayed. If disabled, the screen is filled with White.
  • Scrolling:
    • scrolledY = (ly + int ppu.scy) &&& 0xFF: The current screen ly is offset by SCY and wrapped around 256. This gives the effective Y-coordinate in the 256x256 background map.
    • scrolledX = (x + int ppu.scx) &&& 0xFF: Similarly, SCX offsets the X-coordinate.
    • These scrolledY and scrolledX values are used to calculate the correct tileRow, tileCol, and tileYOffset within the background map.
  • Sprite Collection:
    • We iterate through all 40 potential sprites in OAM.
    • For each, we check if its Y-position (spriteY) falls within the current ly scanline, considering the objSize (8x8 or 8x16, from LCDC bit 2).
    • Up to 10 sprites are collected for the current line, as per Game Boy hardware limits.
  • Sprite Sorting:
    • Visible sprites are sorted. The Game Boy has a fixed priority: sprites with smaller X-coordinates are drawn on top. If X-coordinates are equal, sprites with lower OAM indices (0-39) have higher priority. This is handled by List.sortBy (fun s -> (s.X, s.OamIndex)).
  • Sprite Rendering Loop:
    • For each sorted sprite, we calculate its pixel data.
    • tileIndex: For 8x16 sprites, the TileIndex in OAM refers to the top tile (must be an even number). We adjust tileIndex based on tileYInSprite.
    • XFlip/YFlip: These attributes reverse the tile’s pixels horizontally or vertically.
    • BgPriority: LCDC bit 7 in the sprite attributes (BgPriority) determines if the sprite is drawn behind background colors 1-3 (non-transparent) or always on top. If BgPriority is set and the background pixel is not transparent (color index 0), the sprite pixel (if not transparent itself) will not be drawn.
    • Palette Selection: OBP0 or OBP1 is chosen based on the sprite’s PaletteNum attribute.
    • Transparent Pixels: Sprite pixel color index 0 is always transparent and doesn’t overwrite existing background pixels.
  • Final Pixel Combination: The background pixel is determined first, then sprites are drawn on top, respecting their priority rules.

6. Update PPU Initialization and MMU References

Ensure your MMU construction in main or Emulator module passes the ppu state to the MMU. This establishes the MMU as the central point for all memory and I/O access.

// src/GameBoyEmulator/Emulator.fs (or wherever you initialize MMU and PPU)

module GameBoyEmulator.Emulator

open System
open GameBoyEmulator.CPU
open GameBoyEmulator.MMU
open GameBoyEmulator.PPU
open GameBoyEmulator.Cartridge
open GameBoyEmulator.Types
open Silk.NET.Windowing // Using Silk.NET.Windowing as a modern SDL.NET alternative

// ... (existing code)

let createEmulator (romPath: string) : Emulator =
    let cartridge = Cartridge.loadCartridge romPath

    let ppu = PPU.initPPU cartridge
    let mmu = MMU.initMMU cartridge ppu // Pass initial PPU state to MMU
    let cpu = CPU.initCPU()

    { cpu = cpu; mmu = mmu; cartridge = cartridge; cycles = 0 }

And ensure the MMU type holds the PPU record:

// src/GameBoyEmulator/MMU.fs

module GameBoyEmulator.MMU

open GameBoyEmulator.PPU
open GameBoyEmulator.Cartridge
open GameBoyEmulator.Types

type MMU =
    { rom      : byte array
      ram      : byte array
      ppu      : PPU.PPU // MMU directly holds the PPU state
      cartridge: Cartridge.Cartridge
      // ... other fields
    }

let initMMU (cartridge: Cartridge.Cartridge) (initialPPU: PPU.PPU) : MMU =
    { rom = cartridge.rom
      ram = Array.zeroCreate 0x10000 // 64KB RAM, will be managed by MMU
      ppu = initialPPU // Initialize with the PPU state
      cartridge = cartridge
      // ... other fields
    }

This setup ensures that MMU is the central point of truth for the PPU’s state, allowing the CPU to read/write PPU registers via the MMU’s readByte/writeByte functions, and PPU.stepPPU to modify its own internal state within the MMU.

Testing & Verification

With sprites and scrolling implemented, you’ll want to verify with specific test ROMs. This is an iterative process of running, observing, and debugging.

  1. Blargg’s Test ROMs: These are industry-standard for Game Boy emulator development.

    • cpu_instrs/individual/09-op r,r.gb: This ROM often displays a simple ‘OK’ message and scrolls. Verify the scrolling behavior.
    • sprite_priority.gb: This ROM specifically tests sprite rendering, including priority rules. It shows various patterns where sprites should overlap or be hidden by the background.
    • oam_bug.gb: While we haven’t implemented OAM DMA timing accurately, this ROM can reveal issues with OAM access or sprite sorting.
    • lcd_align.gb: Useful for checking basic LCD timing and alignment.
  2. Visual Inspection:

    • Load simple games (e.g., Tetris, Dr. Mario).
    • Observe how sprites (the falling blocks, score digits) are rendered.
    • Check if background elements scroll smoothly when the game moves.
    • Look for visual artifacts like flickering sprites, incorrect layering, or misaligned scrolling.
  3. Debugging Checks:

    • Log Register Values: Print ppu.scx, ppu.scy, ppu.lcdc, ppu.ly at the start of each scanline. This helps track if the CPU is writing the expected values.
    • Memory Dump OAM: At key points, dump the ppu.oam array to see if sprite attributes are correctly stored.
    • Conditional Breakpoints: Set breakpoints in your drawScanline function, especially within the sprite rendering loop, to inspect individual sprite data and pixel blending logic.

Production Considerations

๐Ÿ”ฅ Optimization / Pro tip: The drawScanline function is performance-critical, executing 154 times per frame (144 visible lines + 10 VBlank lines for LY updates).

  • Sprite Caching: Instead of re-parsing OAM for every pixel, collect and sort visible sprites once per scanline. We already do this by collecting visibleSprites.
  • Early Exit: If OBJ Display Enable (LCDC bit 1) is off, skip the entire sprite rendering loop.
  • Palette Lookups: Pre-calculate the full palette colors if performance becomes an issue, rather than doing bit shifts and match expressions for every pixel.
  • Immutability vs. Performance: While F# favors immutability, the pixel buffer (pixels) is often mutated for performance within a single function scope. This is a pragmatic tradeoff in performance-critical inner loops. The ppu.lcdBuffer is then updated immutably at the end of the scanline, preserving the overall functional structure.

โš ๏ธ What can go wrong:

  • Off-by-one errors: Sprite Y/X positions are offset by -16 and -8 respectively from their OAM values. Incorrectly applying these offsets will cause sprites to appear shifted.
  • Incorrect Priority: The Game Boy’s sprite priority rules are specific (X-coordinate, then OAM index, then BgPriority bit). Any deviation will cause sprites to be drawn in the wrong order.
  • DMA Timing: While we simplified DMA to an instant copy, in a highly accurate emulator, DMA takes 160 machine cycles and halts the CPU. Incorrect DMA timing can lead to corrupted OAM or CPU desynchronization in some games.
  • LCDC Bit Misinterpretation: Each bit in LCDC has a specific, critical function. Misinterpreting any bit (e.g., sprite size, background enable) will lead to significant visual bugs.

Common Issues & Solutions

  1. Sprites are not visible or are flickering:
    • Issue: LCDC bit 1 (OBJ Display Enable) might be off, or objSize (LCDC bit 2) is wrong. Sprite X/Y positions are often off-screen (-16 for Y, -8 for X) or outside the current scanline’s range.
    • Solution: Verify LCDC value in your debugger. Ensure your sprite Y/X calculations correctly account for the -16/-8 offsets. Check the ly range for sprite visibility.
  2. Sprites are drawn in the wrong order:
    • Issue: Incorrect sprite sorting logic. The Game Boy has a specific hardware priority.
    • Solution: Ensure sprites are first sorted by sprite.X (ascending), then by sprite.OamIndex (ascending) for tie-breaking, before rendering.
  3. Background scrolling is jerky or incorrect:
    • Issue: SCX/SCY wrapping or offset calculations are wrong. The background map is 256x256 pixels, and the viewport is 160x144.
    • Solution: Remember that SCX and SCY are 8-bit values and wrap around 256. The (&&& 0xFF) operation is crucial for this. Double-check tileRow and tileCol calculations.
  4. VRAM/OAM access restrictions causing glitches:
    • Issue: CPU writes to VRAM/OAM during PPU Drawing or OamScan modes are not being ignored or returning 0xFF for reads.
    • Solution: Re-check the MMU.writeByte and MMU.readByte functions for 0x8000-0x9FFF (VRAM) and 0xFE00-0xFE9F (OAM) to ensure they correctly check mmu.ppu.mode and enforce restrictions.

Summary & Next Step

In this chapter, you’ve significantly advanced your Game Boy emulator’s visual capabilities. You’ve implemented:

  • Sprite Rendering: Parsing OAM data, handling sprite attributes (position, tile index, palettes, flips, priority), and drawing up to 10 sprites per scanline.
  • Background Scrolling: Using SCX and SCY registers to dynamically offset the background display.
  • LCD Control Register (LCDC): Integrated its various bits to enable/disable display components (BG, sprites), set sprite size, and control tile data/map selection.
  • PPU Timing Refinements: Enhanced the stepPPU function to accurately handle LYC=LY coincidence and STAT interrupts for different PPU modes, including the correct STAT register read behavior.

Your emulator can now render much more complex game scenes, with moving characters and scrolling backgrounds. The visual foundation is becoming robust.

Next, we’ll shift our focus to interaction, implementing input handling to allow the user to control games.

๐Ÿง  Check Your Understanding

  • What is the purpose of the BgPriority bit in a sprite’s attributes, and how does it affect rendering?
  • Why is it important to sort sprites before drawing them, and what are the Game Boy’s rules for sprite priority?
  • What happens if the LCD Control Register (LCDC) bit 7 (LCD Enable) is set to 0, and how should your PPU handle this?

โšก Mini Task

  • Add a debug function to your PPU module that, when called, prints the current values of LCDC, SCX, SCY, LY, OBP0, and OBP1 to the console. Integrate this into your main loop for easy inspection.

๐Ÿš€ Scenario

A new Game Boy ROM you’re testing displays its main character correctly, but all background elements are missing. You’ve checked that the background tiles are loaded into VRAM. What are the most likely PPU register settings or code issues that could cause this, and how would you debug it?

๐Ÿ“Œ TL;DR

  • Sprites add dynamic objects, managed by OAM and LCDC attributes.
  • SCX and SCY registers enable background scrolling by offsetting tile map lookups.
  • LCDC is the master control, enabling/disabling display features and defining sprite size and tile data sources.

๐Ÿง  Core Flow

  1. PPU checks LCDC enable bit; if off, PPU halts.
  2. PPU updates LYC=LY coincidence flag in STAT and triggers LcdStat interrupt if enabled.
  3. For each scanline, PPU renders background pixels, applying SCX/SCY offsets based on LCDC settings.
  4. PPU scans OAM, filters up to 10 visible sprites, sorts them by X then OAM index.
  5. PPU renders sorted sprites over background, respecting BgPriority and transparency.

๐Ÿš€ Key Takeaway

Accurate emulation of the Game Boy’s PPU requires meticulous attention to hardware-specific details like register bit flags, memory access restrictions, and strict sprite priority rules, which collectively dictate the final rendered frame.

References

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