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:
- Define Sprite Structure: Model Game Boy sprites and their attributes using F# records.
- Extend PPU State: Incorporate new registers like
LCDC,SCX,SCY,OBP0,OBP1into our existingPPUrecord. - Implement MMU Register Access: Add logic to the
MMUto correctly handle CPU reads and writes to these new PPU registers, including critical access restrictions. - Refine PPU Timing and Modes: Update
stepPPUto respond toLCDCenable/disable,LYC=LYcoincidence, andSTATregister interrupt flags. - Enhance
drawScanlinefor Background Scrolling: ImplementSCXandSCYoffsets to accurately render the scrolled background. - 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
SCXandSCY. - 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).
๐ง 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,dmaandoamfields.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 againstLY(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:
Spriterecord: Directly mirrors the 4-byte OAM entry, but parsed into more readable fields.OamIndexis added for priority resolution, as sprites with the same X-coordinate are prioritized by their OAM index.PPUrecord: We’ve addedlcdc,stat,scy,scx,lyc,obp0,obp1,dma, andoamto the PPU’s internal state. Whiledmais primarily handled by the MMU, the PPU needs access tooamfor sprite data. We makeoammutablebecause 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 storedstatflags (bits 2-7) with the current PPU mode (bits 0-1).
- DMA Transfer (0xFF46): Writing a value
Nto0xFF46initiates a Direct Memory Access transfer. 160 bytes fromN * 0x100are 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.ppustate.
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
stepPPUdoes is checklcdcbit 7. If the LCD is off, the PPU halts,LYresets to 0, and no rendering or interrupts occur. This is crucial for games that toggle the display. - LYC=LY Coincidence: We now compare
ppu.lywithppu.lycand update bit 2 of thestatregister accordingly. Ifstatbit 6 is also set, and a coincidence just occurred (meaning theLYC=LYflag changed from false to true), aLcdStatinterrupt is requested. This is an edge-triggered interrupt. - Mode STAT Interrupts: Added checks for
statbits 3, 4, 5, which enableLcdStatinterrupts for HBlank, VBlank, and OAM Scan modes respectively. This is vital for timing-sensitive games. ThenewPpu, newInterruptpattern 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:
readTilerefinement: ThereadTilefunction now correctly usesLCDCbit 4 to determine if tiles are fetched from0x8000-0x8FFF(unsigned indices) or0x8800-0x97FF(signed indices relative to0x9000). 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:
LCDCbit 0 controls whether the background is displayed. If disabled, the screen is filled withWhite. - Scrolling:
scrolledY = (ly + int ppu.scy) &&& 0xFF: The current screenlyis offset bySCYand wrapped around 256. This gives the effective Y-coordinate in the 256x256 background map.scrolledX = (x + int ppu.scx) &&& 0xFF: Similarly,SCXoffsets the X-coordinate.- These
scrolledYandscrolledXvalues are used to calculate the correcttileRow,tileCol, andtileYOffsetwithin 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 currentlyscanline, considering theobjSize(8x8 or 8x16, fromLCDCbit 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)).
- 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
- Sprite Rendering Loop:
- For each sorted sprite, we calculate its pixel data.
tileIndex: For 8x16 sprites, theTileIndexin OAM refers to the top tile (must be an even number). We adjusttileIndexbased ontileYInSprite.XFlip/YFlip: These attributes reverse the tile’s pixels horizontally or vertically.BgPriority:LCDCbit 7 in the sprite attributes (BgPriority) determines if the sprite is drawn behind background colors 1-3 (non-transparent) or always on top. IfBgPriorityis set and the background pixel is not transparent (color index 0), the sprite pixel (if not transparent itself) will not be drawn.- Palette Selection:
OBP0orOBP1is chosen based on the sprite’sPaletteNumattribute. - Transparent Pixels: Sprite pixel color index
0is 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.
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.
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.
Debugging Checks:
- Log Register Values: Print
ppu.scx,ppu.scy,ppu.lcdc,ppu.lyat 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.oamarray to see if sprite attributes are correctly stored. - Conditional Breakpoints: Set breakpoints in your
drawScanlinefunction, especially within the sprite rendering loop, to inspect individual sprite data and pixel blending logic.
- Log Register Values: Print
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
matchexpressions 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. Theppu.lcdBufferis 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
BgPrioritybit). 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
LCDChas a specific, critical function. Misinterpreting any bit (e.g., sprite size, background enable) will lead to significant visual bugs.
Common Issues & Solutions
- Sprites are not visible or are flickering:
- Issue:
LCDCbit 1 (OBJ Display Enable) might be off, orobjSize(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
LCDCvalue in your debugger. Ensure your sprite Y/X calculations correctly account for the -16/-8 offsets. Check thelyrange for sprite visibility.
- Issue:
- 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 bysprite.OamIndex(ascending) for tie-breaking, before rendering.
- Background scrolling is jerky or incorrect:
- Issue:
SCX/SCYwrapping or offset calculations are wrong. The background map is 256x256 pixels, and the viewport is 160x144. - Solution: Remember that
SCXandSCYare 8-bit values and wrap around 256. The(&&& 0xFF)operation is crucial for this. Double-checktileRowandtileColcalculations.
- Issue:
- VRAM/OAM access restrictions causing glitches:
- Issue: CPU writes to VRAM/OAM during PPU
DrawingorOamScanmodes are not being ignored or returning0xFFfor reads. - Solution: Re-check the
MMU.writeByteandMMU.readBytefunctions for0x8000-0x9FFF(VRAM) and0xFE00-0xFE9F(OAM) to ensure they correctly checkmmu.ppu.modeand enforce restrictions.
- Issue: CPU writes to VRAM/OAM during PPU
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
SCXandSCYregisters 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
stepPPUfunction to accurately handleLYC=LYcoincidence andSTATinterrupts for different PPU modes, including the correctSTATregister 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
BgPrioritybit 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
PPUmodule that, when called, prints the current values ofLCDC,SCX,SCY,LY,OBP0, andOBP1to 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
LCDCattributes. SCXandSCYregisters enable background scrolling by offsetting tile map lookups.LCDCis the master control, enabling/disabling display features and defining sprite size and tile data sources.
๐ง Core Flow
- PPU checks
LCDCenable bit; if off, PPU halts. - PPU updates
LYC=LYcoincidence flag inSTATand triggersLcdStatinterrupt if enabled. - For each scanline, PPU renders background pixels, applying
SCX/SCYoffsets based onLCDCsettings. - PPU scans OAM, filters up to 10 visible sprites, sorts them by X then OAM index.
- PPU renders sorted sprites over background, respecting
BgPriorityand 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
- Pan Docs - LCD
- Pan Docs - OAM
- F# Language Reference
- Silk.NET.Windowing Documentation
- Game Boy CPU Manual (SM83)
This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.