A Z80 you can drive from a clock, held to Zilog's own manual for the shape of every machine cycle and to a per-opcode suite for every T state of every opcode.
3 parts, 1,604,000 conformance cases and 22,005,372 T states compared, 0 failures, 1,305 tests, 100% statement and branch coverage, no dependencies
from z80 import Cpu, Memory
cpu = Cpu("z80", Memory(image=bytes([0x3E, 0x42, 0x47])))
cpu.reset()
cpu.registers.pc = 0x0000
cpu.step()
cpu.step()
print(f"{cpu.registers.b:02X}")42
pip install git+https://github.com/gufranco/zilog-z80-python.gitPython 3.12 or newer. Nothing else.
Everything a caller touches. Nothing else is public.
| Call | Does | Returns |
|---|---|---|
Cpu(model, memory=None, **options) |
Builds a part, powered and not yet reset. The model is required and there is no default; naming none raises and lists every model. Memory of its own if none is given | a Cpu |
cpu.reset() |
Drives RESET. Costs five T states: the three Zilog names as the minimum the pin must be held, and the two the part spends after it releases | the Cpu |
cpu.step() |
Runs one instruction | T states it cost |
cpu.run_for(cycles) |
Runs whole instructions until at least that many T states have passed | T states actually spent, usually a little over |
cpu.run_until(check, limit=None) |
Steps while check(cpu) is false. limit bounds the instructions and raises RunLimit |
the Cpu |
cpu.held() |
Whether the part has stopped advancing the program | bool |
cpu.irq(vector=0xFF) / cpu.nmi() |
Offers a line and acts on it now. vector may be a callable, which is how a device supplies an instruction longer than one byte in mode zero |
True if taken / nothing |
disassemble(data, address) |
Reads bytes with no machine to run them in | Instruction objects with .text |
MODELS |
Every model this package covers, by the name it goes by | a mapping |
| Pin or attribute | Is |
|---|---|
cpu.irq_line |
The request line as a level. Read at the final T state of an instruction, where the manual says the part reads it, so a request withdrawn before then is not taken |
cpu.nmi_line |
The non-maskable line. Edge sensitive: the transition interrupts, and holding it afterwards does not interrupt again |
cpu.wait_line |
Memory asking for more time. Read after T2 of each machine cycle, and every state it adds repeats T2 |
cpu.cycles / cpu.steps |
T states since construction, across resets; instructions since the last reset |
cpu.halted |
Whether a HALT is being executed, which still costs four T states a time |
cpu.registers |
a, f, b, c, d, e, h, l and the pairs af, bc, de, hl; sp, pc, ix, iy with their halves; the shadow set as af_, bc_, de_, hl_; i, r, iff1, iff2, im; and wz and q, the two nobody documents |
cpu.bus |
The T states of the last instruction, and the recorded cycles when recording=True |
cpu.on_cycle |
Called once per T state, after that state's bus activity |
Options: seed= fixes the undefined state, recording=True keeps a bus log, shape= picks which edge a pin is read on, ports= takes an I/O bus.
A part arrives powered, not reset, because no board hands over one that has reset itself. Every register holds rubbish derived from the seed, the program counter included, so stepping it executes rubbish from a rubbish address. Call reset() to get a machine that runs a program.
A part runs at whatever its crystal says. step() reports what an instruction cost, so a host can hold the part to a real clock.
import time
from z80 import Cpu
HERTZ = 3_546_895
SLICE = 0.02
cpu = Cpu("z80")
cpu.reset()
per_slice = round(HERTZ * SLICE)
owed = 0
for _ in range(5):
began = time.perf_counter()
owed += per_slice
owed -= cpu.run_for(owed)
time.sleep(max(0.0, SLICE - (time.perf_counter() - began)))An instruction cannot be cut in half, so run_for() overshoots and returns what it really spent. Carrying the overshoot into the next slice is what stops a long run drifting.
Clock stops the part between any two T states, which is where a board changes what a read will answer.
from z80 import Clock, Cpu, Memory
space = Memory(image=bytes([0x3E, 0x42, 0x00, 0x00, 0x00, 0x00]))
cpu = Cpu("z80", space, recording=True)
cpu.reset()
cpu.registers.pc = 0x0000
with Clock(cpu) as clock:
clock.tick()
space.write8(0x0001, 0x99)
clock.run_for(6)
print(0x99 in [value for _, value, _ in cpu.bus.log])True
The instruction picked up a byte written after it had already begun. That is real suspension rather than a replay, and it is what makes the three pins above mean anything.
It is not free. An instruction is an ordinary call stack and Python cannot suspend one, so the clock runs the part on a thread and lets it block where the T state is spent, which is what ares and bsnes do. Expect roughly fifty times slower than step(). Use step() for speed and Clock when the question is where a T state falls.
The instruction set never changed. Three things did, all undocumented or defective, which is why software that depended on any of them had to know which board it was on.
| Build it with | Bare OUT (C) sends |
Carry flag bits | Interrupt clears parity | These three columns measured |
|---|---|---|---|---|
Cpu("z80") |
nothing | accumulator and latch | yes, and Zilog documents it | yes, by the corpus |
Cpu("z84c00") |
every bit | accumulator and latch | no, Zilog fixed it | no, Zilog's own sentence |
Cpu("upd780c") |
nothing | accumulator alone | not stated | no, independent research |
The last column is narrow on purpose. One corpus exists, for the NMOS part, and everything the other two share with it is measured through it. What is not measured is the handful of behaviours that make them different parts, which is the three columns before it.
Each answers to the numbers its manufacturer sold it under. Case and separators do not matter.
| Build it with | Also answers to |
|---|---|
Cpu("z80") |
z8400, nmosz80, z0840004psc, z0840006psc, z0840008psc, mostekmk3880, mk3880, mk3880n, sharplh0080, lh0080, lh0080a, u880, ud880d, kr1858vm1, t34vm1, mme, goldstargms z80, thesysz80 |
Cpu("upd780c") |
necupd780c, d780c, d780c1, d780c2, upd780, upd780c1, upd780c2 |
Cpu("z84c00") |
cmosz80, z80c, z8400c, z84c0006, z84c0008, z84c0010, z84c0020, toshibatmpz84c00, tmpz84c00, t84c00, kr1858vm3 |
A part number nothing here implements is refused rather than resolved to something close, so Cpu("z180") raises UnknownModelError instead of handing back a Z80 missing instructions the caller asked for.
A survey of a ROM has nothing but the file, so reading and running are separate halves.
from z80 import disassemble
for found in disassemble(bytes([0x3E, 0x42, 0x47, 0xC9]), 0x8000):
print(f"{found.address:04X} {found.text}")8000 ld a,$42
8002 ld b,a
8003 ret
A run of bytes too short to complete its instruction raises Truncated rather than returning a guess.
Memory and registers hold a reproducible scrambled pattern. There is no parameter that clears them and there will not be one: a read of a byte nothing wrote is a defect on real silicon, and memory that answers zero turns that defect into a passing test.
from z80 import Cpu, SparseMemory
print(hex(SparseMemory().read8(0x1234)))
print(SparseMemory().read8(0x1234) == SparseMemory().read8(0x1234))
powered = Cpu("z80", SparseMemory())
print(hex(powered.registers.pc), powered.cycles)0x84
True
0x8926 0
A byte derived from the address, the same every time, and not zero. The part has spent nothing because nothing has driven RESET yet.
Every instruction is checked against a published per-opcode suite that states each register and each byte of memory before and after: 1,604,000 cases, no failures. The comparison then goes further and checks what the part put on the bus, T state by T state, address by address, pin by pin: 22,005,372 T states, no failures.
python3 -m conformance.fetch ~/.cache/conformance-suites
python3 -m conformance.singlestep ~/.cache/conformance-suites/z80/v1
python3 -m conformance.cycles ~/.cache/conformance-suites/z80/v1The suite commit is pinned so a build is reproducible, and a weekly job runs against whatever upstream holds now and opens a pull request or an issue. A runner reports what it checked rather than a bare pass, because a run that parsed nothing and found no failures exits zero and looks identical to one that checked everything.
Where the manual and the recordings disagree, both are kept. conformance/hardware.json holds every fact taken from a document with the sentence it came from and the page. conformance/divergences.json holds every place two sources part, with what would settle it. That reading found four places where the manual contradicts itself, three of them in its own timing tables.
A document says what the part does and a recording says what one part did. Neither says what is inside the package, and most of the questions left over are about what is inside.
conformance/netlist.py runs the part as a net of transistors, from a netlist extracted from die photographs. Driven from reset it fetches from 0x0000, executes, and comes to rest on every clock edge.
python3 -m conformance.netlistThe resolver follows chipsim.js and wires.js from the Visual 6502 project, which are MIT licensed; the notice they ask for is in THIRD-PARTY-NOTICES.md. The three data files are not carried here. conformance/netlist.manifest.json names them, says where they come from and records what each one hashes to; put them in docs/independent/visual6502 and a load refuses anything that is not what was read.
Two things had to be established here rather than taken from anyone. Thirty-two entries in the transistor file are pull-ups rather than transistors, and loading them is the difference between a netlist that comes to rest and one that never does. And the doubled net names hold the register set an instruction writes, while the single-letter ones hold the shadow set, which is the opposite of what the names suggest; that was measured by executing LD r,n across every register and every bit position and watching where the value landed.
Nothing in this package is held to what the netlist says. It sits below the manufacturer's documents and below a recording taken off a real part, because a netlist is an extraction and an extraction can be wrong.
Seventeen questions remain where being faithful is a claim rather than a measurement, and each names the measurement that would close it: OPEN-QUESTIONS.md. Some cannot be closed by anyone. Bits 3 and 5 of the flag register have gone undocumented through eleven revisions in forty years, and the internal register the recordings call WZ appears nowhere in 780 pages of Zilog's own paper.
python -m coverage erase
for file in $(find z80 conformance -name '*.test.py' | sort); do
python -m coverage run -a "$file"
done
python -m coverage reportpython3 z80/doctor.py says what is actually on this machine: the parts, what makes each one different, and whether the suites this repository cannot carry are fetched and whole. It is run as a file rather than with -m so that it still runs when the package itself will not import, which is the case it exists for. Its report is what an issue asks for, because a report is only as good as what it says about the machine that produced it.
Tests sit beside the module they cover, named <module>.test.py. Coverage is 100% of statements and branches, enforced. Types are mypy at strict. Commits follow Conventional Commits, and releases are cut by semantic-release.
AGENTS.md is the document for an agent working here. FAMILY.md is the standard this repository shares with mos65xx-python, kept identical in both.
z80/
core.py the processor
bus.py machine cycles, and the pins each T state carries
clock.py driving it one T state at a time
models.py the three parts, by name and alias
memory.py memory that holds what it held
opcodes.py the opcode table and a disassembler
registers.py the register file, shadow set included
conformance/
suites.json which corpus, at which commit
singlestep.py running it, state by state
cycles.py running it, T state by T state
hardware.json what Zilog printed, fact by fact
divergences.json where sources part
This repository carries no documents. Every claim is traced to something published elsewhere, listed here so a reader can fetch the same file and check the same page. Each row gives the page count and the first sixteen characters of the file's SHA-256, because vendor links move and a link that has rotted into a different revision is easy to follow without noticing. Compute the full digest with shasum -a 256 <file>.
Every manufacturer document below is copyrighted and not redistributable, which is why none is in this repository. Individual sentences are quoted in conformance/hardware.json with the page they came from.
| Document | Date | Pages | SHA-256 | Redistributable |
|---|---|---|---|---|
| Zilog, Z80 CPU User Manual, UM008011-0816 | 2016-08 | 332 | e3c83da5a5d8e372… |
No |
| Zilog, Z80 Family Data Book, 00-2490-01 | 1989-01 | 448 | 844681b63ffc45bd… |
No |
| Zilog, Z84C00 Product Specification, PS017801-0602 | undated | 36 | 06198d3c22a79a3f… |
No |
| NEC, µPD780C data sheet | undated | 24 | 2036fa845533feee… |
No |
Independent research, used only where two lineages that never consulted each other agree, and never as a citation for a figure a manufacturer gave.
| Document | Author | Pages | SHA-256 | Licence |
|---|---|---|---|---|
| The Undocumented Z80 Documented, v0.91 | Sean Young, 2005-09-18 | 52 | 6413048f39c2e735… |
GFDL 1.1 or later |
| Z80 CCF SCF Outcome Stability | Sainz de Baranda y Goñi, Brewer, Helcmanovsky | 4 | be87311012f9edaf… |
GFDL 1.3 |
| Undocumented Z80 Flags, rev 1.0 | David Banks, 2018-08-21 | 3 | 33766df5494e2fdf… |
None stated |
| MEMPTR, esoteric register of the ZiLOG Z80 CPU | Boo-boo, trans. Vladimir Kladov | text | f9e8e87cdd205e15… |
None stated |
| redcode/Z80 wiki: Interrupts and MEMPTR | Sainz de Baranda y Goñi and contributors | web | n/a | GFDL 1.3 |
| Source | Used for |
|---|---|
| SingleStepTests/z80 | The pinned corpus, 1,604,000 cases. Commit in conformance/suites.json |
| raddad772/jsmoo | The generator that produced it, so it can be rebuilt rather than only downloaded |
| gdevic/Z80Explorer | The netlist whose behaviour is recorded in conformance/divergences.json |
Fetching them is a command rather than an exercise. conformance/documents.json carries the full digest and the byte count for each, and an address where one is recorded, and conformance/documents.py brings down what can be fetched into docs/, which git ignores, and refuses anything whose digest does not match.
python3 -m conformance.documents # fetch what can be fetched and verify every digest
python3 -m conformance.documents --check # verify what is already hereFour of the eight have no address recorded anywhere here, and three more were printed from a web page rather than downloaded, so a fetch would replace them with markup. The manifest marks each of those and verifies them instead of replacing them.
The MEMPTR paper is pinned above as text and the copy in that folder is a print of the same writing, so the two have different digests. Neither is the other, and checking one against the other's digest finds a mismatch that means nothing.
CITATION.cff is kept in step with the released version by the same script that stamps the package, so the version it names is the version that shipped. GitHub renders it as a Cite this repository button.