cpm-videx-series — Part 6

The BIOS Factory

Microsoft SoftCard CP/M 2.23 doesn't ship a fixed BIOS. It ships a BIOS factory — code that builds the runtime BIOS from a slot-info table at boot. The factory is what got rewritten between 2.20 and 2.23, and seeing it explains why fixing Videx took more than 11 bytes.

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

Part 6 of the cpm-videx series. Part 1 found the 11-byte slot-scanner delta on the 6502 side. Part 5 showed that half the runtime BIOS doesn’t exist on disk. This part finds the code that builds it — and the device-code-6 branch that’s the Z-80-side half of the Videx fix.


A puzzle that the 11-byte fix doesn’t explain

Part 1 ended with a clean answer: 2.23 boots a Videx because it added an 11-byte branch on the 6502 side that detects Pascal 1.1 firmware and tags the slot with a new device code ($06) instead of the Pascal-1.0 default ($04). 2.20 doesn’t do this branch, sees the Videx as a Pascal 1.0 card, and dispatches naive Pascal 1.0 calls that hang.

But this story has a hole. Even if 2.20’s slot scanner had tagged the Videx as device code $06, what would the BIOS do with that information? $06 is a value 2.20’s code never expected. Some part of the BIOS — the part that consumes device codes and decides how to talk to the slot — would have to have a case for $06. If that case doesn’t exist, the slot-scanner fix is meaningless on its own.

So somewhere in 2.23 there has to be a consumer of the device code that knows about $06. And 2.20 has to lack that consumer (or have it but not handle $06). That’s the second half of the fix.

This article finds it.

What 2.20 ships in BIOS — that 2.23 doesn’t

The 2.20 BIOS is 2 KB at Z-80 $DACC-$E2CB. Lay out the bytes by 256-byte page and a clean structural pattern emerges:

PageRangeContents
0$DACC-$DBCBcode: jump table, console routines
1$DBCC-$DCCB$E5 filler
2$DCCC-$DDCBcode: SETDMA, READ, WRITE, helpers
3$DDCC-$DECB$E5 filler
4$DECC-$DFCBcode: cold-boot device-scan
5$DFCC-$E0CB$E5 filler
6$E0CC-$E1CBcode: per-device handler routines
7$E1CC-$E2CB$E5 filler

That’s a 4+4 interleave of code pages and $E5-filled pages. ($E5 is CP/M’s “deleted file” marker; CP/M used it as a filesystem placeholder for unused space. In Z-80 instruction encoding it happens to be PUSH HL. Microsoft used it as harmless filler for unused BIOS regions.)

The code in page 6 ($E0CC-$E1CB) is what makes 2.20 a complete BIOS — it contains routines like:

$E0D0: CALL $DCEA          ; per-device output sequence
$E0D3: LD HL,$F678
$E0D6: ADD HL,DE
$E0D7: LD (HL),C
$E0D8: LD HL,$C9AA          ; expansion-ROM call target
$E0DB: JP $DB3B               ; into CONOUT-style routine
...
$E0E8: CALL $DCEE
$E0EB: LD HL,$C84D            ; another expansion-ROM target
$E0EE: CALL $DB3B
...
$E12C: LD HL,$E08E
$E12F: LD A,E
$E130: ADD A,A; ADD A,A      ; multiply by 4 four times = ×16
$E131: ADD A,A
$E132: ADD A,A
$E133: ADD A,A
$E134: PUSH AF
$E135: ADD A,L                ; HL = base + slot×16
$E136: LD L,A
...

These are the per-device input/output handlers — code that reads expansion-ROM addresses, computes per-slot offsets, and routes I/O to the right place. The handlers are baked into the BIOS at compile time. Whatever device codes 2.20 supports, the handler code lives statically here in page 6.

2.23’s BIOS has no equivalent page. The whole BIOS is shorter — about 1.35 KB at $FAB8-$FFFF, just three code pages alternating with two-and-a-half “filler” pages:

PageRangeContents
0$FAB8-$FBB7code: jump table, dispatch table, generator
1$FBB8-$FCB7FF FF 00 00 / F7 F7 00 00 markers
2$FCB8-$FDB7code: per-device init helpers
3$FDB8-$FEB7trap markers
4$FEB8-$FFB7code: device-scan loop
5$FFB8-$FFFFtrap markers (partial)

Three differences jump out:

  1. No static handler page. 2.20’s page 6 is gone; whatever fills its role must be different.
  2. Filler pages aren’t $E5. They’re FF FF 00 00 / F7 F7 00 00 — Z-80 instruction encodings that are RST $38 and RST $30 traps. Premature execution lands in a defined trap rather than running random PUSH HL instructions all over the stack.
  3. The trap-marker pages are targets of CALL and JP from the static code. Real instructions in code page 0 jump into the marker pages. Static disassembly would call those calls “into garbage” — unless those bytes are populated at runtime.

So 2.23’s filler pages aren’t filler. They’re slots awaiting runtime population.

The cold-boot generator

If something populates the trap-marker pages, the question is what and when. Searching code page 0 past the dispatch table reveals a routine at $FB3A:

$FB3A: LD DE,0007         ; counter E = 7
$FB3D: LD HL,F3B8           ; HL = slot-info table base (built by 6502 slot scanner)
$FB40: ADD HL,DE
$FB41: LD A,(HL)             ; A = device code at slot E
$FB42: SUB 03
$FB44: JR NZ,FB4D            ; if not 3, skip
$FB46: CALL FE81              ; init handler for device 3
$FB49: LD (HL),03
$FB4B: LD (HL),15
$FB4D: DEC A
$FB4E: JR NZ,FB5B            ; if not 4, skip
$FB50: CALL FD83              ; init handler for device 4 (Pascal 1.0)
$FB53: LD HL,C800
$FB56: CALL FB45               ; expansion-ROM helper
$FB59: JR FB65
$FB5B: CP 02
$FB5D: JR NZ,FB65            ; if not 6, skip
$FB5F: LD HL,0DD0
$FB62: CALL FDB0              ; ← init handler for device 6 (Pascal 1.1)
$FB65: DEC E
$FB66: JR NZ,FB3D
$FB68: RET

This is the cold-boot generator. It walks the slot-info table at $F3B8+E for E = 7..1 and for each detected device code, calls a per-device init routine. The init routines are in code pages (statically present), and they’re what actually operate on the trap-marker pages — writing real Z-80 code into the slots so that when the BIOS device-scan dispatch later jumps into one of those addresses, there’s a real handler waiting.

The branch decoding works through subtractive comparisons:

  • SUB 03 followed by JR NZ means “if A wasn’t 3, skip; else handle 3 (and A = 0).”
  • DEC A followed by JR NZ means “if A wasn’t 4 (after SUB 03 + DEC A), skip; else handle 4.”
  • CP 02 followed by JR NZ means “if A − 4 ≠ 2, i.e., if A wasn’t 6, skip; else handle 6.”

So 2.23’s generator dispatches device codes 3, 4, and 6.

What 2.20’s generator does

2.20 has the same loop. Same address-table base ($F3B8), same counter, same subtractive-compare pattern — just without the device-6 branch:

$DB6E: LD DE,0007
$DB71: LD HL,F3B8
$DB74: ADD HL,DE
$DB75: LD A,(HL)
$DB76: SUB 03
$DB78: JR NZ,DB81
$DB7A: CALL DD60               ; init device 3
$DB7D: LD (HL),03
$DB7F: LD (HL),15
$DB81: DEC A
$DB82: JR NZ,DB8D
$DB84: CALL DCEE                ; init device 4
$DB87: LD HL,C800
$DB8A: CALL DB3B
$DB8D: DEC E
$DB8E: JR NZ,DB71
$DB90: RET

After the device-4 branch, 2.20 jumps directly to the loop bottom ($DB8D). There’s no third branch. 2.20 has no concept of device code 6. Even if its slot scanner had written $06 into the slot-info table, this generator would scan past it without doing anything.

The full Videx fix, end to end

So the complete delta between 2.20 and 2.23 for Videx support is two coordinated changes:

SideWhereWhatBytes
6502 (Apple-side boot loader)Slot scannerRead $Cn0B, set device code $06 if Pascal 1.111
Z-80 (BIOS cold-boot generator)$FB3A loopAdd CP 02 / JR NZ / LD HL,$0DD0 / CALL $FDB0 device-6 branch~10

About 21 bytes of code, in two places, on two CPUs, for a fix that lets a 1980 OS recognize a card from 1981. The 11 bytes of detection are useless without the 10 bytes of dispatch — and the 10 bytes of dispatch have nowhere to dispatch to in 2.20 (which doesn’t have a runtime-generation architecture for handlers). So the Videx fix isn’t really 21 bytes plus context. It’s 21 bytes assuming you’ve already adopted a different architecture — the BIOS factory.

What we don’t yet know

Honesty time. The cold-boot generator at $FB3A is real. The device-6 branch is real. But the routine it calls — $FDB0 — is one byte:

$FDB0: C9    RET

It returns immediately. So the actual Pascal 1.1 driver-install path doesn’t live in $FDB0 itself. Possibilities:

  • The driver bytes get installed by an earlier mechanism (the 6502 loader, or a SoftCard hardware overlay).
  • The driver is installed lazily on first device use, with $FDB0 serving as a placeholder.
  • 2.23 ships a stub on the assumption that the dispatch tables are good enough for Videx without explicit driver bytes — possible if the device-scan dispatch jumps into trap-marker addresses already populated some other way.

Tracing the actual population of $FBC4, $FC9A, and similar trap-marker call/jump targets is the next step. The architectural understanding is firm; the precise mechanism by which Pascal 1.1 driver bytes reach memory is the open frontier.

Why this matters

The story Part 1 told — 11 bytes in a slot scanner — is the visible tip of the fix. The story this article tells is the invisible substrate that those 11 bytes depend on: a runtime BIOS-factory architecture that can produce per-device handler code based on what the slot scanner found.

Microsoft didn’t fix Videx by changing 11 bytes. They fixed Videx by first redesigning the BIOS so that adding new device support meant adding a generator branch instead of editing static handler code, then adding the slot-scanner branch and the matching generator branch.

That redesign is what makes 2.23 forward-compatible with hardware that didn’t exist when CP/M was first written for the SoftCard. The BIOS factory ships in the binary; the runtime BIOS is built per-boot. It’s an extensible architecture in a 2 KB box. And it’s why a card from late 1981 works under software stamped 1982.

Part 7 traces the boot path that flows through this generator — from the Z-80’s first instruction through the device-scan dispatch. The static-vs-runtime population mechanism the generator depends on isn’t fully resolved by the end of that article either; it’s the longest-standing open question in the investigation.

Deep dives

The investigation that found the generator. Two dead ends are preserved here — the early “$FB70 is cold-boot” misread and the “2.20 has inline init” diff that turned out to be comparing the wrong routines.