cpm-videx-series — Part 3
Apple Memory, Through Z-80 Eyes
How the Microsoft Z-80 SoftCard fits a Z-80 CPU into an Apple ][ that doesn't share its instruction set, and what the SoftCard CP/M BIOS looks like once you can read its first few hundred bytes. The address-line XOR nobody warns you about; a per-device dispatch table 2.20 didn't have.
Part 3 of the cpm-videx series. Part 1 identified the 11-byte detection delta. Part 2 walked the 6502 boot stage to the moment the Z-80 takes over. This part picks up at the Z-80’s $0000 and looks at what CP/M actually is once it’s running.
There’s a moment, somewhere between when the 6502 stops executing and when the Z-80 fetches its first instruction, where the Apple ][ becomes a different machine. The disk drive is still spinning down. The screen still shows whatever the 6502 last drew. The SoftCard’s hardware-level CPU switch has flipped, and the Z-80 starts decoding its first byte — which, as Part 2 showed, is $C3 at Z-80 address $0000. That’s the Z-80 opcode for “JP” (absolute jump), with the next two bytes — $00 $FA — forming a little-endian target. So the Z-80’s first action is JP $FA00 — straight into the CP/M BIOS.
What happens in the next half-second is what makes a CP/M machine. That story is the subject of the next several articles. This one sets up the architecture: how the SoftCard fits a Z-80 into an Apple ][ at all, what CP/M’s three-layer software model looks like once it’s running, and what the 2.23 BIOS actually contains in the small fraction of it that’s been cleanly extractable so far.
The XOR Nobody Warns You About
The SoftCard isn’t a coprocessor in the sense of “a second CPU that shares memory.” It’s an alternative CPU — when the Z-80 is active, the 6502 is halted, and vice versa. They take turns, with a hardware soft switch flipping which CPU drives the address bus.
But they don’t see the same address space. The Z-80 reads memory through an address translator built into the SoftCard, and that translator does one specific thing: it XORs bit 12 of the Z-80’s 16-bit address with 1 before presenting the address to the Apple ][ memory bus. Concretely:
| Apple ][ address | Z-80 address | What lives there |
|---|---|---|
$0000-$0FFF | $1000-$1FFF | Apple zero page, stacks, text page 1 — Z-80 sees this as TPA-extended |
$1000-$1FFF | $0000-$0FFF | Apple text page 2 area — Z-80 sees this as low memory (incl. reset vector) |
$2000-$FFFF | $2000-$FFFF | Same address on both — high memory |
The reason for this swap is a Z-80 design constraint that bumps into an Apple ][ design constraint. The Z-80 expects RAM at its low addresses — $0000 is where it fetches its first instruction after reset, $0008/$0010/$0018/... are the eight RST restart vectors, and so on. The Apple ][ uses its low addresses ($0000-$01FF) for the 6502’s zero page and stack — stuff that’s already in active use by the Apple’s monitor ROM, BASIC, and any application code. You can’t just point the Z-80 at Apple $0000 because everything would clobber the 6502 state and vice versa.
The bit-12 XOR sidesteps this. The Z-80 fetches its reset vector from Z-80 $0000, which is physically Apple $1000 — squarely in user RAM, well clear of the 6502’s state. Conversely, the 6502 sees Apple $1000 as just another byte of user RAM, available for whatever the loader wants to put there (which, as Part 2 showed, is the three-byte Z-80 reset vector that points the Z-80 into the CP/M BIOS).
Above $2000, the addresses match — both CPUs see the same byte at the same address. That’s the bulk of the Z-80’s address space, and it’s where CP/M lives.
CP/M as Three Layers
CP/M was designed to be portable across machines. The trick is layering: the parts that talk to hardware are isolated into a small replaceable module, and the parts that don’t are written once and shared across every machine that runs CP/M. The three layers:
The CCP — the Console Command Processor. The thing that displays the A> prompt, parses what you type at it, and either runs a built-in command (DIR, ERA, TYPE, SAVE, REN, USER) or loads and runs a .COM program. About 2 KB of Z-80 code. Identical across every CP/M 2.x machine — Microsoft’s port for the SoftCard uses the same CCP that runs on a Kaypro, an Osborne 1, or an IMSAI.
The BDOS — the Basic Disk Operating System. The 39-call API that programs use for filesystem operations (OPEN, READ, WRITE, CLOSE, etc.), console I/O (line-oriented reads, character output), and a small handful of system services. About 3.5 KB of Z-80 code. Also platform-independent — same code runs everywhere.
The BIOS — the Basic Input/Output System. The small machine-specific module that knows how to actually talk to the screen, the keyboard, the printer, the disk drives. About 1-2 KB of code, written specifically for whatever machine the CP/M build is targeting. The BIOS is the only part that varies between SoftCard CP/M 2.20 and 2.23. Everything else is bit-for-bit identical because everything else came from Digital Research and didn’t change.
In memory, the layout is:
Z-80 address space (after the SoftCard switch):
$0000 Z-80 reset vector (the JP $FA00 the 6502 planted)
$0008 RST 1 vector
$0010 RST 2 vector
...
$0040 ── interrupt handlers, system trap area
$0100 ── Transient Program Area (TPA) — user programs load here
...
── most of the address space is TPA
...
$E400 CCP cold-start (in 2.23)
$EC00 BDOS entry
$FA00 BIOS cold-start
$FAB8 BIOS jump table (15 entries)
$FAE5 ── BIOS routines
...
$FFFF end of memory
A user program that runs (a .COM file) gets loaded at $0100 and uses everything up to where the CCP sits. So a 2.23 system gives the user roughly $E400 - $0100 = 57 KB of TPA. Generous for 1982. (The 2.20 BIOS sat at $DACC — about 8 KB lower, leaving 51 KB of TPA. The relocation in 2.23 is part of why the version delta is bigger than just the Videx fix.)
The BIOS Jump Table
CP/M’s BIOS has a contractual structure: the very first thing in it has to be a jump table — 15 (CP/M 2.0) or 17 (CP/M 2.2) JMP instructions, in a documented fixed order. The BDOS calls into the BIOS by computing an address from the BIOS base and the entry-point index, jumping to it, and expecting that jump to land at a JMP that vectors to the actual implementation. This indirection lets the BIOS reorganize its internals without breaking the BDOS interface.
The 2.23 SoftCard BIOS’s jump table, disassembled with the Z-80 disassembler I wrote for this project, looks like:
$FAB8: C3 D1 FE JP FED1 ; BOOT — cold-start
$FABB: C3 B8 FA JP FAB8 ; WBOOT — warm-boot (jumps back to BOOT)
$FABE: C3 10 FB JP FB10 ; CONST — console status
$FAC1: C3 1A FB JP FB1A ; CONIN — console input
$FAC4: C3 4D FB JP FB4D ; CONOUT — console output (the Videx-relevant one!)
$FAC7: C3 70 FB JP FB70 ; LIST — printer output
$FACA: C3 7F FB JP FB7F ; PUNCH — punch output (legacy paper-tape)
$FACD: C3 91 FB JP FB91 ; READER — reader input (legacy paper-tape)
$FAD0: C3 6C FE JP FE6C ; HOME — return disk head to track 0
$FAD3: C3 8E FE JP FE8E ; SELDSK — select disk drive
$FAD6: C3 77 FE JP FE77 ; SETTRK — set track for next read/write
$FAD9: C3 F4 FB JP FBF4 ; SETSEC — set sector for next read/write
$FADC: C3 F9 FB JP FBF9 ; SETDMA — set DMA address (where read/write data goes)
$FADF: C3 BD FE JP FEBD ; READ — read sector
$FAE2: C3 C0 FE JP FEC0 ; WRITE — write sector
Fifteen entries — CP/M 2.0 style, no LISTST or SECTRAN. Microsoft’s SoftCard build dates to 1982, after CP/M 2.2 had standardized 17 entries, but the SoftCard BIOS skipped the additions. Either Microsoft never updated the codebase, or they decided the two extra entries weren’t worth the BIOS bytes. (Following the jump table at $FAE5-$FAEA are six bytes that look like inline XOR A; RET and LD H,B; LD L,C; RET — the standard “always ready” / “no translation” stubs that LISTST and SECTRAN would normally point to. So the implementations are there, just not exposed via the jump table.)
The targets cluster in two regions: the console routines ($FB10-$FB91) form one block, the disk routines ($FE6C-$FEC0) form another. WBOOT pointing back to BOOT is a common convention — both routines do the same thing, just at different points in the boot flow. CONOUT at $FB4D is the routine that ends up writing characters to the screen, and as flagged in Part 1, this is where the Pascal-1.1 driver path lives.
A Per-Device Dispatch Table Hiding in Plain Sight
Disassembling the bytes immediately after the jump table, expecting to find the CONST and CONIN routines, produces nonsense. The bytes at $FB10 (where CONST is supposed to live) are seven NOPs. The bytes at $FB1A (CONIN) are a RST $38 followed by eight NOPs. Neither is a credible BIOS-routine prologue.
Looking at the raw bytes shows a structured pattern:
$FB0A: 00 00 00 00 00 00 00 E4 FE 73 FA AC FF 64 FF 00 entry 1
$FB1A: 00 00 00 00 00 00 00 E4 FE 73 FA B8 FF 76 FF 00 entry 2
$FB2A: 00 00 00 00 00 00 00 E4 FE 73 FA C4 FF 88 FF 00 entry 3
$FB3A: 00 00 00 00 00 00 00 E4 FE 73 FA D0 FF 9A FF entry 4
Four entries, 16 bytes each. Each entry has 8 bytes of zeros (probably per-device state buffer) followed by 8 bytes that decode as four 16-bit Z-80 addresses. Two of those addresses are constant across entries ($FEE4 and $FA73 — common dispatch targets); two vary in regular $0C and $12-byte strides ($FFAC → $FFB8 → $FFC4 → $FFD0, and $FF64 → $FF76 → $FF88 → $FF9A).
This is the per-device dispatch table — what the slot scanner from Part 1 builds at Apple $03B9-$03BF (one byte per slot 1-7) ultimately consumes. Each entry holds a (input, output) routine pair for one device class, and the spacing of the targets says each input routine is 12 bytes long and each output routine is 18 bytes long.
Four entries, fitting device codes 1 through 4 from 2.20’s signature scanner. CP/M 2.23 added device code 6 for Pascal-1.1 cards — which means there’s at least one more entry somewhere, presumably extending the table. Whether 2.23’s table is a 5-entry version of this same structure (with code 6 mapping to entry 5) or something different is one of the things the next round of investigation will pin down.
What Got Extracted, What Didn’t
The 2.23 BIOS image extracted from the disk by sector-aligned scanning is partially contiguous. Some routines are at their nominal addresses and disassemble cleanly — CONOUT at $FB4D is the clearest example. Others — CONST at $FB10, CONIN at $FB1A, BOOT at $FED1, WBOOT pointing back to BOOT — are at addresses that, in the extract, contain the per-device dispatch table or other data, not executable code.
The reason is the SoftCard’s loader doesn’t lay all the BIOS bytes contiguously into Z-80 memory directly from the on-disk image. The 6502 boot loader stages a substantial chunk of bytes from the disk’s system tracks into the language card RAM area at Apple $A300-$BFFF (which, post-XOR, is partly Z-80 $A300-$BFFF, partly other addresses). Then Z-80 code that runs after the SoftCard switch (specifically, code planted in Apple $0200-$03FF = Z-80 $1200-$13FF) does a final move into the BIOS’s runtime location at $FAB8-$FFFF. So the BIOS ISN’T at the offset where it appears to be in the disk image — only the parts that happen to live in a single sector that gets directly copied are where they appear. The rest gets assembled by Z-80 code from non-contiguous sources.
This is solvable but blocked on a Z-80 emulator (boot the system, dump memory) or on a careful reverse-engineering of the loader’s LOAD_CPM routine at Apple $0C00. Either path gets us the complete BIOS for both versions; both are ahead of us. For the 2.23 fragments that are directly extractable, we have CONOUT confirmed as expansion-ROM-aware (it loads HL with $C800, the shared expansion-ROM window — see the corrected devlog for why that isn’t the same as Videx-aware), the dispatch table structure, and several BIOS-internal routines whose intent isn’t obvious in isolation.
What’s Coming Next
Part 4 covers the handoff itself — what the 6502 leaves in memory in its final breath, and what the Z-80 finds when it wakes. Part 5 is the surprise that reframed the project: about half the BIOS isn’t on the disk at all. Several articles after that excavate why, including the Pascal-1.1 driver path Part 1 left open as a question.
For this article, the takeaway is the architecture: the SoftCard XORs bit 12 of the Z-80 address with 1, so Apple $1000 is Z-80 $0000. CP/M is layered as CCP/BDOS/BIOS, and only the BIOS varies between Microsoft builds. The 2.23 BIOS uses the older 15-entry CP/M 2.0 jump-table convention. There’s a per-device dispatch table at $FB0A+ (refined to $FAEB+ later — see Part 5) that the slot-scanner-built device-code table feeds into. And a meaningful fraction of the BIOS isn’t directly extractable from the disk image — it gets assembled at runtime by code that runs after the SoftCard switch.
Deep dives
- Z-80 disassembler online; CONOUT loads HL with the Videx VRAM window — building the Z-80 disassembler tool and the first read of CONOUT showing
LD HL,$CC00, which initially looked like Videx-specific code. - $C800 isn’t the Videx — it’s the shared expansion-ROM window — correcting the above. Apple II’s
$C800-$CFFFis the shared expansion-ROM window that any slot card can use; it’s not Videx-specific.
The annotated 6502 boot loader is in docs/CPM_BootLoader.md and docs/CPM223_BootLoader.asm in the Orchard repository. The Z-80 disassembler is at nibbler/z80.py and is also documented as a standalone tool.