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.
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:
| Page | Range | Contents |
|---|---|---|
| 0 | $DACC-$DBCB | code: jump table, console routines |
| 1 | $DBCC-$DCCB | $E5 filler |
| 2 | $DCCC-$DDCB | code: SETDMA, READ, WRITE, helpers |
| 3 | $DDCC-$DECB | $E5 filler |
| 4 | $DECC-$DFCB | code: cold-boot device-scan |
| 5 | $DFCC-$E0CB | $E5 filler |
| 6 | $E0CC-$E1CB | code: 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:
| Page | Range | Contents |
|---|---|---|
| 0 | $FAB8-$FBB7 | code: jump table, dispatch table, generator |
| 1 | $FBB8-$FCB7 | FF FF 00 00 / F7 F7 00 00 markers |
| 2 | $FCB8-$FDB7 | code: per-device init helpers |
| 3 | $FDB8-$FEB7 | trap markers |
| 4 | $FEB8-$FFB7 | code: device-scan loop |
| 5 | $FFB8-$FFFF | trap markers (partial) |
Three differences jump out:
- No static handler page. 2.20’s page 6 is gone; whatever fills its role must be different.
- Filler pages aren’t
$E5. They’reFF FF 00 00 / F7 F7 00 00— Z-80 instruction encodings that areRST $38andRST $30traps. Premature execution lands in a defined trap rather than running randomPUSH HLinstructions all over the stack. - 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 03followed byJR NZmeans “if A wasn’t 3, skip; else handle 3 (andA = 0).”DEC Afollowed byJR NZmeans “if A wasn’t 4 (afterSUB 03 + DEC A), skip; else handle 4.”CP 02followed byJR NZmeans “ifA − 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:
| Side | Where | What | Bytes |
|---|---|---|---|
| 6502 (Apple-side boot loader) | Slot scanner | Read $Cn0B, set device code $06 if Pascal 1.1 | 11 |
| Z-80 (BIOS cold-boot generator) | $FB3A loop | Add 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
$FDB0serving 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.
- The Z-80 callbacks read and write the BIOS second half — BIOS state slots (
$FECB,$FED2,$FED4, etc.) located. - The cold-boot generator hides at the LIST jump-table entry — first attempt at locating cold-boot, later corrected.
$FB70is LIST, not BOOT. - 2.20 has inline init where 2.23 has runtime code generation — first cold-boot diff, also based on the wrong routines. The structural shift it argues for is real, but the bytes it cites aren’t the ones to compare.
- 2.20 and 2.23 BIOSes have identical device-scan loops — same instructions, same encoding. The fix isn’t in the scan.
- 2.20 ships static device handlers in BIOS; 2.23 generates them at runtime — the architectural shift, located in 2.20’s page 6 vs nothing in 2.23.
- The cold-boot generator: $FB3A in 2.23, $DB6E in 2.20 — and the device-code 6 branch only 2.23 has — the generator located in both versions.
- $FDB0 (the Pascal 1.1 callee) is a RET stub — driver init lives somewhere else — the disappointment behind the generator’s device-6 branch.