Part 2 of the softcard-emu series. In Part 1 I set myself a bar: boot unmodified disk images to an interactive prompt or don’t bother. This part is how I built the machine. Everything here lives in the Orchard repository as the softcard_emu package. Running python -m softcard_emu CPMV233.DSK --keys "DIR\r" reproduces this article. I wrote it with Claude Code doing much of the typing while I steered.
One memory, two CPUs, an exception for a bus
I built the architecture around the real machine’s one strange truth: there is exactly one memory, and the CPUs take turns. A single 64 KB bytearray is the Apple’s RAM. A full 6502 core and a full Z-80 core (from the repo’s nibbler toolkit, both instruction-complete, neither cycle-accurate) each see that array through their own lens: the 6502 directly, the Z-80 through the SoftCard’s four-window address translation, which I implemented as a ten-line function that every Z-80 access passes through.
The CPU switch is the part I’d happily defend in a design review. On real hardware, any access to the SoftCard’s slot I/O page flips bus ownership. In my emulator, the memory hooks raise a Python exception:
class Yield(Exception):
"""Raised inside a bus access to hand the bus to the other CPU."""
My run loop executes whichever CPU is active until its hooks raise Yield, then swaps. Each CPU resumes past its triggering instruction, exactly like the hardware. I gave it no scheduler, no cycle interleaving, no shared-state locking, because the real machine has none of those things either. The SoftCard’s entire elegance is that the CPUs are never concurrent, and an exception-per-switch models that with embarrassing directness. The boot does 176 switches. A DIR listing does a couple of thousand. The 2.20 failure loop does two million. Python shrugs.
The disk controller that cheats, honestly
Disk emulation is where whole-system projects usually drown, and I expected to drown here too. The Disk II has no sector hardware. Software reads raw GCR nibbles off a spinning surface and does its own framing, and CP/M’s RWTS drives stepper phases with timing loops whose assumptions a functional, non-cycle-accurate emulator can’t honor.
I sidestepped all of it with the right kind of cheat. Every disk read in this system, boot-time and runtime, funnels through one sector-read primitive in the preserved RWTS, with a documented contract (track at $03E0, sector at $03E1 via the CP/M skew table, destination pointer at $03E8/$03E9, carry plus a status byte coming back). So I put a breakpoint on that primitive, read the request out of the live state block, copy 256 bytes straight from the .dsk image, fake the success epilogue, and return. I also built a full nibble-level model, behind a flag, for fidelity runs. The bypass is the default because it substitutes for exactly one well-understood routine, below which nothing I was investigating lives.
That’s my design philosophy in miniature: hook at documented contracts, never patch the system’s code. The disk bypass, the monitor entries, the boot PROM are all PC breakpoints that service a known interface and hand control straight back. The bytes of the system under test stay untouched, which is what makes my findings transferable back to real hardware.
Monitor ROM as breakpoints, and a bug that impersonated the system
The Apple’s monitor ROM isn’t in the disk image, and shipping one with the emulator felt like scope creep to me, since SoftCard CP/M only calls a handful of entries. So I made monitor routines PC hooks too: execution reaching $FF4A runs my Python implementation of the monitor’s register-SAVE and returns.
Which produced the best bug of the project. The first interactive boot echoed every keystroke as the previous output character. I’d type DIR and see >>>. It looked exactly like a CP/M console bug. It wasn’t mine to blame on CP/M. The warm loop’s JSR $FF4A is not decoration: the monitor save area at $45-$48 is the RPC result channel between the CPUs, and my SAVE stub was a bare return, so the 6502’s keyboard result never landed where the Z-80 looked for it, and CONIN forever read CONOUT’s leftovers. One honest implementation of a 1977 monitor routine later, DIR worked.
The lesson stuck with me, and it’s worth a paragraph of emphasis: in a whole-system emulator, my bugs present as its bugs. My only defense was the bar from Part 1, keep raising fidelity until the system works end-to-end, because every subsystem I stub is a place where a wrong assumption can cosplay as a discovery.
The Videx, with its politics intact
My Videoterm model executes the real 1 KB firmware ROM (the same image the A2FPGA uses), with the MC6845 register file, the paged 2 KB VRAM behind its 512-byte window, and a screen decoder that reads the display out of CRTC state. So “what’s on the screen” is always derived the way real hardware derives it.
And critically, I gave the model the card’s bus politics: the $C800-$CFFF expansion-ROM window is owned, claimed by slot-page access, released by $CFFF, and, under the arbitration rule the A2FPGA implements, released by any access to a different slot’s page. Ownership is a switch (--flat-c800 turns it off), and I log every access to an unowned window with the responsible CPU and PC. That fault log turned out to be the instrument the whole investigation ended up resting on. Part 3 shows what it caught.
One wart, which I’ll document rather than hide: the 6502 core fetches opcodes straight from the memory array, bypassing the read hooks. So I mirror the firmware bytes into the fetch plane, and catch fetch-side ownership violations with a blanket of PC breakpoints over $C100-$CFFF. Per-access Python hooks plus seventeen hundred breakpoint dictionary entries, and the full boot still runs in seconds. Functional emulation in Python is fine. Nobody is more surprised than the 1980s.
The language card
The final piece I added is an Apple language card: 16 KB of bankable RAM behind $D000-$FFFF, with the real soft-switch semantics ($C080-$C08F, dual $D000 banks, the double-read pre-write counter, write protection). It matters because the warm loop’s opening bars, LDA $C083 twice before handing the bus to the Z-80 and LDA $C081 after taking it back, are LC bank management as part of the inter-CPU protocol: CP/M 2.20B’s entire BIOS lives in LC RAM, and the monitor calls need ROM banked back in. Once I modeled the card, the 56K system runs its BIOS through honest banking. (The 2.23 disk, for the record, banners as a 44K system because its boot tracks are the 44K build. The “60K” strings on that disk belong to CPM60.COM, a separate 60K system loader. Bringing that up was the package’s open item when I first wrote this, and I have since closed it: the emulator now banks language-card RAM faithfully enough to run CPM60.COM itself and boot the relocated 60K build. That became its own story, in Part 10.)
Part 3: what all of this machinery actually found.
Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.