Part 1 of a three-part series on the emulator behind my softcard-videx investigation: why I had to build it, how it’s built, and what it found. It’s free to download and run. It lives in the Orchard repository on GitHub as the softcard_emu package.
A system with four moving authorities
The question that started everything was small: why does Microsoft SoftCard CP/M 2.20 die at boot when a Videx Videoterm is installed, while 2.23 boots fine? I began the way you’re supposed to, with disassembly. Diff the disks, annotate the boot loaders, trace the code paths byte by byte.
Static analysis got me astonishingly far on this system. It found the 11-byte slot-scanner delta between the versions, the extra branch 2.23 uses to notice the Videx’s Pascal 1.1 signature and file it under a different device code than 2.20 does. (That readable difference was also the bait for a confident wrong answer, since the one part of the puzzle sitting in plain sight is the easiest to mistake for the cause; softcard-videx Part 5 is that story.) It mapped the boot pipeline from the Disk II PROM through the stage-2 loader. I could reconstruct both disks byte-identically from annotated source, with round-trip tests guarding every file. And for a while I thought reading the code would be enough.
It wasn’t. A SoftCard CP/M system has four authorities over what actually executes, and only one of them is readable:
- The bytes on disk. Static analysis sees these perfectly.
- The SoftCard’s address translation. The Z-80 sees Apple memory through a hardware mapping, and that mapping isn’t in the disk bytes anywhere. It has to come from documentation or from probing hardware, and every address-level conclusion I drew silently depended on it.
- Self-modifying code. The warm loop’s service-dispatch operand is rewritten by the Z-80 before every inter-CPU call; the boot loader patches its own installed fragments; the cold-boot pass fixes up the BIOS image. Disassembly shows you the placeholder, not the call.
- Bus-level hardware protocol. The expansion-ROM window at
$C800-$CFFFis owned by whichever card most recently claimed it. That’s state that exists in no memory dump, only in flip-flops on the cards themselves.
The actual bug lives at the intersection of all four: a self-modified call into a hardware-arbitrated window, made at the request of code on the other CPU. No amount of reading was going to catch it, because the failure isn’t in any single piece of code. It’s in the interleaving of two correct pieces of code against a bus protocol. To see it, I had to watch the system run.
What makes an emulator evidence
Here’s the trap I wanted to avoid. An emulator is an executable copy of my model of a system, not of the system itself, so every assumption I bake in comes back out as “behavior.” So I decided its evidentiary value would be gated by what it can do, not by what it agrees with:
- An emulator that can merely reproduce a crash has confirmed almost nothing. Crashes are the least specific behavior a computer has; nearly any wrong execution ends in one. If all my replica did was die when the real machine died, I’d have learned nothing. I might just have two different bugs that happen to share a symptom.
- An emulator that can run the whole system (boot from raw disk bytes, take keystrokes, read files, render its display through the real firmware) has survived thousands of independent little falsification tests before I ask it my actual question. Every subsystem that works end-to-end is a place a wrong assumption would already have shown itself.
That principle drove the design from the start.
The bar it had to clear
So I wrote the requirements as behaviors, not features:
- Boot an unmodified disk image, with no pre-extracted binaries and no hand-staged memory, from the Disk II PROM’s first sector read.
- Run both CPUs honestly: real 6502 and Z-80 cores, one shared Apple memory, switching on the same hardware event real SoftCards use.
- Execute the real Videx firmware ROM for every character of console output, into a real CRTC/VRAM model I can read a screen back from.
- Get all the way to an interactive
A>prompt: typeDIR, get the directory of the actual disk image, rendered on the actual emulated display. - And then, only then, flip on the instrumentation and ask about the bug.
That’s a high bar for a bug hunt, and that’s the point. Every capability on that list turned into a discovery instrument later: the keyboard path exposed the RPC register protocol, the disk path exposed the sector-read contract, the display path exposed which firmware entries CP/M actually calls, and the boot path exposed where every byte of the BIOS really comes from. Part 3 of this series is the inventory.
Part 2 is the build: two CPUs in a Python harness, a CPU switch made of an exception, a disk controller with a shortcut, and the debugging sessions where the emulator’s own bugs impersonated the system’s. Claude Code paired with me on most of it. A faithful two-CPU emulator is a lot of machine to write, and on my own it is exactly the kind of yak-shave that would have ended the investigation before it began. With Claude doing the bulk of the construction, and rebuilding it every time the bug demanded more fidelity, it became something I could stand up quickly and keep sharpening, so my own time went into reading what the thing was telling me instead of building it.

Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.