cpm-videx-series — Part 5

The BIOS That Half-Exists

How Microsoft SoftCard CP/M loads itself from disk into Z-80 memory — and the surprise that half the BIOS doesn't exist on disk at all. The other half is generated at runtime by code that runs after the SoftCard switch. A code-generation pattern that explains why 2.20 and 2.23 differ structurally.

13 min read advanced
apple-ii6502z80cpmsoftcardreverse-engineeringoperating-systemsretrocomputingcpm-videx-seriesdeep-dive

Part 5 of the cpm-videx series. Part 4 covered the 6502→Z-80 handoff. This part walks the actual disk-to-Z-80-memory path: where each piece of CP/M ends up, and the discovery that about half the BIOS doesn’t exist on disk at all.


By the end of Part 3, I had the BIOS jump table, the per-device dispatch table, CONOUT, LIST, and a few other routines disassembled cleanly from the 2.23 BIOS. But I also had a wall: BOOT at $FED1, the per-device handlers at $FF64-$FFDF, the cold-boot area $FA00-$FAB7, and several other targets — all looked like garbage data when I tried to disassemble them. About half the BIOS wasn’t recoverable from the disk image.

The hypothesis at the end of Part 3 was that the loader didn’t lay all the BIOS bytes contiguously, so the file-offset extraction was missing pieces that lived at non-adjacent sectors. That hypothesis turned out to be partially right — but the actual answer is more interesting, and more revealing about how SoftCard CP/M is structured.

Following the Disk-to-Memory Path

To find the missing BIOS, I needed to trace exactly what the 6502 boot loader does when it loads CP/M from disk. The boot stub from Part 2 only loaded the loader itself (10 sectors of track 0); the rest of CP/M comes from somewhere later in the boot.

Working backward from the warm-boot routine the loader installs at Apple $03C0, the chain leads to a routine I’d previously left as “the LOAD_CPM-equivalent.” It lives in the disk-routines area at Apple $0BEB. The 6502 jumps to it via JSR $BBEB after a PREP_HANDOFF step copies the disk routines from $0A00-$0FFF up to $BA00-$BFFF — so $0BEB becomes $BBEB after the relocation.

What LOAD_CPM does is straightforward enough once you read it:

$0BEB: STA $03E9            ; $03E9 = $80 (destination high byte)
$0BEE: ; ...zero state, set track=0, sector=$0B, slot=6, count=29
$0C09: PHA                  ; push counter
$0C0C: JSR $BE11             ; call the actual sector-read routine
$0C0F: BCC $0C19             ; success → continue
$0C11: ; error path
$0C19: PLP / INC $03E9       ; advance dest page
$0C1A: ; sector++, wrap track if needed
$0C2D: PLA / SEC / SBC #$01  ; counter--
$0C31: BNE $0C09             ; loop while counter != 0
$0C33: LDA #$08 / STA $03E9  ; reset $03E9 = $08
$0C38: RTS

29 sectors, starting at track 0 sector $0B (the first sector the boot stub didn’t load), continuing sequentially through track 0’s remaining sectors, all of track 1, and the first 8 sectors of track 2. Each sector lands at $8000, $8100, $8200, …, $9CFF. Then LOAD_CPM returns.

The 6502 isn’t done after that. The loader’s PREP_HANDOFF code does two more things with the staging area:

First copy:  $9700-$9CFF  (last 6 pages, 1.5 KB)  →  Apple $0A00-$0FFF
Second copy: $8000-$96FF  (first 23 pages, 5.9 KB) →  Apple $A300-$B9FF

These are the staging splits. The first copy lands in the area where the original 6502 disk routines used to live; the second copy stages a substantial chunk into the language-card-area RAM at $A300-$B9FF.

What’s in the System Image

Wrote a Python script (reconstruct_staging.py) that does the equivalent: reads the same 29 sectors from CPMV233.DSK directly, applies the splits, and produces three binary files. The largest of them — sysimg_223.bin, 5888 bytes — is the content that ends up at $A300-$B9FF.

It’s real Z-80 code. The first instruction at $A300 is JP $9631, followed immediately by what’s clearly a CP/M command-line parser — checking for space, =, _, ., :, ;, <, >, returning early on each. Classic CCP. And then, about 5.5 KB later, at the very tail of the image, a string of plain ASCII bytes:

Softcard CP/M
     60K Ver. 2.23
(c) 1980,1982 Microsoft

That’s the boot banner. The line that appears the instant CP/M reaches the A> prompt. So sysimg_223.bin is the CCP + BDOS + banner string combined — about 5.9 KB of platform-independent CP/M with the version stamp at the end.

The other piece — newdisk_223.bin, 1.5 KB — is the bytes that land at Apple $0A00-$0FFF. As 6502 code it’s nonsense (lots of KIL opcodes). As Z-80 it’s coherent. The 6502 stages Z-80 code at low Apple memory for the Z-80 to find post-handoff. After the SoftCard XOR, Apple $0A00 is Z-80 $1A00, and that’s where the Z-80’s disk callbacks live — the bridge between BIOS-level disk requests and the actual disk I/O.

So far, all consistent with what Part 3 sketched out. CCP + BDOS at one place, Z-80 callbacks at another. But the BIOS at Z-80 $FAB8 was supposed to be loaded too. Was it?

The Exact Match

I’d previously extracted what I called bios_223.bin — 2 KB starting at the BIOS jump table position in the .DSK file. Comparing it byte-for-byte against newdisk_223.bin produced the answer:

newdisk_223.bin[0x200:0x600]  ==  bios_223.bin[0:0x400]

Exact match. 1024 bytes. The first half of the BIOS — $FAB8-$FEB7, including the 15-entry jump table, the inline LISTST/SECTRAN stubs, the per-device dispatch table, CONOUT, LIST, and the helper routines — is sitting at newdisk_223.bin offset $200. Which means after PREP_HANDOFF, those bytes land at Apple $0C00-$0FFF. Some still-untraced step then copies them to LC RAM at the BIOS final position $FAB8-$FEB7. The first half of the BIOS is in the LOAD_CPM load.

That left the second half — $FEB8-$FFFF, where BOOT, the per-device handlers, and several dispatch-target callees ought to live. Where’s that?

Where the Second Half Comes From

The bytes immediately after the BIOS first 1 KB in the staging come from physical sectors trk2:$08-$0B. Extracted those four sectors directly from CPMV233.DSK:

$FEB8: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
$FEC8: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
$FED8: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
$FEE8: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
... (1024 bytes, all zeros)

The sectors that should contain the BIOS second half are entirely zero on disk.

It’s not a damaged disk image. The cold-boot area too — $FA00-$FAB7, where the Z-80’s planted reset vector points (JP $FA00) — is also zero/data on disk. The sectors are deliberately blank.

Which means the BIOS routines for BOOT, HOME, SELDSK, SETTRK, READ, WRITE, the per-device input handlers at $FF64-$FF9F, the per-device output handlers at $FFAC-$FFDF, and the cold-boot routine at $FA00 — none of them exist on the disk. They have to come from somewhere else.

The only thing left is runtime generation. Some Z-80 code that runs after the SoftCard switch fires must write the actual handler bytes into those addresses. SoftCard CP/M does code generation at boot time.

Why That’s a Big Deal

The cleanest implication is that this explains the structural difference between 2.20 and 2.23 that I found earlier in the investigation. Disassembling the static .DSK images side-by-side made the two versions look almost completely different, byte-for-byte — far more different than a “small Videx fix” would suggest. The reason: the .DSK doesn’t contain CP/M’s runtime BIOS. It contains CP/M’s BIOS factory — the code-generator. The factory is what differs between versions, and the difference in generator naturally produces wholly-different output BIOSes.

Which makes the 2.23-vs-2.20 question much more concrete. The 11-byte detection delta in the boot loader (Part 1) gives the trigger: 2.23 sees the Pascal 1.1 byte and tags the slot with device code $06 instead of $04. The runtime BIOS generator then produces different code for slots tagged $06 than for slots tagged $04. The Videx works under 2.23 not because the BIOS knows about the Videx, but because the BIOS generator knows how to produce a Pascal-1.1-aware driver from the device-code table.

A second implication: most of the work to fully disassemble the BIOS is now bounded. The cold-boot generator lives in the populated 1 KB at $FAB8-$FEB7 (which we have, cleanly disassemblable). Reading it forward will reveal the algorithm that produces the second half — which is the answer to the deeper “what does 2.23 actually do for a Videx” question.

How the Other Half Helps

A side observation: the Z-80 disk-callback code at Apple $0A00 (= Z-80 $1A00) doesn’t just call into the BIOS. It also writes into the BIOS second half. Searching for instructions that target the $FE/$FF area:

$1EAA: LD HL,$FECB
$1EAD: LD (HL),A
$1EBA: LD HL,$FED4
$1EF5: LD ($FED2),A

These are stores to specific bytes within $FEB8-$FFFF. Not code-generation patterns; not block copies. Individual byte stores to specific addresses. The cleanest interpretation: the second half of the BIOS is both code and state. The cold-boot generator populates the executable parts (the per-device handler routines), and the Z-80 callbacks populate the state parts (current track, current sector, current DMA address — the standard CP/M BIOS-local variables that SETTRK/SETSEC/SETDMA write and that READ/WRITE read).

This is consistent with how CP/M BIOSes are normally organized. The BDOS calls SETTRK with a track number; the BIOS implementation just stores it locally; later, READ reads the stored track and uses it to compute which sector to fetch. SoftCard CP/M does exactly this, with the local storage interleaved into the same memory region as the generated handler code.

So $FEB8-$FFFF at runtime is a small per-device control block — code interleaved with mutable state. Both halves of that interleaving are populated post-handoff: the code by the cold-boot generator, the state by the running BDOS-facing callbacks.

The Cooperative-CPU Picture

With the BIOS materialization understood (mostly), the cooperative-CPU disk model can be drawn end-to-end:

  1. Application program calls BDOS. Wants to read a sector.
  2. BDOS calls the BIOS jump table at $FAB8, eventually landing at READ’s entry ($FEBD per the jump table).
  3. The runtime-generated READ routine reads the BIOS-local state (track at $FECB, sector at $FED2, DMA at $FED4), formats them into a request, signals the 6502 via the $E000/$E010 flag pair the inter-CPU sync at $1E36-$1E44 polls.
  4. SoftCard switch fires. Z-80 halts; 6502 wakes up.
  5. The 6502 reads the same BIOS-local state (Apple sees $FECB/$FED2/$FED4 at the same physical address), uses the original 6502 RWTS routines preserved at Apple $BA00-$BFFF, fetches the requested sector, deposits it at the DMA address.
  6. 6502 flips the SoftCard back. Z-80 wakes up. The poll on $E000 succeeds (bit 7 set).
  7. Z-80 returns from the disk callback to whoever called READ. BDOS hands the data back to the application.

The two CPUs share the BIOS second half as their communication channel. They never run simultaneously — the SoftCard hardware enforces that. They communicate by writing to memory addresses that are physically identical (above $2000 the SoftCard XOR is a no-op).

What’s Still Open

The exact mechanism that fires the SoftCard CPU switch is still elusive. The 6502 loader has no $C0Bx write anywhere; the Z-80 callbacks have no IN/OUT instruction we can decode. The switch happens via either a memory-access pattern the SoftCard hardware monitors silently, or via instructions encoded in the CB/DD/ED/FD prefix tables that my Z-80 disassembler doesn’t yet decode. Resolving that is one of the next concrete pieces.

The cold-boot code generator — the actual algorithm that populates $FEB8-$FFFF and $FA00-$FAB7 — lives in the populated 1 KB at $FAB8-$FEB7. Reading it forward is the next concrete piece. Once it’s understood, two things fall out: (a) the actual difference between what 2.20’s generator produces for device code $04 vs what 2.23’s generator produces for device code $06, and (b) the complete READ/WRITE/HOME/etc. routines that the runtime BIOS will execute. That’s the deep answer to the original question.

Part 6 picks up there.

For this part, what’s settled: the LOAD_CPM mechanism is fully understood. The CCP, BDOS, banner, Z-80 disk callbacks, and the BIOS first 1 KB are all extracted as real, disassemblable Z-80 code. The 2.20 and 2.23 versions are extracted in parallel (with the structural difference that 2.20 lays the BIOS first 1 KB at the start of the Z-80 callback area while 2.23 lays it at the end; same content, different order). And the surprise that explains the 2.20-vs-2.23 byte-level chaos — the BIOS factory model — is in hand.

Deep dives

The investigation behind this article — and the dead ends along the way:


All extraction scripts and intermediate binaries are in Orchard under cpm-investigation/. The Z-80 disassembler used is at nibbler/z80.py and is also documented as a standalone tool.


Update (2026-04-27): The cold-boot generator referenced as “to be located in the populated 1 KB” was found shortly after this article was published — at Z-80 address $FB70, mislabeled as the LIST entry in the BIOS jump table. See the cold-boot finding devlog for what it does, including the surprise that the cold-boot routine rewrites the Z-80’s reset vector from JP $FA00 to JP $FA03 and plants the standard CP/M BDOS call vector at Z-80 $0005. Part 6 covers the generator’s algorithm in detail.

Update (2026-04-28): The “BOOT vector at $FB70 mislabeled as LIST” claim above is itself wrong. $FB70 IS the LIST jump-table entry; the actual BOOT vector points to $FED1. The structural observation that $FB70 contains cold-boot-style code (stack init, BDOS-vector planting) survives — but $FB70 is reached indirectly from $FED1, not directly from BOOT. Also, the “first 1 KB on disk, second 1 KB runtime-generated” framing in this article is too coarse: both BIOSes use a 256-byte interleave (4 code pages, 4 generated pages, alternating). The runtime-generation thesis still holds; only the layout description is wrong. See the BIOS jump-table correction devlog.