Two ways to emulate a machine
cpmemu and romwbw_emu both run CP/M software and share the same Z80 core, but they sit at opposite levels of abstraction. One intercepts the operating system, the other emulates the hardware. Neither is the better design. They answer different questions.
I maintain two Z80 emulators that run CP/M software. cpmemu runs CP/M programs on Linux and Windows with no disk image at all. romwbw_emu emulates a RomWBW machine down to its bank-switching hardware and boots CP/M from a real ROM image.
AI-built. I directed this work; I did not write the code. How that works.
They share a CPU core. Above it they have almost nothing in common, and that is the interesting part: the choice of where to cut between "emulated" and "native" determines nearly everything else about what the emulator can and cannot do.
Cut at the operating system
CP/M's system call interface is a single entry point. A program puts a function
number in register C, a parameter in DE, and executes CALL 5. Address
0x0005 holds a jump into BDOS.
cpmemu exploits exactly that. At startup it writes a jump instruction into emulated memory at address 5:
#define BDOS_ENTRY 0x0005
...
mem[BDOS_ENTRY] = 0xC3; /* JMP opcode */
mem[BDOS_ENTRY + 1] = BDOS_BASE & 0xFF;
mem[BDOS_ENTRY + 2] = (BDOS_BASE >> 8) & 0xFF;
BDOS_BASE is not code. It is a sentinel address, and the emulator's execution
loop checks for it: when the program counter lands there, the emulator services
the call in C++ on the host and returns, rather than executing any Z80
instructions at all. There is no BDOS in the emulated machine. There is a
jump instruction and a trap.
The consequence is that a CP/M file operation becomes a Linux file operation
directly. OPEN FILE opens a host file. There is no disk image, no CP/M
filesystem, no directory structure being simulated. The program's working
directory is a directory.
That property is the entire reason cpmemu exists. Most emulators keep a disk image containing a CP/M filesystem, which means testing a compiler turns into a loop of importing source files into the image and exporting results back out. With translated file I/O the test files just sit in the directory where you build them — which matters when the thing you are testing is a Z80 C compiler and the suite has hundreds of cases.
The idea is not new. tnylpo has worked this way since 2018. Where cpmemu goes further is naming: CP/M can only express an 8.3 filename, and a compiler test suite is full of files that are not 8.3. cpmemu maps arbitrary host paths of any length onto synthetic 8.3 names that the emulated program sees, with the mapping — and each file's text-versus- binary treatment, which decides whether line endings get converted — declared in a config file.
What that cut costs
Everything below the system call is gone, and some CP/M software lives there.
A program that writes to the screen through BDOS works. A program that pokes the video hardware directly has nothing to poke. Anything that reads raw sectors, implements its own filesystem, reprograms an interrupt vector, or depends on instruction timing is outside what this design can represent — not because of a missing feature, but because the machine those things address does not exist in this emulator. There is no disk controller to talk to.
For running compilers, assemblers and utilities, none of that matters. For running a game that scribbles on hardware, all of it does.
Cut at the hardware
romwbw_emu makes the opposite choice. It emulates RomWBW — a real firmware for real Z80 hardware — at the level of the memory system:
Memory model (when banking enabled):
- 512KB ROM (16 x 32KB banks, IDs 0x00-0x0F)
- 512KB RAM (16 x 32KB banks, IDs 0x80-0x8F)
- CPU sees 64KB: lower 32KB banked, upper 32KB fixed to common bank
A Z80 can address 64 kilobytes. A megabyte of ROM and RAM is reachable only by switching which 32K page is visible in the lower half of the address space while the upper half stays fixed — the fixed half is where the code doing the switching has to live, or it would page itself out mid-instruction. Getting that right is the whole job, and it has to be right at the memory-access level, beneath anything the software does.
HBIOS itself is not reimplemented in C++. It is Z80 assembly (emu_hbios.asm,
emu_rom.asm) executed by the emulated CPU, the same as on hardware. The
emulator provides the machine; the firmware runs on it unchanged.
So this design gets what the other one cannot: programs that bypass the operating system still work, because there is something underneath them to work against. It boots from ROM. It reads real disk images. WordStar and Zork run unmodified, and the same core is compiled to WebAssembly to run in a browser.
The price is the disk image. Getting a file in or out means manipulating a CP/M filesystem, which is exactly the friction cpmemu was built to remove.
The shared floor
Both need a Z80. Neither needed its own.
qkz80 — the CPU core, with Z80 and 8080 modes — lives in the cpmemu tree and
is packaged as a library. romwbw_emu builds against those sources from a sibling
checkout. Its banked_mem class extends qkz80_cpu_mem, overriding memory
access to add bank switching, and the instruction decoder above it neither knows
nor cares.
That is the seam that makes both designs affordable. Instruction decoding, flag
semantics and the 8080/Z80 differences are genuinely hard to get right and
genuinely worth testing once — the core carries its own test suite, with cases
like test_daa_8080 and test_inr_halfcarry pinning down the corners where the
two instruction sets disagree. Everything above the memory interface is policy,
and policy is cheap to have two of.
Choosing
The question is never "which emulator is more accurate". It is what has to be true for your purpose.
If you are testing a compiler, fidelity below the system call buys nothing and disk images cost real time on every iteration: intercept the OS. If you are running software that predates the assumption that an operating system is the only way to reach the machine, you need the machine: emulate the hardware.
Picking the wrong one is expensive in a specific way — you do not find out until you hit the first program that needs the layer you decided not to build, and by then the decision is load-bearing throughout. It is worth being explicit about which question you are answering before writing the execution loop.
That decision is also the part of this work that does not delegate. Both of these emulators were written by AI under my direction — I wrote the start of cpmemu and nothing since. Ask for "a CP/M emulator" and you will get one, competently, and it will embody a choice about that cut which nobody made on purpose. The specification is where the engineering lives: intercept at address 5 and translate to the host filesystem, or emulate 512 KB of banked memory and run the real firmware on top. Everything downstream of that sentence is implementation, and implementation is no longer the expensive part.