@dcorp80/z80cpu
v0.1.3
Published
The hardware-accurate Z80 CPU emulator
Maintainers
Readme
@dcorp80/z80cpu
For the 50th anniversary of the Z80 CPU
A hardware-accurate half-cycle Z80 emulator for JavaScript and TypeScript. Models the original NMOS Z80 (Zilog Z8400 family).
What this library is
@dcorp80/z80cpu is a low-level, hardware-oriented Z80 core for:
- emulators
- debuggers
- reverse-engineering tools
- timing-sensitive simulation
It models half-cycle edges, bus-level behavior, interrupts, WAIT/BUSREQ timing, and undocumented behavior while exposing enough state for external tooling.
Companion toolkit
If you want higher-level tooling on top of this core — an interactive
debugger, a disassembler, and a REPL — head over to the
z80cpu-lab sibling repo.
This package is the bare CPU; z80cpu-lab is where the developer-facing
experience lives.
Features
- Half-cycle timing accuracy — every rising and falling edge is a distinct simulation step.
- Full instruction set, including undocumented opcodes.
- Documented and undocumented flag behavior.
- Hardware-accurate RESET, interrupt, WAIT, and BUSREQ processing.
- Models the Special Reset feature plus all NMOS-Z80 RESET quirks beyond the datasheet — see RESET behavior.
- Fast: ~20 MHz effective Z80 throughput in Node.js on Apple M1.
- JIT-friendly — no allocations on the hot path.
- Pure ESM — works in Node and the browser, no CommonJS build.
- TypeScript types included.
- Introspectable — exposes internal state, upcoming sequencer step, and architectural snapshots for debugger tooling.
Validation
- 7000+ tests, with behavior validated against the Visual6502 Z80 netlist.
- Extensive coverage of undocumented and timing-sensitive behavior.
Install
npm install @dcorp80/z80cpuUsage
import { Z80Cpu } from "@dcorp80/z80cpu";
const cpu = new Z80Cpu();
const mem = new Uint8Array(0x10000);
mem[0] = 0x3E; mem[1] = 0x42; // LD A,42h
const resolve = () => {
const { nMREQ, nRD, nWR, addr, data } = cpu.bus;
if (nMREQ === 0) {
if (nRD === 0) cpu.bus.data = mem[addr];
if (nWR === 0) mem[addr] = data;
}
};
for (let i = 0; i < 16; i++) {
resolve();
cpu.clockEdge();
}Each clockEdge() advances the CPU by one half-cycle (rising or falling). Run
your bus resolver before each edge: read the CPU's outputs, and on a read
cycle set cpu.bus.data to the value the CPU should sample.
Reading internal state
"Hardware-accurate" here refers to externally observable behavior: bus pins, instruction effects, M-cycle and T-state timing visible to the host. Internal state organization — the step pipeline, the bank-swap encoding, sub-edge sequencing details — is the emulator's own design and may differ from the real chip internals, even where external behavior matches at every edge.
That distinction matters as soon as you want to sample the registers.
An instruction's writebacks don't all land at the end of the
instruction itself — many register commits, plus various internal
flip-flops, are written inside the M1 cycle of the next
instruction, spread across multiple edges (M1_T2_1, M1_T3_1, …).
The F register is the last writeback in that series, committed
on the T3 falling edge of the following M1. Only once that edge has
fired does cpu.regs match what the real chip's internal latches
would hold.
For most instructions, the right place to read the CPU's internal
state is therefore StepId.M1_T3_1 of the next instruction's M1
cycle:
┌────────── instruction N ──────────┐ ┌────── instruction N+1 ──────┐
M1_T1_0 → M1_T3_0 → … → lastOpTState → M1_T1_0 → M1_T3_0 → M1_T3_1
▲
│
read internal state here
M1_T1_0 official start of the instruction; PC driven onto the address bus
M1_T1_1 PC incremented
M1_T3_0 opcode just latched — only now does the CPU "know"
which instruction it is about to execute
… additional M-cycles / T-states as the instruction runs
lastOpTState last T-state of instr N (2 half-cycles). On the bus the
instruction looks done, but several register writebacks
are still pending — they land in the next M1
M1_T1_0 next instruction's M1 cycle begins
M1_T3_0 next instruction's opcode latched
M1_T3_1 T3 falling edge of the next M1. The final deferred
writeback for instr N — the F register — lands here;
cpu.regs now matches the real chip's internal latches
100%Coarser markers — cpu.seq.lastOpTState or the M1_T1_0 boundary —
are useful for tracing or single-stepping "between instructions", but
state read at those points is still mid-writeback: the next M1 hasn't
yet finished applying it. If your debugger needs exact internal-state
semantics, advance to M1_T3_1 of the next M1 before sampling.
Introspection
The CPU exposes enough of itself for external observers (debuggers, visualizers, tracers) to be built without reaching into the simulator's implementation details.
Architectural snapshot
import { Z80Cpu, type CpuState } from "@dcorp80/z80cpu";
const cpu = new Z80Cpu();
// …run for a while…
const s = cpu.snapshot(); // fresh CpuState each call
console.log(s.pc, s.main.a, s.alt.b, s.im, s.iff1);
// In tight loops, allocate once and reuse:
const buf: CpuState = cpu.snapshot();
for (let i = 0; i < N; i++) {
cpu.clockEdge();
cpu.snapshot(buf); // writes into buf, no allocation
}CpuState decodes the bank-swap encoding (exAf, exx, exDe) into
named main / alt register banks, flattens the IM flip-flops to 0|1|2
and IFFs to booleans.
Numeric fields are plain JS numbers. Values are explicitly constrained
at CPU writeback sites, so out-of-range values indicate a bug rather than
runtime state. See Plain arrays below.
interface RegBank {
b: number; c: number; d: number; e: number;
h: number; l: number; a: number; f: number;
}
interface CpuState {
pc: number;
sp: number;
ix: number;
iy: number;
i: number;
r: number;
wz: number; // internal "MEMPTR"
main: RegBank; // currently-visible bank
alt: RegBank; // what EXX / EX AF,AF' would expose
im: 0 | 1 | 2;
/**
* `true` when the IM flip-flops are in the undocumented
* `imFa=0, imFb=1` state — behaves as IM 0, `im` still reads `0`;
* this flag is the only way to tell the two IM-0 states apart.
*/
imUndocumented: boolean;
iff1: boolean;
iff2: boolean; // restored to IFF1 by RETN; exposed via P/V on LD A,I/R
nmiPending: boolean;
}Use decodeFlags(f) to unpack an F byte into named bits when you
need the individual sign / zero / half-carry / etc. flags.
halted is intentionally omitted from CpuState — the HALT state is
surfaced through cpu.ctl.haltLatch (see the Hardware-level state flags
table below) and the cpu.bus.nHALT pin.
Upcoming step
import { StepId } from "@dcorp80/z80cpu";
if (cpu.nextStep === StepId.M1_T1_0) {
// about to start new instruction — a clean boundary for breakpoints, etc.
}cpu.nextStep returns the StepId of the step the sequencer will execute on
the next clockEdge(). Stable enum — the only public API for observing the
upcoming sequencer step. The sequencer's internal cpu.next function pointer stays
out of the public surface.
Raw state
cpu.regs, cpu.bus, cpu.seq, and cpu.ctl are all public objects.
You can read any of them at any half-cycle edge.
The register file cpu.regs.file: number[] is a hardware-like flat
view — hardware slot positions rather than architectural register names.
The bank-swap state lives elsewhere:
cpu.seq.prefix— bits0/1/2are theexAf/exx/exDeflip-flops; bits3/4flag the IX / IY substitution active for the current DD / FD prefix.cpu.seq.exDe0andcpu.seq.exDe1— the per-bank DE↔HL swap flip-flops (multiplexed onto bit 2 ofprefixbyexx).
For most consumers, cpu.snapshot() is the right answer — it decodes
all of that into named main / alt register banks. Going through
regs.file directly is for code that needs to track or reproduce the
bank-routing logic itself (e.g. a netlist comparator).
Hardware-level state flags
A few internal flip-flops are useful for instrumentation:
| Field | Meaning |
|--------------------------------|----------------------------------------------------------------------------------------------------------------------------|
| cpu.seq.lastOpTState | true during the last T-state of the current instruction — a convenient point to log state or single-step execution. |
| cpu.seq.phase | Clock phase, matches CLK: true after a rising edge (CLK high), false after a falling edge (CLK low). |
| cpu.ctl.haltLatch | Internal HALT state, set by the HALT instruction and cleared by RESET / INT / NMI. Distinct from the nHALT output pin. |
| cpu.ctl.nres | Normal-reset latch — set when RESET was sampled, cleared once recovery completes. |
| cpu.ctl.sres | Special-reset flip-flop — set when RESET is sampled during M1 T2. See RESET behavior section below |
| cpu.ctl.iff1, cpu.ctl.iff2 | Interrupt-enable flip-flops (also surfaced in CpuState). |
| cpu.ctl.nmiFf | NMI pending — set by cpu.nmi(), cleared at the NMI M1. Surfaced as nmiPending in CpuState. |
Bus and signals
The CPU exposes its pins through cpu.bus. The host advances time by
calling cpu.clockEdge() — that call is one half-cycle of CLK, so
there's no nCLK pin on the bus. Bus signals are exchanged between
those edges.
Active-low convention throughout: 0 = asserted, 1 = released. The
transaction signals (MREQ, IORQ, RD, WR) tri-state
represented as undefined, therefore typed 0 | 1 | undefined. The
address bus (number | undefined, 16-bit) and data bus (number | undefined,
8-bit) tri-state alongside them. The status signals (M1, RFSH, HALT) and
BUSACK stay driven throughout — typed 0 | 1.
cpu.bus.nM1 // 0 during M1 fetch
cpu.bus.nMREQ // memory request; undefined while granted
cpu.bus.nIORQ // I/O request; undefined while granted
cpu.bus.nRD // read strobe; undefined while granted
cpu.bus.nWR // write strobe; undefined while granted
cpu.bus.nRFSH // refresh (DRAM refresh during M1 T3/T4)
cpu.bus.nHALT // HALT acknowledged on the pin
cpu.bus.nBUSACK // bus-grant acknowledge
cpu.bus.addr // 16-bit address bus; undefined while granted
cpu.bus.data // 8-bit data bus; CPU drives during writes, host fills during readsInputs are typed 0 | 1 — the host sets them, the CPU samples them at
the modeled sampling edges:
cpu.bus.nRESET = 0; // assert RESET
cpu.bus.nINT = 0; // level-triggered interrupt request
cpu.bus.nWAIT = 0; // stretch the current memory / IO cycle
cpu.bus.nBUSRQ = 0; // request a bus grantNMI is the exception. The real Z80 NMI pin is edge-triggered — a falling edge latches a request internally. To model that faithfully through a level pin you'd need the host to toggle it and the CPU to detect the transition, which adds friction in a synchronous emulator. Instead, NMI is a method:
cpu.nmi();One call = one edge = one latched NMI, serviced at the end of the current instruction. Calling again while a previous NMI hasn't been acknowledged is a no-op.
RESET behavior
The reset path has more nuance than typical references suggest:
Special Reset
A designed but historically obscure feature. If RESET is sampled during
M1 T2, the CPU performs a partial reset: only PC is cleared to 0,
while registers and most internal state are preserved — not a full
reset. Documented by Tony Brewer at
primrosebank.net.
Surfaced as cpu.ctl.sres.
Beyond-the-datasheet quirks
The Z80 datasheet warns that if RESET is sampled during T2 or T4
of a cycle, MREQ may become indeterminate for one
T-state shortly afterward. In systems with dynamic RAM this can potentially
corrupt memory contents, which is why the datasheet recommends synchronizing
RESET with the falling edge of M1 when RAM must be preserved.
The transistor-level netlist suggests broader behavior:
- Sampled on the last T-state of any M-cycle, not only
T2orT4. RDalso becomes indeterminate, alongsideMREQ, during the nextM1 T1.- A spurious
RDlow pulse appears on the following reset recovery edge.
These effects may be netlist artifacts rather than real silicon behavior, but are modeled for completeness.
Plain arrays inside the CPU
The CPU's internal register state — cpu.regs.file, cpu.regs.f — is
plain number[]. Uint8Array is intentionally avoided here.
Why: V8 specializes object/array shapes by element-kind. Mixing typed
arrays with number[] on the same hot path tends to introduce element-kind
transitions ("type churn") that defeat inline caching. Sticking to
SMI-tagged 32-bit integers in plain arrays through the register file
keeps everything on one fast path.
Host-side storage is the host's choice. The bus resolver runs outside
the CPU — it can back memory and I/O ports with Uint8Array,
Uint16Array, plain number[], or anything else that responds to
addr and yields a number.
Side effect of the plain-array choice: register values aren't
automatically truncated to 8/16 bits — the CPU does that explicitly
at every writeback site, and cpu.snapshot() trusts that invariant.
Consumers reading raw state shouldn't need to mask either, as long as
the CPU stays correct.
License
Apache-2.0 — see LICENSE and NOTICE.
Acknowledgements
Inspired by the Visual6502 and VisualZ80 Remixed projects.
