RSS Amplifier

Wise Owl · Jun 16, 2026

Hooking the Contracts: Disk, Monitor ROM, and the Keyboard the Z-80 Reads Itself

0
Sign in to vote or save

Brent Rector · Wise Owl

Part 2 of this series (One Memory, Two CPUs, and a Bus Switch) built the core: a single 64 KB array, a 6502 core and a Z-80 core taking turns, and the bus switch modeled as a Python exception. That executes instructions. It doesn’t boot. CP/M boots by touching peripherals, and the peripherals are where the work is.

The running CP/M system reaches outside the CPU and the memory array in four places: the disk, the Apple’s monitor ROM, the channel the two CPUs use to call each other, and the keyboard. Each one is an interface with a fixed set of inputs and outputs. I serviced each interface from the outside and left the system’s own bytes untouched. Patch the code I’m investigating and my findings stop transferring to real hardware. The overview series covered the two tools in Two Tools and a Whole Distribution; below is the byte level.

The disk: one primitive, hooked

The Disk II controller has no concept of a sector. It is a stepper motor, a read/write head, and a shift register that streams self-clocking GCR nibbles off a spinning surface, and all the framing - where a sector starts, what track you’re actually over, whether the data is any good - is done in software by RWTS (Read/Write Track/Sector). RWTS drives the stepper by toggling phase magnets with timing loops, and those loops assume real-world microseconds. A functional emulator that doesn’t count cycles cannot honor them. An early run wedged inside the seek helpers around $BBC1, with the $46 step counter climbing and never arriving, because my settle delays meant nothing to a non-cycle-accurate core.

Every disk read in this system, boot-time and runtime alike, funnels through one primitive. In the 2.23 build that primitive is LOAD_CPM_PRIM at $BE11, and it has a documented calling convention I pulled straight out of the RWTS source:

  • $03E0 holds the track.
  • $03E1 holds the CP/M-logical sector. The physical sector comes from the skew table at $BF9E.
  • $03E8/$03E9 is the destination pointer.
  • $03E4 is the drive (bit 0 selects drive 1).
  • $03EB bit 0 set means read.
  • It returns carry plus a status byte at $03EA.

The emulator puts a PC hook on $BE11. When execution arrives there, the hook reads the request out of that live state block, copies 256 bytes straight from the .dsk image into the destination, fakes the success epilogue (clear carry, write the status byte), and returns as if RWTS had done the whole nibble dance. The stepper never moves because there’s nothing below $BE11 that I’m investigating; it’s a well-understood routine standing in for a physical mechanism. A --real-rwts flag preserves the full nibble-level path for fidelity runs, but the sector hook is the default because it substitutes for one routine with a clean interface.

One detail that matters for the distribution: 2.20’s RWTS lives at different addresses, so the $BE11 hook never fires for it, and its runtime reads run the real nibble path, which works, because by then the seek-timing problem had its own fix. The hook is build-specific, the way most things across this three-release distribution are.

The monitor ROM: run from ROM, regardless of RAM

The Apple’s monitor ROM is the firmware that lives at $F800-$FFFF on a real machine and provides primitive routines: print a character, save and restore the 6502 registers, scroll the screen. It is not in the disk image. SoftCard CP/M only calls a handful of those entries, so rather than ship a ROM I made the monitor routines into PC hooks too. Execution reaching $FF4A runs my Python implementation of the register-SAVE routine and returns; $FF3F is the matching RESTORE.

The flat memory model forces these hooks to run “from ROM” regardless of what’s sitting in RAM at those addresses. On a real Apple, $FF4A is monitor ROM, but on the larger systems the high addresses behind $D000 are switched between ROM and RAM as the machine runs, so one address like $FF4A is the monitor routine at one moment and part of the resident system at another. The emulator’s memory is flat and doesn’t switch banks, so it fires these hooks on the program counter alone and ignores whatever byte is in the array at the time. Get this wrong and 2.20 halts at $FF51, which is what an earlier version did, until the hooks ran unconditionally from PC.

My worst self-inflicted bug lived in these hooks. My first interactive boot echoed every keystroke as the previous output character: I’d type DIR and the screen showed >>>. It looked like a CP/M console bug. The cause was my SAVE stub. The CPU-handoff loop’s JSR $FF4A isn’t housekeeping. The monitor save area at $45-$48 is the result channel for the cross-CPU RPC, and a bare-RTS stub meant the 6502’s result never landed where the Z-80 went looking, so CONIN forever read back CONOUT’s leftover character. A bug in my own stub looks like a bug in the CP/M code I’m studying. The test that catches it is the one from Part 1: keep raising fidelity until the emulator boots an unmodified disk to its A> prompt. A wrong stub stops the boot before then.

The RPC channel: how a Z-80 asks a 6502 for a favor

The two CPUs never run at the same time, so when the Z-80 needs the 6502 to do something - read a key, write a character to the Videx - it can’t just call a function. It has to leave a request in shared memory, hand over the bus, and read the answer back when control returns. The protocol is small and entirely visible once you have the real address map.

This is the same CPU-handoff loop Part 2 walked through as the bus-switch mechanism, now read as an RPC channel. The bytes haven’t changed; the difference is reading them as a call-and-return through the monitor save slots $45-$48 and the operand at $03D0/$03D1. It sits in memory at $03C0:

$03C0: AD 83 C0  LDA $C083      ; bank soft-switch, read twice
$03C3: AD 83 C0  LDA $C083
$03C6: 8D 00 C7  STA $C700      ; <- BUS FLIP (slot 7 I/O space)
$03C9: AD 81 C0  LDA $C081
$03CC: 20 36 0E  JSR $0E36      ; restore A,X,Y,P from $45-$48; RTS
$03CF: 20 58 FF  JSR $FF58      ; <- operand at $03D0/$03D1 is PATCHED
$03D2: 8D 81 C0  STA $C081
$03D5: 78        SEI
$03D6: 20 4A FF  JSR $FF4A      ; save A,X,Y,P to $45-$48
$03D9: 4C C0 03  JMP $03C0

Three things make this an RPC. First, the 6502’s registers travel through the monitor save slots $45-$48: the Z-80 writes them via its own $F045-$F048 (Apple $0045-$0048 through the -$F000 translation window), and JSR $0E36 restores them into the live 6502 registers before the call. $0E36 is plain 6502 code, A5 48 48 A5 45 A6 46 A4 47 28 58 60: pull P, load A/X/Y from $45/$46/$47, restore. Second, the call target is not fixed. The JSR at $03CF ships with a placeholder operand, and the Z-80 patches $03D0/$03D1 (its $F3D0) with the address of whichever 6502 service routine it wants this time. Third, the bus flip at $03C6 (STA $C700) hands control to the 6502 in the first place; the Z-80 had earlier flipped to itself via LD ($E700),A, Apple $C700 through the I/O window.

The dispatch helper that arms all this is at Z-80 $FB45: 22 D0 F3 32 00 E7 C9, which is LD ($F3D0),HL / LD ($E700),A / RET, storing the service address into the CPU-handoff loop operand, then flipping the bus. (This is the routine I’d once misread as having a code/data overlap; at the true base it’s three clean instructions, no overlap, and the symbol got renamed RPC_DISPATCH. The byte I’d read as a JR displacement sits at true $FA8D, which nothing calls.) Results come back the same way they went out: the 6502 service routine runs, the CPU-handoff loop’s JSR $FF4A saves the registers back into $45-$48, the bus flips back, and the Z-80 reads $F045+.

The 6502 service routines themselves live at Apple $0DD0-$0E35, a small 6502 code island sitting inside the shared BIOS pages, which is Z-80 $FDD0+ from the other side. In 2.23 that island is a textbook Pascal 1.1 firmware client: $0DD0 INIT, $0DE1 WRITE (CONOUT), $0E06 READ (CONIN, with a status wait), $0E14 STATUS, plus the $CFFF deselect and $C330 claim that Part 4 is about. Disk goes through the same island: $0E00 is JMP $BBE9, $0E03 is JMP $BE04, both into the preserved RWTS.

One install-time detail explains a stray byte. The loop ships with a slot placeholder - 2.23’s install image literally has STA $FFFF at the flip site, 2.20’s has STA $C400 (slot 4) - and the stage-2 slot scanner patches it to the SoftCard’s real slot once it identifies the card, via STY $03C8 / STA $03C7 around $1086-$1090. That’s why the running loop shows STA $C700 for slot 7 even though no source file contains it.

The keyboard: the Z-80 reads it directly

The last interface needs no RPC at all. The Apple keyboard is two memory-mapped locations: $C000 holds the last key in bits 0-6 with bit 7 set when a key is waiting, and reading $C010 clears the strobe. Through the SoftCard’s I/O window, Apple $C000-$CFFF is Z-80 $E000-$EFFF, so the Z-80 reads $E000, clears at $E010, and talks to the hardware itself, with no bus flip and no register marshaling. The code sits at Z-80 $FB39-$FB44: poll $E000 bit 7, strobe-clear at $E010. The emulator injects keystrokes by feeding that location (--keys "DIR\r"), and the Z-80 picks them up on its own.

This corrects an old reading. The $E000/$E010 pair had once been described as a sync-flag handshake between the two CPUs, as if it were part of the RPC mechanism. It’s the keyboard, seen from the Z-80 side of the address map, nothing more. The wrong address map made a lot of plain I/O look like protocol, and untangling that was most of the work.

Four interfaces, four hooks, and not one byte of the CP/M code rewritten. The disk hook services a request and returns. The monitor hooks run from PC regardless of RAM. The RPC channel is the system’s own bytes, observed, not altered. The keyboard is read where the hardware would be read. With the window-ownership model added on top, Part 4 watches 2.20 destroy its own expansion-ROM claim, and 2.20’s code is untouched while it happens.

Read the original on wiseowl.com

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.