Half the BIOS is zero on disk because it's generated at runtime
Detail for Part 5 — The BIOS That Half-Exists.
Update (2026-04-28): The “first 1 KB populated, second 1 KB all-zero” framing is too coarse. A page-by-page byte histogram shows both BIOSes use a 256-byte interleave: alternating code pages and runtime-generated pages, four of each. 2.20 fills the generated pages with
$E5(CP/M’s deleted-file marker, alsoPUSH HL); 2.23 uses aFF FF 00 00 / F7 F7 00 00pattern that decodes asRSTtraps. The runtime-generation observation survives — what changes is the layout (interleaved chunks, not bookend halves) and the implied position of the BOOT vector ($FED1lives in a runtime-generated page; the actual cold-boot entry isn’t where the populated-half claim assumed). See the BIOS jump-table correction.
Picking up from the LOAD_CPM finding: the BIOS at Z-80 $FAB8 is 2 KB total. The first 1 KB ($FAB8-$FEB7) is loaded by LOAD_CPM and lands at Apple $0C00-$0FFF after PREP_HANDOFF — verified by exact byte-for-byte match against the BIOS extract from earlier. But the second 1 KB ($FEB8-$FFFF) — containing BOOT, HOME, SELDSK, SETTRK, READ, WRITE, and the per-device handlers that the dispatch table at $FB0A references — wasn’t anywhere in the LOAD_CPM staging.
So where does it come from?
The next physical sectors after where LOAD_CPM stops (trk2:$07) are trk2:$08-$0B — exactly 4 sectors = 1 KB. Extracted those from the .DSK and compared against the second half of the BIOS extract. They match — and they’re all zeros. Both the disk-source bytes AND the second half of the BIOS extract are entirely zero-filled.
This isn’t a bug in the extraction. The .DSK genuinely has zero bytes at trk2:$08-$0B. The BIOS area $FEB8-$FFFF in the running CP/M isn’t loaded from disk because there’s nothing on disk to load.
Which means the actual code at those addresses gets generated at runtime. SoftCard CP/M does code generation at boot time.
The pattern is consistent with what’s visible in the populated half:
$FAB8-$FAE4: 15-entry jump table (JP BOOT,JP WBOOT,JP CONST, etc.) — fixed targets that point into the second half (e.g., BOOT →$FED1, HOME →$FE6C)$FAE5-$FB09: small inline stubs (LISTSTXOR A; RET, SECTRANLD H,B; LD L,C; RET)$FB0A-$FB39: the per-device dispatch table (4 entries × 16 bytes), with each entry referencing two routine addresses in the second half ($FFAC-$FFD0for one set,$FF64-$FF9Afor the other)$FB3A-$FE6B: directly-disassemblable code — CONOUT, LIST, the$FB45helper, plus more$FE6C-$FFFF(second half): all zeros at boot
When the Z-80 reaches its first instruction (JP $FA00 from the planted reset vector — see Part 2), it lands in the BIOS cold-boot area $FA00-$FAB7. That code reads the device-code table the slot scanner built at Apple $03B9-$03BF (Z-80 sees these at $03B9+ as well — same address since $0xxx addresses are XOR’d to $1xxx), then writes the appropriate per-device handler bytes into the second half of the BIOS area. After initialization, the second half of the BIOS contains real code; at boot time before initialization, it’s zeros.
This explains a lot:
- Why CP/M 2.20 and 2.23 differ structurally. The “BIOS factory” (the cold-boot code generator) is different between versions. 2.23’s factory knows how to generate Pascal-1.1 driver code (because it sees the new device code
$06in the slot table); 2.20’s doesn’t. - Why diffing the static .DSK images shows so many bytes different. The .DSK doesn’t store the full BIOS — it stores the code generator. The actual runtime BIOS is the OUTPUT of the generator, not its source.
- Why static analysis can only see half. The runtime second half doesn’t exist statically. Either we run it (Z-80 emulator) or we trace the generator’s logic forward to predict its output.
- Why the BIOS jump table targets
$FED1,$FE6C, etc. look reasonable but the bytes there are dispatch-table data: because at runtime they’re NOT dispatch-table data — they’re populated routines. The dispatch table at$FB0Ais in the first half (correct in our extract); the routines it dispatches to are in the second half (zero in our extract; populated at runtime).
The extracted bytes also explain another puzzle. CONST at $FB10 and CONIN at $FB1A “decode as data” because they ARE data — they’re slots within the dispatch table that other code (the per-device handlers, generated at runtime in the second half) reads from. CONST and CONIN are presumably reached via different code paths (maybe their targets are RE-WRITTEN by the BIOS factory, not at the original $FB10/$FB1A addresses, but to something pointing into $FExx-$FFxx).
Where this leaves us
The 6502 boot loader is now fully understood. The CCP, BDOS, and the first 1 KB of the BIOS are now extracted and disassemble cleanly as Z-80 code. The Z-80 disk callbacks at Apple $0A00 are extracted. We have the boot banner string verifying everything is correct.
What’s needed to see the runtime second half of the BIOS:
- Either trace the BIOS cold-boot code generator from Z-80
$FA00-$FAB7(which IS in our extract — the populated half includes everything below the jump table) and predict the generated code - Or boot the system in a Z-80 emulator and dump memory after BIOS init completes
The first path is doable from the extracted bytes — the cold-boot logic is sitting in our bios_223.bin at offsets $0000-$0010 (just before the jump table); pointed to by the BOOT entry in the jump table (which points at $FED1, but at boot time $FED1 is zero, so the actual cold-boot must be reached differently — probably via a JMP from the planted Z-80 reset vector or similar).
Actually — the planted Z-80 reset vector is JP $FA00. So the Z-80 cold-boot starts at $FA00, which is below the BIOS jump table base at $FAB8. The bytes at $FA00-$FAB7 are the ~$B8 bytes of “pre-jump-table” BIOS code — the actual cold-boot routine. It runs first, sets up state, generates the per-device handlers in the $FE/$FF area, then presumably falls through to the jump table or a normal WBOOT. That’s the next concrete trace target.
Status: the BIOS isn’t missing — it’s runtime-generated. The cold-boot code generator is in bios_223.bin at $FA00-$FAB7 (~184 bytes, in our extract) and is the next thing to disassemble. Once that’s understood, we’ll know the algorithm that produces the per-device handlers from the device-code table — which is the real Part 4 article.
Update (2026-04-27): The claim above that the cold-boot generator is at $FA00-$FAB7 was wrong. That area is data/zeros, not code (visible in the staging-bytes dump if you re-check the disassembly output). The actual cold-boot entry is at $FB70, mislabeled as the LIST jump-table entry — see the cold-boot finding devlog for the corrected location and what that routine actually does (stack init, BDOS call vector planting, Z-80 reset vector rewrite from JP $FA00 to JP $FA03).