How to Program a Computer Emulator in Rust: Step by Step Guide (Part 1)

Welcome to this journey back to the origins of personal computing, where we’ll explore the iconic ZX Spectrum 48K — the first computer I ever owned, and the one that ignited my passion for computing. Launched in 1982 by Sinclair Research, the ZX Spectrum was powered by the Zilog Z80 processor, a chip that became the heart of countless machines of that era. This humble device, with its rubber keyboard and 8-bit graphics, not only brought computing into homes but also gave many people their first steps in programming and game creation.

Today, there are numerous ZX Spectrum emulators available — Fuse, Retro Virtual Machine, Spectaculator — that let us experience this classic computer on modern systems. However, most of these emulators don’t explain how to recreate the Spectrum’s operation from scratch. In this series, we’ll fill that gap: the goal is not just to run old programs, but to understand how a real computer from that era actually worked, at the lowest level.

Why Rust for an Emulator?

Rust is an excellent choice for emulator development for several reasons:

  • Performance: Emulation is CPU-intensive; Rust’s zero-cost abstractions deliver C-like speed
  • Memory safety: No segfaults or buffer overflows, common pitfalls in emulator development
  • Pattern matching: Rust’s match expressions are ideal for implementing CPU instruction decoders
  • Rich type system: Enums and structs map naturally to hardware components

The ZX Spectrum Architecture

To emulate the ZX Spectrum, we need to understand its key components:

  • CPU: Zilog Z80A running at 3.5 MHz
  • RAM: 48KB of RAM (addresses 0x4000-0xFFFF)
  • ROM: 16KB ROM containing Sinclair BASIC (addresses 0x0000-0x3FFF)
  • Display: 256×192 pixels, 8 colors, 2 intensity levels
  • Border: Colored border around the display area
  • Keyboard: 40-key matrix keyboard
  • Tape interface: For loading and saving programs

Setting Up the Project

cargo new zx-spectrum-emulator
cd zx-spectrum-emulator

Add to Cargo.toml:

[dependencies]
# We'll add display and audio libraries as we progress

The Memory Module

The first component to implement is memory. The Z80 has a 16-bit address bus, giving it 64KB of addressable memory:

pub struct Memory {
    data: [u8; 0x10000],  // 64KB
}

impl Memory {
    pub fn new() -> Self {
        Memory { data: [0; 0x10000] }
    }
    
    pub fn read_byte(&self, addr: u16) -> u8 {
        self.data[addr as usize]
    }
    
    pub fn write_byte(&mut self, addr: u16, value: u8) {
        // ROM is read-only (0x0000-0x3FFF)
        if addr >= 0x4000 {
            self.data[addr as usize] = value;
        }
    }
    
    pub fn read_word(&self, addr: u16) -> u16 {
        let lo = self.read_byte(addr) as u16;
        let hi = self.read_byte(addr.wrapping_add(1)) as u16;
        (hi << 8) | lo
    }
    
    pub fn load_rom(&mut self, rom: &[u8]) {
        let len = rom.len().min(0x4000);
        self.data[..len].copy_from_slice(&rom[..len]);
    }
}

The Z80 CPU Registers

The Z80 has a rich set of registers. Let's define them in Rust:

pub struct Registers {
    // Main register set
    pub a: u8,   // Accumulator
    pub f: u8,   // Flags
    pub b: u8, pub c: u8,  // BC pair
    pub d: u8, pub e: u8,  // DE pair
    pub h: u8, pub l: u8,  // HL pair
    
    // Alternate register set (Z80 specific)
    pub a_: u8, pub f_: u8,
    pub b_: u8, pub c_: u8,
    pub d_: u8, pub e_: u8,
    pub h_: u8, pub l_: u8,
    
    // Index registers
    pub ix: u16,
    pub iy: u16,
    
    // Special registers
    pub sp: u16,  // Stack Pointer
    pub pc: u16,  // Program Counter
    pub i: u8,    // Interrupt vector
    pub r: u8,    // Memory refresh
    
    // Interrupt flip-flops
    pub iff1: bool,
    pub iff2: bool,
}

impl Registers {
    pub fn new() -> Self {
        Registers {
            a: 0xFF, f: 0xFF,
            b: 0xFF, c: 0xFF,
            d: 0xFF, e: 0xFF,
            h: 0xFF, l: 0xFF,
            a_: 0xFF, f_: 0xFF,
            b_: 0xFF, c_: 0xFF,
            d_: 0xFF, e_: 0xFF,
            h_: 0xFF, l_: 0xFF,
            ix: 0xFFFF, iy: 0xFFFF,
            sp: 0xFFFF,
            pc: 0x0000,
            i: 0x00, r: 0x00,
            iff1: false, iff2: false,
        }
    }
    
    // Register pair helpers
    pub fn bc(&self) -> u16 { ((self.b as u16) << 8) | self.c as u16 }
    pub fn de(&self) -> u16 { ((self.d as u16) << 8) | self.e as u16 }
    pub fn hl(&self) -> u16 { ((self.h as u16) << 8) | self.l as u16 }
    
    pub fn set_bc(&mut self, val: u16) { self.b = (val >> 8) as u8; self.c = val as u8; }
    pub fn set_de(&mut self, val: u16) { self.d = (val >> 8) as u8; self.e = val as u8; }
    pub fn set_hl(&mut self, val: u16) { self.h = (val >> 8) as u8; self.l = val as u8; }
}

The Z80 Flags

The F register encodes various condition flags that instructions set and check:

pub const FLAG_C: u8 = 0x01;  // Carry
pub const FLAG_N: u8 = 0x02;  // Add/Subtract
pub const FLAG_PV: u8 = 0x04; // Parity/Overflow
pub const FLAG_H: u8 = 0x10;  // Half carry
pub const FLAG_Z: u8 = 0x40;  // Zero
pub const FLAG_S: u8 = 0x80;  // Sign

Next Steps

In Part 2, we'll implement the Z80 instruction decoder and start executing our first instructions. We'll cover:

  • The fetch-decode-execute cycle
  • Implementing the most common Z80 instructions (LD, ADD, JR, CALL, RET)
  • Passing the ZEXALL test suite to verify correctness

Building an emulator is one of the most educational programming projects you can undertake. It forces you to understand how computers work at the most fundamental level — and there's something magical about seeing old software run on code you wrote yourself.

The ZX Spectrum ROM is freely available online. Once we have a working CPU and memory implementation, we'll be able to load it and start booting the Spectrum's famous BASIC interpreter.

By admin

Leave a Reply

Your email address will not be published. Required fields are marked *