A simple instruction-stepped Z80 CPU emulator written in Go, inspired by the cycle-accurate emulation techniques described in floooh's blog posts.
- Instruction-stepped execution: Simple and fast, suitable for most games and applications
- Complete instruction set: All documented Z80 instructions including:
- Main instructions
- CB-prefixed (bit operations)
- ED-prefixed (extended instructions)
- DD/FD-prefixed (IX/IY operations)
- DDCB/FDCB-prefixed (indexed bit operations)
- Accurate flag handling: Including undocumented X and Y flags
- Interrupt support: NMI and maskable interrupts (modes 0, 1, 2)
- Memory and I/O interfaces: Flexible interfaces for custom implementations
- Clean API: Simple to integrate into larger projects
zen80/
├── z80/
│ ├── z80.go # Core CPU state and main loop
│ ├── decode.go # Instruction decoder
│ ├── alu.go # Arithmetic and logic operations
│ ├── prefix_cb.go # CB-prefixed instructions
│ ├── prefix_ed.go # ED-prefixed instructions
│ └── prefix_ddfd.go # DD/FD-prefixed instructions
├── memory/
│ └── memory.go # Memory implementations
├── io/
│ └── io.go # I/O port implementations
├── cmd/
│ └── example/
│ └── main.go # Example programs
├── go.mod
└── README.md
package main
import (
"github.com/ha1tch/zen80/z80"
"github.com/ha1tch/zen80/memory"
"github.com/ha1tch/zen80/io"
)
func main() {
// Create memory and I/O
mem := memory.NewRAM()
io := io.NewNullIO()
// Load a program
program := []uint8{
0x3E, 0x05, // LD A, 5
0x06, 0x03, // LD B, 3
0x80, // ADD A, B
0x76, // HALT
}
mem.Load(0x0000, program)
// Create and run CPU
cpu := z80.New(mem, io)
for !cpu.Halted {
cpu.Step()
}
// Result is in register A
fmt.Printf("Result: %d\n", cpu.A)
}// Create CPU
cpu := z80.New(memory, io)
// Reset CPU
cpu.Reset()
// Execute one instruction
cycles := cpu.Step()
// Run until condition
cpu.Run(func() bool {
return !shouldStop
})Implement the MemoryInterface:
type MemoryInterface interface {
Read(address uint16) uint8
Write(address uint16, value uint8)
}Built-in implementations:
RAM: Simple 64KB RAMROM: Read-only memoryMappedMemory: ROM + RAM regions
Implement the IOInterface:
type IOInterface interface {
In(port uint16) uint8
Out(port uint16, value uint8)
}Built-in implementations:
NullIO: Returns 0xFF for all readsSimpleIO: Basic 256-port arrayMappedIO: Port handlers with callbacks
// Trigger interrupts
cpu.INT = true // Maskable interrupt
cpu.NMI = true // Non-maskable interrupt
// Interrupt modes
cpu.IM = 0 // Mode 0: Execute instruction from data bus
cpu.IM = 1 // Mode 1: RST 38H
cpu.IM = 2 // Mode 2: Vectored interruptsBased on the lessons from the cycle-accurate emulation articles:
- Instruction-Stepped Approach: Chosen for simplicity and adequate performance for most use cases
- Clean Interfaces: Memory and I/O as interfaces allow flexible implementations
- No Complex Callbacks: Simple, synchronous execution model
- Direct Register Access: Public register fields for easy inspection and debugging
- Accurate Flag Behavior: Including undocumented flags for compatibility
- Optimized for clarity over speed: The code prioritizes readability and correctness
- No JIT compilation: Pure interpretation for portability
- Suitable for: Games, business software, educational purposes
- May struggle with: Timing-critical demos, exact hardware simulation
The intended entry point for running the tests is the runtest.sh script. It
sets up the ROM path and step budget the ROM-backed test needs, runs the fast
unit tests, and skips the slow conformance exercisers by default:
./runtest.sh # fast unit tests + ROM-backed opcode-coverage test
./runtest.sh --zex # the above, plus the ZEXDOC/ZEXALL exercisersThe test suite has three tiers, described below. The distinction matters
because the ZEXDOC/ZEXALL exercisers run for billions of cycles and take
30-60+ minutes, so they are kept separate from the fast tests that
runtest.sh runs by default -- not run routinely, and never expected to
complete within a CI job or a sandboxed session.
The bulk of the suite: per-instruction behaviour, flag derivations, timing,
prefixes, and interrupt modes (27 test files). These run in well under a
second and are what runtest.sh runs by default.
opcov_runtime_rom_test.go executes a real 128K ROM to exercise opcode
coverage at runtime. It needs a ROM path and a step budget, which runtest.sh
provides: it points Z80_ROM_PATH at rom/128-0.rom and runs with a 50M-step
budget. (Run on its own it would skip for lack of those.)
These are the standard Z80 instruction exercisers (the same ones used to validate real hardware emulators), run under a small CP/M BDOS shim. ZEXDOC checks documented flag behaviour; ZEXALL additionally checks the undocumented flag bits. Each runs to completion and takes 30-60+ minutes -- this is not a hang or a bug, it genuinely runs that long, exercising every opcode across its full input space. Not something a CI run or a sandboxed session should ever be expected to complete; run these deliberately, locally, when actually verifying opcode correctness after a change to core execution:
./zexdoc.sh # documented-flags exerciser
./zexall.sh # documented + undocumented flagsA passing run prints each instruction group followed by OK. Console output
is captured to zexdoc.out / zexall.out.
These exercisers are gated behind environment variables so they do not run by accident. The runner scripts set them for you; the key ones are:
| Variable | Meaning |
|---|---|
Z80_ZEX_STEPS / Z80_ZEXALL_STEPS |
Maximum CPU steps. 0 means unlimited (run to completion). |
Z80_ZEX_OUTPUT / Z80_ZEXALL_OUTPUT |
File to capture console output to. |
Z80_ZEX_PROGRESS_EVERY |
How often to print a progress line (in steps). |
Z80_ZEX_SILENT_LIMIT |
Steps with no new output before the harness gives up. |
The scripts run the exercisers with -timeout=0 so Go's default 10-minute test
timeout does not cut them off. Run them on a real machine; in a heavily
constrained or sandboxed environment they may appear to hang simply because
they need the cycles to finish.
To work on one instruction or behaviour, run a single test by name with go test -run. The pattern is anchored to a test function name:
# one test
go test ./z80 -run '^TestDAA_AfterAdd_LowerNibbleOverflow$' -v
# a related group (prefix match)
go test ./z80 -run '^TestDAA' -v
# the whole package, fast
go test ./z80ZEXDOC/ZEXALL live in tools/zex, not z80, specifically so a bare go test ./z80 (or go test ./... from the repo root) never discovers or
compiles them -- not merely skips them at runtime. Run them via
./zexdoc.sh/./zexall.sh, or go test ./tools/zex -run <name> directly.
Potential improvements while maintaining simplicity:
- Basic Debugger: Breakpoints, step debugging, register inspection
- Cycle Counting: More accurate cycle counting for each instruction
- State Serialization: Save/load CPU state
- Performance Optimizations: Table-driven decoder, caching
- Test Suite: Comprehensive instruction testing
- Z80 CPU User Manual
- The Undocumented Z80 Documented
- floooh's Z80 Emulation Blog Posts
- Decoding Z80 Opcodes
Email: h@ual.li
https://oldbytes.space/@haitchfive
Copyright 2026 h@ual.li
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS,