The Z80 emulator is the largest thing on this site: a whole little computer that boots in a browser tab. You type assembly or C, press Run, and watch a 1976 CPU execute it one instruction at a time, with its registers, its memory and a screen you can draw on.
This is how it's put together — where the CPU came from, why the screen and the buttons are nothing but memory, how a browser tab paces a 3.5 MHz clock without setting your laptop on fire, and why compiling C needed a second service and a sandbox. The site around it is a separate write-up.
What it is
The emulator is a Zilog Z80 with 64 KiB of address space, part of it a memory-mapped display and a byte of buttons. All of it is Rust compiled to WebAssembly, and all of it runs on your machine: once the page has loaded, nothing about running a program involves the server. There is no account needed, no session, no telemetry about your code. What you type stays in the tab (and in localStorage, so it's still there tomorrow).
What you get on the page:
- a display, 192×128 pixels, which programs draw to by writing bytes;
- a pad — four arrows and four numbered buttons, which a program reads as one byte while it runs;
- an editor with two languages — Z80 assembly, assembled in the browser, and C, compiled on the server;
- the registers, all of them, including the alternates, editable while the machine is stopped;
- a disassembly of whatever is in memory, following the program counter;
- a memory view over the full 64 KiB, which highlights the bytes the last instruction touched;
- a clock you can set to anything from a few hertz — slow enough to watch each instruction land — up to far faster than the real chip ever ran.
Programs can be stepped one instruction at a time, run until a breakpoint, or paused mid-flight; the whole 64 KiB can be saved to a file and loaded back.
The machine: a CPU and 64 KiB
The CPU is not written for this site. It comes from rs_emu_lib, an emulation library of mine that predates the site by some years, and which the site depends on as a Git dependency pinned to an exact commit. That pin matters: a CPU core is precisely the kind of thing whose behaviour you want frozen while you debug something above it.
The library gives me a Z80 and a Memory built out of devices, which is what makes the memory map a few lines rather than a subsystem:
0x0000 ─ 0x3FEF 16368 B RAM code, by convention
0x3FF0 ─ 0x3FFE 15 B I/O reserved, reads 0
0x3FFF 1 B I/O the buttons, read-only
0x4000 ─ 0x9FFF 24 KiB screen 192 × 128 pixels, one byte each
0xA000 ─ 0xFFFF 24 KiB RAM data, and the stack growing downFour devices in a row: RAM, the input registers, the display, RAM. The CPU knows nothing about any of it — it reads and writes addresses, and the device sitting at that address decides what happens. Apart from the input registers, the map is the one the original desktop version of this emulator used, so programs and memory images written for that still run here.
Nothing enforces the convention that code goes low and data goes high. A program is free to execute out of the screen and watch itself scroll past as pixels, which is exactly the sort of thing a Z80 ought to let you do.
A screen that is only memory
There is no video chip, no frame-buffer API, no draw calls. The display is a memory device 24,576 bytes long, one byte per pixel, and drawing a pixel is LD (HL), A. Each byte is a colour in RGB332 — three bits of red, three of green, two of blue:
bit 7 6 5 4 3 2 1 0
R R R G G G B B
└── red ───┴── green ─┴─ blue
0xE0 = red 0x1C = green 0x03 = blueEight bits per pixel is a deliberately generous choice for a machine of this vintage — a real Z80 micro would have had a palette and bit-planes and a great deal of cleverness — but it makes the first program somebody writes a three-line loop instead of a research project. Turning those bytes into something a canvas can show is a 256-entry lookup table, computed at compile time by a const function, so there is no conversion cost at startup at all.
Two details make this cheap enough to do sixty times a second:
- The pixels are atomics. The display's bytes are
AtomicU8s behind anArc, so the emulator (which writes them, as a memory device) and the screen (which reads them, once a frame) share them with no lock and no copy between them. - It only redraws when something changed. Every write sets a dirty flag; the screen swaps that flag to false when it draws. A program that computes for a second without touching the screen costs exactly one canvas operation, not sixty.
The canvas itself is 192×128 — its true size — and CSS scales it up with nearest-neighbour, so the pixels stay pixels at any window size. The conversion writes into one reused buffer that is allocated once, which matters more than it sounds: at 60 fps, allocating 96 KB per frame is how you get a sawtooth in a memory profile.
Eight buttons, in one read-only byte
A computer you can only watch is a demo. The emulator has four arrows and four numbered buttons, and they work the way the screen does — not through an API, but as an address. One byte at 0x3FFF, one bit per button:
bit 7 6 5 4 3 2 1 0
4 3 2 1 R L D U
└ buttons ┘ └ arrows ┘
AND 0x0F → any arrow held AND 0xF0 → any button heldReading a button is LD A, (0x3FFF) and then BIT 0, A for up. The arrows are the low nibble and the numbered keys the high one, so AND 0x0F asks "is any direction held" in one instruction and AND 0xF0 asks the same of the buttons. Bits, not bytes, because several are held at once constantly: a diagonal is two bits, and jumping while running is three. One byte polled once a frame is also the cheapest thing a Z80 can possibly do about input.
It is read-only, and that turned out to be the one decision with teeth in it. Writing to it doesn't silently do nothing, which is what a real machine would do and what would have you staring at a working program wondering why nothing moves — it stops the program, with attempted to write memory at read only address: 0x3FFF in the log. The CPU writes through the same path the library uses for ROM, so an error there aborts the run, and that is exactly the behaviour I wanted: in a debugger, failing loudly beats failing politely.
The same read-only rule applies to the host, not just to programs, and that's less obvious. Loading a program clears all 64 KiB first, and loading a saved memory image writes every address — so if those could write this byte, pressing a key and loading a program at the same moment would hand your program a key that is held and can never be released. The byte refuses the emulator's own writes too. The buttons own it; nothing else gets a say.
Which is also why the memory view shows those sixteen bytes as read-only cells rather than editable ones. They would have taken your typing and shown it back while memory never changed — a small lie, but exactly the kind that costs an hour.
Pressing a key works while a program is running, and the mechanism is the same trick as the screen's: the byte is an atomic behind an Arc, so the pad holds a clone of the same byte the memory map holds. A press writes it directly and never touches the machine — which matters, because during a frame the run loop owns the machine, and because telling every panel "something changed" sixty times a second on the off-chance you're holding an arrow would cost more than the emulation.
The emulator's C example is a two-player Pong built on that byte — one player on the arrows, the other on buttons 1 and 3 — which is as good a demonstration as I can give that a program really does see a key go down while it runs.
Fifteen of the sixteen bytes read zero and do nothing. They are there so that the next input device — a keyboard, sound, a timer — doesn't move the memory map again, which brings us to where this block sits. It is below the screen, not above it, and that is not for elegance: 0xA000 is where the C toolchain puts every program's variables. Putting registers there would have meant relinking every compiled program, regenerating the committed test binaries, and a flag-day where a site and a compiler deployed minutes apart disagree about where data lives — the kind of skew that corrupts silently rather than failing. Sixteen bytes off the top of the code region cost one number in the compiler's size check and nothing else.
An assembler in front of a parser
rs_emu_lib knows how to turn one bare instruction into bytes. That is genuinely most of the hard part — the Z80's encoding is a thicket of prefixes and special cases — but it is not an assembler. An assembler is what turns a file into bytes, and files have labels in them, which means you cannot know what any jump encodes until you know where everything is.
So the site has a small two-pass assembler sitting in front of the library's parser:
- Pass one walks the lines, works out how long each instruction is and therefore the address of each one, and so learns where every label points. It doesn't encode anything yet.
- Pass two rewrites each operand into the one spelling the library's parser accepts — a hex literal, and a 16-bit value as exactly four hex digits — substitutes the labels it now knows, and asks the parser for the bytes.
On top of that it adds the things programs actually need and a lone instruction doesn't: labels, ORG, EQU, DB / DW / DS for data, comments, and numbers in whatever base you felt like writing them in.
One conversion is worth singling out, because it's the one everybody trips over when they write Z80 by hand. JR and DJNZ don't encode an address; they encode a signed one-byte distance from the next instruction. Writing those by hand is miserable and writing them wrong is silent. The assembler lets you write a target address or a label and works out the displacement, and refuses the ones that are out of reach instead of wrapping them into a jump somewhere surprising. The disassembler does the favour in reverse: a JR is shown with its raw displacement and an arrow to where it actually lands.
Errors come back as a list, one per bad line with its line number, rather than stopping at the first one — if you've mistyped four labels you would like to hear about four labels.
Pacing a 3.5 MHz clock at 60 fps
The clock starts at 3,579,545 Hz, which is the NTSC colour-burst frequency the original machine divided down for its CPU, and which I kept out of sentiment. A browser gives you an animation frame roughly every 16 ms, so each frame has to run about 57,600 cycles. Getting that to feel right took four small rules, all of them in one file:
- Owed cycles carry over. A frame is due
frequency × elapsedcycles, which is almost never a whole number. The fraction is carried into the next frame rather than rounded away, so a clock set to 30 Hz really does advance one cycle every other frame instead of drifting or stalling. - A frame spends at most 10 ms emulating. The rest of the 16 ms belongs to drawing the screen and to the page staying responsive. Past the budget the emulator simply runs slower than you asked — the clock you set is a target, not a promise.
- The budget is checked every 20,000 cycles. Checking the clock after every instruction would cost more than the instructions; checking it once a frame would let a tight loop blow straight through the budget. A slice is the compromise.
- Catching up is capped at 100 ms. Background a tab for five minutes and the next frame is nominally owed eighteen billion cycles. Without a cap, coming back to the tab means it locks up while it faithfully runs all of them. With one, it just carries on.
What the page shows you as the measured clock is a smoothed average of what actually ran — a plain exponential moving average, because an unsmoothed number jitters too much to read. It is also the honest one: set the clock to 50 MHz and you'll watch the measured figure settle well below it, which is the emulator telling you it has run out of frame.
Stepping, breakpoints and disassembly
A debugger is where an emulator earns its keep, and the Z80 is small enough that you can show all of it at once. Everything is live:
- Every register, the alternate set included, with
PC,SP,IX,IYand the odd pairIandR. They're editable while the machine is stopped: typing a newPCis the fastest way to jump into the middle of a routine and see what it does. Hex is accepted in every spelling anyone has ever used —1F,0x1f,$1F,1Fh. - The memory view covers all 64 KiB and highlights the addresses the last instruction or load wrote, which the library's memory tracks for me. Watching a
LDIRlight up a block is a decent way to be sure it did what you meant. - The disassembly is produced from memory on demand, not from your source, so it shows what the machine will really execute — including after a program has rewritten itself. Bytes that aren't a valid instruction become a one-byte
DBline so the listing carries on past them rather than giving up; wandering into data is a normal thing to do by accident, and the listing shouldn't punish you for it. - Breakpoints are a click in the margin of the disassembly. A run stops with the reason it stopped — a breakpoint, a
HALT, or an instruction the CPU couldn't execute — and says which, in a log with timestamps, rather than just going quiet.
The whole 64 KiB can be saved to a file and loaded back, which is the lowest-tech snapshot mechanism imaginable and has never once confused me about what it does.
C, in a service of its own
Assembly assembles in the browser. C cannot: compiling C for a Z80 means z88dk, a real and venerable toolchain of preprocessors, compilers, assemblers and linkers — none of which runs in WebAssembly, and all of which would be running on source typed by strangers. That doesn't belong anywhere near the process serving the website, so it lives in a service of its own:
- A small Rust API around
zcc, in its own container on Cloud Run. It scales to zero when nobody is compiling, and handles one compile at a time per instance, so nothing of one person's program is on disk during somebody else's. - Nobody on the internet can reach it: its ingress is internal, and a call must carry a Google ID token for the site's own identity, which the site fetches from the metadata server beside it and renews well before the hour is up. There is no key for either side to lose.
- Every run of the toolchain happens in a fresh directory with a scrubbed environment — three variables get through, the ones z88dk itself needs — under limits on CPU time, address space and file size, with a wall-clock deadline that kills the entire process group. The limits are applied by the shell immediately before it
execs the tool, which means the service needs nounsafecode to set them. - The request and response types live in a tiny crate shared by both sides, so the site and the compiler cannot disagree about the contract without one of them failing to build. Since they deploy separately, that crate's rules are written down: add fields, never rename them; a new diagnostic severity must read as "other" to an older site; when the site starts relying on something new, the compiler ships first.
- The site spends the compiler's capacity carefully: 64 KiB of source at most, two compiles in flight across the whole site, twenty a minute per visitor, and a sign-in required. The first compile after a quiet spell waits a few seconds for Cloud Run to start an instance, and the editor says so rather than looking frozen.
The toolchain is pointed at the emulator's memory map: code and constants from 0x0000 up to the input registers, data and variables from 0xA000, the stack coming down from the top. zcc will happily build a program far too large for any of that, so the service checks the sizes itself and reports a program that doesn't fit as a diagnostic, which is friendlier than a machine that mysteriously misbehaves because its variables are underneath the screen.
Two niceties fell out of having a real toolchain available. The editor can show you your C as the assembly it compiled to, with the C interleaved as comments — a very good way to find out what a Z80 thinks of your loop — and it can reformat your source, since the same container already had a formatter in it.
All of it in one lazy island
The site renders pages to plain HTML on the server and sends WebAssembly only for the parts that have to react to you. The emulator is the extreme case of that: the CPU, the assembler and the entire debugger UI are one lazy island, compiled into a separate WebAssembly file that only this page ever downloads. Nothing else on the site pays a byte for it.
Inside the island, keeping sixty frames a second was a matter of keeping the machine out of the reactive system. A naive version makes the registers, the memory and the screen all reactive state, and then every instruction notifies everything, and you get a few thousand frames' worth of work per frame. Instead the machine is stored outside it, and a single version counter is bumped once per frame while it runs. Each panel derives what it shows from that counter through a memo that compares its own value — so the registers view only re-renders the registers that changed, and the memory view only the rows that did.
The page is server-rendered as inert markup first, which flips to live when the island starts. It means the layout doesn't jump around while that WebAssembly arrives, and a search engine — which will never run any of it — still gets a page that says what this is.
Things that went wrong
Four that cost real time, and all of which left something behind in the code:
- C programs that inherited the last program's memory. Compiled C came out running correctly once and then wrongly, or wrongly only on a second Run. The C start-up code is stripped out — this machine has no operating system to return to — and with it goes the part that zeroes uninitialised variables. So a fresh program was reading whatever the previous one had left lying at those addresses. Loading a program now clears all 64 KiB first, loads the data segment (which carries those zeros explicitly) as well as the code, and resets the CPU. There's a test that deliberately fills memory with garbage, sets the program counter somewhere absurd and halts the CPU before loading, just to prove that none of it survives.
- Relative jumps, in both directions. The library's parser speaks the machine's language and takes
JRas a signed displacement, which is correct and completely unusable by hand. Writing an address and getting a jump to a byte 130 bytes away with no complaint is a bad afternoon. The assembler converts targets to displacements and rejects what's out of range; the disassembler annotates everyJRwith where it lands. - A tab that locked up after a coffee break. Leave the emulator running, switch tabs, come back, and the frame loop would dutifully try to run every cycle it had been owed while the tab was asleep — minutes of emulated time in one frame, with the page frozen until it finished. Catching up is now capped at 100 ms, so it just resumes.
- An invisible 96 KB per frame. The first version of the screen built a fresh RGBA buffer every time it drew. Nothing looked wrong; it just made the garbage collector a permanent fixture of the profile. One buffer, allocated once and reused, plus the dirty flag so most frames don't draw at all.
What I'd change next
The honest list. There is no sound, and a Z80 without a square wave somewhere is missing a trick — that's what the next reserved byte is for. Eight buttons is enough to play something but not to type, so a proper keyboard register is on the list too. Interrupts work at the CPU level but nothing on the machine generates them, so there's no vertical blank to synchronise to, and a game has to poll the buttons and guess at the frame. And I'd like symbolic debugging for C, so a breakpoint could be set on a line of source rather than on an address you worked out by reading the generated assembly.
If you want to see what it actually does, the emulator is right there, with an example already in the editor that draws a band of every one of its 256 colours. Press Run. The C tab has a two-player Pong, if you can find somebody to share a keyboard with.