Tracing CONOUT into the BIOS hits the extraction wall

5 min read
z80cpmsoftcardreverse-engineeringretrocomputingcpm-videx-series

Detail for Part 5 — The BIOS That Half-Exists.

Continuing from the Z-80 disassembler devlog: traced CALL $FB45 from CONOUT (the helper that runs after LD HL,$C800) to see if it does Videx-pattern hardware writes.

$FB45 itself is 8 bytes, then falls through into CONOUT’s body:

$FB45: 07        RLCA               ; rotate A left
$FB46: CD 81 FE  CALL FE81          ; ?? — referenced
$FB49: 36 03     LD (HL),03         ; write 03 at (HL)
$FB4B: 36 15     LD (HL),15         ; write 15 at (HL)
$FB4D:                              ; falls through to CONOUT entry

It looks like a small init routine — but tracing CALL $FE81 immediately hits a wall. $FE81 in our BIOS extract is FF 00 00 FF FF 00 00 ... — that’s RST $38 followed by NOPs followed by more RST $38s, which is data, not a routine. The pattern continues through $FEA8 where it changes to F7 F7 00 00 and then to all zeros at $FEB8+. Some kind of structured table, but the routines it would call into aren’t here.

Same for the dispatch-table common targets: $FEE4 (which both common entries in the per-device dispatch table reference) is all zeros in our extract. So the dispatch table points at addresses that, at extraction time, contain no code.

Real Z-80 code does appear at $FF0E:

$FF0E: LD B,A
$FF0F: LD HL,FECD          ; load HL from a BIOS-internal data area
$FF12: LD A,(HL)
$FF13: LD E,A
$FF14: OR A
$FF15: JR NZ,FF29
$FF17: LD A,(F397)         ; check Apple ][ TPA address $F397 for state
$FF1A: OR A
$FF1B: JR Z,FF23
...

This routine reads from $F397 — that’s in the Apple ][ TPA area (which the Z-80 sees at the same address since $2000+ doesn’t get XORed). Some kind of system-state check.

And $FA73 (the OTHER constant address from the dispatch table entries) is before our BIOS extract starts ($FAB8 is offset 0). So that part isn’t here either.

The boundary of what’s recoverable is now clear:

  • In our extract: the jump table, CONOUT, LIST, the $FB45 init helper, the $FF0E dispatch routine, and probably a few other routines around $FE6C-$FE93 (HOME/SETTRK/SETSEC area) and $FF0E-$FFnn.
  • NOT in our extract: $FA00-$FAB7 (the BIOS cold-boot area below the jump table), $FE81 callee, $FEE4 callee, all four per-device input handlers at $FF64-$FF9F, all four per-device output handlers at $FFAC-$FFDF, BOOT at $FED1, and chunks elsewhere.

Two paths to fill the gaps:

  1. Reverse-engineer LOAD_CPM at Apple $0C00 — figure out exactly which sectors get read into which Z-80 addresses during the boot loader’s CP/M-image staging. Tedious 6502 disassembly + careful mapping. No new tooling needed.
  2. Z-80 emulator + memory dump — boot CP/M, let it reach the A> prompt, dump the entire $2000-$FFFF range. Gives the in-memory view directly. But nibbler doesn’t have a Z-80 emulator yet (only a Z-80 disassembler), so this needs new tooling.

Path 1 is mostly disassembly work that the existing toolchain handles. Path 2 produces cleaner results faster (no inference about what gets loaded where) but requires building a Z-80 emulator first, which is a significant project on its own.

Status: trace from CONOUT into per-device routines is blocked at the BIOS extraction boundary. About half of 2.23’s BIOS is reachable in the partial extract; the other half (specifically the per-device input/output handlers and BOOT) requires either Path 1 or Path 2. Both are real next steps; choice depends on whether the goal is “understand 2.23 specifically right now” (Path 2) or “build infrastructure that also unlocks 2.20 BIOS extraction and the diff” (Path 1, since LOAD_CPM is the same routine in both versions).


Update (2026-04-27): Path 1 was chosen and completed. The “missing” BIOS routines (BOOT, HOME, SELDSK, the per-device handlers, the cold-boot area) turned out to be runtime-generated, not loaded from disk — see BIOS runtime-generated. The cold-boot generator that produces them was located at $FB70 (in the populated half) — see the cold-boot finding. So the “extraction wall” was partly a categorization mistake — the bytes that “weren’t extractable” were never on disk to extract; they’re constructed at runtime by code in the populated half.