cpm-videx-series — Part 2
From the Disk II ROM to the Z-80's First Instruction
Beginning-to-end walk of the 6502 boot stage of SoftCard CP/M 2.23: boot stub, language card switch, slot scan, Z-80 reset vector planting. Three kilobytes of 6502 code between disk click and Z-80 takeover, plus the surprise that the loader contains no SoftCard CPU-switch write.
Part 2 of the cpm-videx series. Part 1 — Why Microsoft CP/M Didn’t Recognize an 80-Column Card — identified what 2.23 changed in slot detection. This part walks the entire 6502 boot stage from the moment the Disk II PROM reads sector 0 to the moment the Z-80 takes over.
The Microsoft Z-80 SoftCard does something most peripheral cards never do: it takes over the entire computer. When CP/M is running, the Apple ][+‘s 6502 isn’t doing the work. The Z-80 on the SoftCard is. The 6502 is dormant, waiting, irrelevant — until CP/M wants to read a sector from the floppy disk, at which point the Z-80 hands control back to the 6502 long enough to run the disk routines, then takes over again.
That handoff is interesting in both directions. The interesting first direction is: how does the Apple ][, which knows nothing about the SoftCard, manage to load a Z-80 operating system into memory, position the Z-80’s reset vector, and then transfer control to a CPU it doesn’t share an instruction set with? The answer is what this article is about.
Everything below happens in a few hundred milliseconds. Somewhere between the click of the disk drive head positioning at track 0 and the moment the Z-80 fetches its first instruction, three kilobytes of 6502 code execute. This is a tour of those three kilobytes.
What the Apple ][ Knows On Its Own
The Apple ][+ has 12 KB of read-only memory: 4 KB of monitor / autostart ROM in $F800-$FFFF, and 8 KB of Applesoft BASIC in $D000-$F7FF. Neither knows anything about CP/M. Neither knows what a Z-80 SoftCard is. The Disk II controller card has another 256 bytes of ROM (the P6 PROM) at the slot’s $Cn00-$CnFF window — and that ROM does exactly one thing: read the very first sector of the very first track of whatever disk is in the drive, drop it into RAM at $0800-$08FF, and JMP $0801.
From there, what happens is up to the disk.
For an Apple-only DOS 3.3 disk, what’s at $0801 is the start of the DOS bootstrap. For a ProDOS disk, it’s the SOS-style block-device boot. For a CP/M SoftCard disk — which is what we have — it’s a dense little 60-byte loop that knows how to use the P6 PROM as a sector-reading service and use it to load 10 more sectors of track 0 before transferring control upward to a stage-2 loader.
The Disk II PROM’s job ends at JMP $0801. From that instant, the 256 bytes at $0800-$08FF are running the show.
The Boot Stub: Sixty Bytes That Load Ten Sectors
Here’s what’s at $0801:
$0801: LDA $27 ; $27 = page count P6 has loaded (= $09 first time)
$0803: CMP #$09
$0805: BNE +21 ; subsequent calls take this branch (skip init)
$0807: TXA ; X = slot * 16 (P6 convention)
$0808: LSR x4 ; A = slot number 0-7
$080C: ORA #$C0 ; A = $C6 for slot 6
$080E: STA $3F ; $3F = $C6
$0810: LDA #$5C / STA $3E ; ($3E/$3F) = $C65C
$0814: LDA #$00 / STA $00 ; sector-table index = 0
$0818: INC $27 ; $27 = $0A (next sector → $0A00)
;
$081A: INC $00 ; ++index
$081C: LDY $00
$081E: CPY #$0B ; loaded all 10 yet?
$0820: BNE +3
$0822: JMP $1000 ; YES — transfer to stage-2 entry
$0825: LDA $082D,Y ; A = physical sector from skew table
$0828: STA $3D ; tell P6 which physical sector
$082A: JMP ($003E) ; JMP $C65C — P6's "search for field prolog"
; entry; reads one sector, advances $27,
; then re-enters here at $0801
Followed by the CP/M sector skew table: 00 02 04 06 08 0A 0C 0E 01 03 05 07 09 0B 0D 0F.
The clever bit is the use of JMP ($003E) to re-enter the P6 PROM at its mid-routine “search for field prolog” entry point at $Cn5C. The P6 PROM was already designed with a sector-counter mechanism (the byte at $0800 says how many pages to load, and $27 tracks how many have been loaded so far). The boot stub piggy-backs on that machinery: every time it calls $Cn5C, the P6 PROM reads one sector identified by $3D, advances $27, and JMP $0801 back into the boot stub. The boot stub then picks the next sector from its skew table and calls $Cn5C again.
After 10 iterations, sectors 2, 4, 6, 8, A, C, E, 1, 3, 5 have been loaded into memory at $0A00, $0B00, $0C00, $0D00, $0E00, $0F00, $1000, $1100, $1200, $1300. The stub then JMP $1000, transferring control to the stage-two loader.
The skew is significant: it’s CP/M’s preferred sector ordering, not DOS 3.3’s. By loading sectors in even-then-odd order, the loader minimizes rotational delay between the back-to-back reads — at the moment the disk completes one sector, the next sector to read is already two sectors past the head, giving the 6502 just enough time to process the previous read while the next one comes around. This is the same trick CP/M itself uses for file I/O once the system is running. The skew is baked into the boot stub because CP/M’s logical view of the disk is going to assume this skew from the very start.
Stage-Two Entry: Bring Up the Language Card
$1000 is the start of the loader proper — the code that will do everything between “ten sectors of track 0 loaded” and “Z-80, take it from here.” About 3 KB of 6502 code at $0800-$13FF, of which roughly 1.5 KB is reusable disk I/O code ($0A00-$0FFF) and the rest is the boot orchestration.
The first two instructions are surprising the first time you see them:
$1000: LDA $C081
$1003: LDA $C081
That’s the language card soft switch. The Apple ][+ shipped with 48 KB of RAM. The “language card” was a popular peripheral card that added 16 KB of additional RAM, banked into the same address space as the Apple’s monitor ROM ($D000-$FFFF). When the language card was active, you could read RAM where the ROM normally lived, and write to that RAM regardless. The ROM was still there underneath, accessible by flipping a soft switch.
CP/M needs RAM at the high addresses where the Apple’s monitor ROM normally lives — the entire Z-80 transient program area extends from $0100 to roughly $F800, and the Z-80 BIOS sits above that, all in addresses where the Apple’s ROMs would otherwise be. So the very first thing the loader does is enable the language card RAM. The double-LDA sequence is the standard pattern: the first access primes the soft switch, the second commits it.
The next several instructions wrap up Apple-ROM-side housekeeping:
$1006: TXA / LSR x4 / TAY ; A = slot number
$100C: PHA ; save slot for later
$100D: STA $C088,X ; turn the disk drive motor off
$1010: LDA #$00
$1012: STA $0478,Y ; clear per-slot scratch byte
$1015: STA $04F8,Y
$1018: JSR $FB2F ; Apple monitor: TEXT mode
$101B: JSR $FE93 ; Apple monitor: SETVID
$101E: JSR $FE89 ; Apple monitor: SETKBD
$1021: PLA ; restore A
$1022: LDX #$FF / TXS ; reset stack
$1025: CMP #$06 ; boot signal == $06?
$1027: BEQ +16 ; yes, proceed
$1029: ; (failure path: print "MUST BOOT FROM SLOT SIX" and drop to monitor)
The motor turns off because we’re going to be doing more disk reads later, and we want the drive to spin down between boot stages. The Apple monitor calls put the Apple’s display into a known state (text mode, screen output, keyboard input). The check for A == $06 is a sanity test: the boot stub passes the boot-success signal through, and $06 indicates a clean load. Anything else and the loader prints MUST BOOT FROM SLOT SIX (the familiar message anyone who’s tried to boot SoftCard CP/M from the wrong slot has seen) and drops into the Apple monitor.
The Install Loops: Staging Z-80 Code Into Apple Memory
This is where the loader starts doing something genuinely surprising. Three back-to-back copy loops:
$1039: LDY #$0E
$103B: LDA $11B0,Y / STA $0FFF,Y / DEY / BNE -7 ; copy 14 bytes
$1044: LDA $1200,Y / STA $0200,Y / DEY / BNE -7 ; copy 256 bytes
; from $1200 → $0200
$104D: LDY #$F1
$104F: LDA $12FF,Y / STA $02FF,Y / DEY / BNE -7 ; copy 241 bytes
; from $1300 → $0300
The destinations $0200-$03FF are interesting because they’re in the Apple’s “low memory” range, far from where the loader itself lives. Why install bytes there?
Because the SoftCard re-maps the Apple’s memory. The Microsoft Z-80 SoftCard’s address translator XORs bit 12 of the Z-80 address with the Apple address. Concretely:
| Apple ][ address | Z-80 address |
|---|---|
$0000-$0FFF | $1000-$1FFF |
$1000-$1FFF | $0000-$0FFF |
$2000-$FFFF | $2000-$FFFF (no swap) |
So the bytes the loader is installing at Apple $0200-$03FF will be visible to the Z-80 at Z-80 $1200-$13FF. The 6502 is staging Z-80 code into Apple memory before the SoftCard switch, putting it where the Z-80 will be able to find it once the Z-80 wakes up.
The $11B0 source for the smaller 14-byte copy stages a related fragment elsewhere — six of those 14 bytes are the Apple monitor reset vectors that the loader patches at $FFFA-$FFFF later. The full set of installed bytes amounts to about 480 bytes of Z-80 code plus some data.
The Slot Scanner: Where 2.20 and 2.23 Differ
The loader then walks slots 7 → 1, page by page, and probes each slot’s expansion ROM for known firmware signatures. The full mechanism is the subject of Part 1 of this series — for the narrative arc here it’s enough to know:
- The scanner uses an 8-byte signature table at
$11BEto recognize four classes of card: the Apple Disk II controller ($Cn05=$03, $Cn07=$3C— verified against the actual P6 PROM bytes), some Microsoft-specific cards, and the standard Apple Pascal 1.0 firmware ID ($38, $18). - Each slot gets a one-byte “device code” recorded at
$03B9-$03BF(one byte per slot 1-7). - 2.23 inserts an 11-byte branch that additionally reads
$Cn0Bafter a Pascal 1.0 ID match; if the byte is$01(the Pascal 1.1 signature byte), the slot gets the new device code$06instead of the generic Pascal-1.0 device code$04. That is the entire detection delta between the version that hangs on a Videx and the version that works.
Once the scan completes, the device-code table is filled in for every slot 1-7. The loader has built itself a map of “what’s in each slot.”
Plant the Z-80’s Reset Vector
This is the cleanest find from working through the loader. After the slot scan finishes and the loader has copied a final 16-byte vector block into $03EF, it does this:
$10FA: LDA #$C3 / STA $1000 ; Apple $1000 = Z-80 $0000 (reset vector)
$10FF: LDA #$00 / STA $1001 ; low byte of JP target
$1104: LDA #$FA / STA $1002 ; high byte of JP target
Those bytes self-modify Apple ][ RAM at $1000-$1002 — the same address as the loader’s stage-2 entry point. After modification: $C3 $00 $FA. As 6502 instructions: nonsense ($C3 is an undocumented opcode). As Z-80 instructions: JP $FA00.
Apple $1000 is Z-80 $0000 under the SoftCard XOR. Z-80 $0000 is where a Z-80 fetches its first instruction after reset. JP $FA00 jumps directly into the 2.23 CP/M BIOS cold-boot entry (the BIOS sits at $FAB8; $FA00 is right next to its jump table). When the SoftCard switch flips and the Z-80 starts up, it reads its $0000, sees JP $FA00, and jumps directly into CP/M.
This is the SoftCard handoff bridge: the 6502 finishes its work, plants the Z-80 reset vector with the address of the CP/M BIOS, and only then flips the switch.
Cross-checked against 2.20: at the equivalent address in 2.20’s loader, 2.20 plants $C3 $00 $DA = Z-80 JP $DA00. Exactly consistent with its BIOS load address at $DACC. Same pattern, different jump target — this is independent confirmation, surfaced from the 6502 side, of the $2000 BIOS-base shift the BIOS jump-table scan turned up earlier in the investigation.
Stage Disk Routines for the Z-80 to Use
The Z-80 will need to read from disk eventually — CP/M’s BDOS requires it for file I/O. But the Z-80 doesn’t have its own disk routines, and the disk I/O code at Apple $0A00-$0FFF is 6502 code, not Z-80. How do the two CPUs cooperate?
Looking at what the loader does next gives a hint:
$1109: ; copy disk I/O routines from Apple $0A00-$0FFF up to $BA00-$BFFF
$1126: ; copy a different block from $9700-$9CFF down to $0A00-$0FFF
$1133: ; copy a $1700-byte block from $8000-$96FF to $A300-$B9FF
The first copy preserves the original 6502 disk I/O routines in language card RAM. The second replaces them with different code at the original $0A00 address. The third stages a substantial chunk (probably the CP/M system image — CCP+BDOS+BIOS combined are roughly the right size) into the language card area.
The cooperation pattern this implies: when CP/M needs to do disk I/O, the Z-80 toggles the SoftCard switch back to the 6502, the 6502 runs the disk routines (the original ones, now at $BA00, or a stub at $0A00 that calls into them), and then toggles back. Each side of the switch knows where to find the other’s code.
The substantial 6 KB of stuff being moved around in this phase is what sets up the entire memory layout the Z-80 will see when it wakes up. By the time the loader is done, the Z-80’s view of memory is fully populated.
The Handoff (And What’s Still Open)
The loader’s final actions are to patch the Apple monitor’s reset vectors at $FFFA-$FFFF and jump into the warm-boot routine that was installed at $03C0:
$1140: ; copy 6 bytes to $FFFA-$FFFF (NMI, RESET, IRQ vectors)
$114B: JMP $03D2
The warm-boot routine at $03C0 cycles: enable LC RAM, touch a self-modified address that resolves to $Cn00 (touching the slot ROM page-select for the last-scanned slot), disable LC RAM, call into the disk routines, save 6502 state, and loop back to the top. It’s a tight loop.
Here’s the surprising thing — and the genuine open question this leaves: the loader contains no STA $C0Bx instructions. The Microsoft Z-80 SoftCard’s CPU-toggle soft switch is at $C080 + slot * 16 of whichever slot the SoftCard occupies (typically slot 4). I expected to find a STA $C0Cx (for slot 4) or similar somewhere in the loader as the actual “flip the CPU now” instruction. There isn’t one.
So either:
- The CPU switch is triggered by a side-effect of reading something — perhaps an access to the SoftCard’s slot ROM area at
$C400-$C4FF(which the slot scanner did when it walked slot 4) sets a hardware flag that the nextSEIhonors - The switch is in code I haven’t fully disassembled yet — most likely inside the disk-routine block at
$0A00-$0FFF, which the warm-boot calls into viaJSR $0E36 - The Z-80 is always running in parallel and just waits for a memory pattern that signals “your turn” — possible but unusual for the SoftCard hardware
This is honest — I don’t know yet. The loader gets right up to the edge of the handoff and then loops in a way that should never produce the switch directly. The trigger is somewhere in the warm-boot routine at Apple $03C0 or in a side-effect of the LC RAM bank-switch sequence; identifying the exact instruction is open. Part 4 returns to the handoff with more material but doesn’t close this question.
What We’ve Covered
About 1.5 KB of 6502 code — the orchestration code in $1000-$13FF, plus the 60-byte boot stub at $0801-$083C. The other 1.5 KB is RWTS-style disk I/O at $0A00-$0FFF: WRITE_SECTOR, READ_SECTOR, SEEK_TRACK, LOAD_CPM, plus the 6-and-2 GCR encode/decode tables. Standard Apple Disk II machinery, used here to load whatever the boot stub didn’t (the CP/M system image from disk system tracks).
The fully-annotated 6502 disassembly is in docs/CPM223_BootLoader.asm in the Orchard repository; the architectural narrative companion is docs/CPM_BootLoader.md. Both are versioned alongside the disk images and extraction scripts.
What’s coming next: Part 3 covers what the Z-80 sees when it wakes — the SoftCard’s memory model, the bit-12 XOR for low addresses, and how CP/M’s CCP/BDOS/BIOS layering maps onto Apple memory. Then Part 4 revisits the handoff itself with much more material than this article had. After that, the Z-80 is alive and fetching its first instruction; the rest of the series traces what it finds, what it does with what it finds, and why a Videx in slot 3 makes 2.20 hang and 2.23 boot.
Deep dives
- The 6502 plants the Z-80’s reset vector — the moment the 6502 writes
JP $FA00into Z-80-visible memory, located in the boot-loader disassembly.