cpm-videx-series — Part 7
From the Reset Vector to the Device Scan
What happens when the Z-80 first executes after the SoftCard switch. The path from JP $FA00 through cold-boot setup, the BIOS factory's generator, and into the device-scan dispatch — and an honest accounting of which mechanisms are still open.
Part 7 of the cpm-videx series. Part 6 located the cold-boot generator at $FB3A (2.23) and identified the device-code-6 branch as the Z-80 side of the Videx fix. This part walks the boot path that runs through that generator — from the moment the Z-80 fetches its first instruction to the device-scan dispatch — and the still-open mechanisms it leaves behind.
Update (2026-04-29): A runtime trace in the Stage-2 emulator devlog revises one detail of this article. The BIOS jump table (CP/M’s 17-entry JP nn dispatch) actually lives at Z-80 $1A00 (Apple $0A00 after bit-12 XOR), not at $FAB8. Static analysis confused the table’s targets (which are addresses in $FAxx-$FFxx) with the table’s location. The 1208 bytes at $FAB8-$FFFF in bios_223.bin are the handler bodies the table dispatches to — and they’re populated by the cold-boot generator at runtime, not by 6502 boot code. The “BIOS at $FAB8-$FFFF” framing in this article should be read as “BIOS handler code at $FAB8-$FFFF, dispatched via jump table at $1A00.”
The setup
By the time the Z-80 starts executing, the 6502 has already done a lot:
- The boot stub loaded the CP/M loader from track 0.
- The stage-2 loader at Apple
$1000enabled the language card RAM, configured the Apple monitor, scanned the slots, and built the slot-info table at$F3B8-$F3BF(one byte per slot, holding a per-slot device code). - LOAD_CPM read 29 sectors from disk (trk0:
$0Bonward) into a staging area at Apple$8000-$9CFF. - PREP_HANDOFF moved the staging into final positions: CCP+BDOS to Apple
$A300-$B9FF, Z-80 disk callbacks plus the BIOS first 1 KB to Apple$0A00-$0FFF. - The 6502 wrote
$C3 $00 $FA(= Z-80JP $FA00) at Apple$1000-$1002. Apple$1000is Z-80$0000under the SoftCard’s bit-12 XOR mapping. So that’s the Z-80’s reset vector. - The 6502 somehow triggered the SoftCard’s CPU switch — the exact instruction that flips the bus from 6502 to Z-80 isn’t yet identified (the loader contains zero
$C0Bxwrites, the conventional SoftCard switch port).
After the switch, the Z-80 fetches its first instruction from address $0000. That instruction is JP $FA00. So the Z-80 immediately jumps high into the BIOS region.
The first jump lands in runtime-generated code
$FA00 is below the BIOS jump table (which starts at $FAB8). It’s in a 184-byte region that, on the disk, is filled with $E5 bytes (in 2.20) or FF FF 00 00 / F7 F7 00 00 markers (in 2.23). Both fill patterns are “trap-style” — $E5 decodes as PUSH HL and the FF FF 00 00 pattern as RST $38; RST $38; NOP; NOP. Either way, executing them as instructions would produce stack thrash or trap to a low-memory vector — not the controlled cold-boot path that actually runs.
So either:
- Something writes real code into
$FA00-$FAB7before the Z-80 first reaches it, or - The SoftCard hardware overlays a built-in ROM into that address range for the first few instructions, then disables the overlay.
The first option is more parsimonious, but I haven’t traced the exact code path yet. The 6502 loader doesn’t visibly write to language card RAM addresses corresponding to $FA00-$FAB7. (Z-80 high addresses don’t go through the bit-12 XOR; they’re served by the SoftCard’s RAM, which is presumably language card RAM banked into Apple $D000-$FFFF.) There’s something about the LC bank-switch sequence — or a side-effect of one — that has to put real code into $FAxx before the Z-80 fetches instruction one.
This is an open mechanism. The structural understanding works without it, but the first instruction execution of cold-boot is reaching code that arrives in memory by some path I haven’t fully identified.
What the cold-boot path eventually does
Whatever populates $FA00-$FAB7 plants enough code there to set up state and reach the BIOS proper. Disassembly of the cold-boot continuation at $FB70 shows the architectural setup in concrete instructions:
$FB70: LD SP,$0080 ; init the Z-80 stack
$FB73: LD A,($E051) ; read Apple's video state byte
$FB76: LD HL,$0E00 ; (some setup pointer)
$FB79: CALL $FB45 ; initialization helper
$FB7C: CALL $FA82 ; CALL into the cold-boot area (runtime-generated!)
$FB7F: LD A,($9C08) ; read into BDOS area to detect first-boot vs warm-boot
$FB82: CP $9C
$FB84: JR Z,$FB97 ; first-boot? skip the warm-boot path
$FB86: LD HL,$FF59 ; warm-boot: set up state pointer
$FB89: LD ($F3D0),HL
$FB8C: LD HL,($F3DE)
$FB8F: LD A,$77
$FB91: LD ($000B),A
$FB94: JP $000B ; hand off to a runtime-stored entry point
$FB97: XOR A ; first-boot: zero some state
$FB98: LD ($9307),A
$FB9B: XOR A
$FB9C: LD ($FEDD),A
$FB9F: LD ($FED8),A
$FBA2: LD A,$C3 ; A = $C3 = JP opcode
$FBA4: LD ($0000),A ; plant JP at $0000
$FBA7: LD HL,$FA03
$FBAA: LD ($0001),HL ; new reset vector = JP $FA03
$FBAD: LD ($0005),A ; plant JP at $0005 (BDOS call)
$FBB0: LD HL,$9C06 ; BDOS final position
$FBB3: LD ($0006),HL ; BDOS call vector = JP $9C06
$FBB6: LD BC,$FF80
A surprising thing: cold-boot rewrites the Z-80 reset vector from JP $FA00 to JP $FA03. This is a one-shot mechanism. The first cold-boot enters at $FA00 (which presumably skips an initialization-only first instruction). Subsequent warm-boots — where CP/M’s BDOS does a “warm boot” via the JP $0000 reset on user-program completion — enter at $FA03, bypassing the cold-only setup.
The cold-boot also plants the standard CP/M BDOS call vector: any user program that does CALL $0005 to invoke a BDOS function flows through JP $9C06, where the BDOS lives after relocation. (CCP+BDOS gets relocated from its staged position at Apple $A300 to $9C06 by some intermediate step still being traced.)
Then the generator runs
After the architectural setup, the cold-boot generator at $FB3A runs. (Part 6 covers this in detail.)
The generator iterates the slot-info table at $F3B8+E for E = 7..1 and dispatches each detected device code to a per-device init routine:
- Device code 3 →
CALL $FE81 - Device code 4 (Pascal 1.0) →
CALL $FD83+LD HL,$C800+ helper - Device code 6 (Pascal 1.1) →
LD HL,$0DD0+CALL $FDB0← this is 2.23’s Videx branch
The 2.20 generator is structurally identical except for the missing device-code-6 branch.
The device-scan dispatch
After the generator, the BOOT vector at $FED1 (in code page 4) lands in a NOP slide that flows into real code at $FF0E:
$FF0E: LD B,A
$FF0F: LD HL,$FECD ; state byte (in runtime-populated zone)
$FF12: LD A,(HL)
... (preflight check)
$FF29: LD HL,$F3A0 ; device-code table base
$FF2C: LD B,$09 ; 9 entries to scan
$FF2E: LD A,(HL) ; ← scan loop start
$FF2F: OR A
$FF30: JR Z,$FF36 ; entry empty → skip
$FF32: XOR E
$FF33: CP C
$FF34: JR Z,$FF3B ; match!
$FF36: DEC HL
$FF37: DJNZ $FF2E
$FF39: JR $FF5C ; no match — error path
$FF3B: LD DE,$000B
$FF3E: ADD HL,DE
$FF3F: LD A,(HL)
$FF40: OR A
$FF41: LD C,A
$FF42: JP P,$FC9A ; ← match dispatch into runtime-generated handler
The match dispatch at $FF42 jumps to $FC9A. That address is in BIOS page 1 ($FBB8-$FCB7) — a trap-marker page on disk. So the dispatch jumps to whatever the runtime-population mechanism wrote at $FC9A.
The trap-marker pages and the open question
The 2.23 BIOS has trap-marker pages interleaved with code pages: pages 1, 3, and 5 in BIOS layout. Static code in pages 0, 2, and 4 makes calls and jumps into the trap-marker pages. So the trap-marker pages have to hold real code at runtime, even though they hold FF FF 00 00 / F7 F7 00 00 patterns on disk.
I’ve found one strong candidate for the source of the runtime-installed bytes: physical sector trk2:physA of the disk image contains 256 bytes of real Z-80 code that calls into BIOS routines ($FE81, $FD80, $FB45, etc.). That sector isn’t in LOAD_CPM’s staging — the 6502 loader doesn’t read it. But the bytes are real and BIOS-aware, so they’re intended to execute somewhere.
The simplest hypothesis is that the Z-80 cold-boot path itself reads additional sectors from disk via the BIOS callbacks (which become available once cold-boot has set up the disk-callback dispatch) and copies them into the trap-marker pages. That would mean the boot sequence has two loading stages: 6502-side LOAD_CPM (the static BIOS plus CCP+BDOS) and Z-80-side cold-boot (the per-device handler bytes from sectors past LOAD_CPM’s range).
That hypothesis is still being tested. The mechanism by which $FA00-$FAB7 is initially populated is also still open. So is the SoftCard CPU-switch trigger.
What I can describe is the architectural skeleton:
- Z-80 starts; reset vector =
JP $FA00(planted by 6502). - Code at
$FA00-$FAB7runs (populated by mechanism unknown — leading candidate: SoftCard hardware overlay or LC RAM bank-switch side-effect). - Cold-boot setup at
$FB70initializes the stack, plants the BDOS call vector at$0005, rewrites the Z-80 reset vector to$FA03, zeros some state. - Cold-boot generator at
$FB3Aruns the slot-info dispatch; for each detected device, it calls a per-device init (and possibly loads additional handler bytes from disk). - BOOT vector dispatch lands at
$FED1(NOP slide), flows to device-scan at$FF0E. - Device-scan matches the slot-info table to find a match; dispatches to a runtime-installed handler at
$FC9A(or related addresses in trap-marker pages). - The handler does the device-specific I/O — for a Videx, the Pascal 1.1 calling convention.
- CCP+BDOS run normally (relocated to
$9C06); BDOS prints the boot banner; CCP shows theA>prompt.
The articles up through Part 6 cover steps 4-7 in detail. This article maps the larger boot path that surrounds them. The next steps are filling in the open mechanisms — particularly steps 2 and 6’s runtime population, and the SoftCard switch in step 1.
Why the gaps still matter
It’s tempting to declare the Videx fix “solved” once you’ve found the 11 bytes in the slot scanner and the 10 bytes in the BIOS generator. They’re the visible delta. But the system that those deltas plug into — the BIOS factory, the runtime-population mechanism, the cooperative-CPU disk I/O — is what makes those deltas work. Without the factory, 2.20’s slot scanner could detect Pascal 1.1 all day and the BIOS would have nowhere to dispatch. Without runtime population, the generator’s CALL $FDB0 would land in trap markers and the system would RST $38 to a dead end.
So the gaps aren’t details — they’re the connective tissue. Filling them in is the rest of the project.
What’s settled and what’s open (snapshot at the end of this article):
| Stage | Status |
|---|---|
| 6502 boot stub | Settled (Part 2) |
| Stage-2 loader | Settled (Part 2) |
| Slot scanner + Pascal 1.1 detection | Settled (Part 1) |
| LOAD_CPM 29-sector read | Settled (Part 5) |
| PREP_HANDOFF page copies | Settled (Part 5) |
| Z-80 reset vector planting | Settled (Part 2) |
| SoftCard CPU-switch trigger | Open |
Initial population of $FA00-$FAB7 | Open |
Cold-boot setup at $FB70 | Settled (this article) |
Cold-boot generator at $FB3A | Settled (Part 6) |
Device-scan dispatch at $FF0E | Settled (Part 6) |
| Runtime population of trap-marker pages | Open — strong candidate sectors identified |
CCP+BDOS relocation from $A300 to $9C06 | Resolved later — see the boot-finalization devlog. The loader’s third page copy at $1933-$193D does it. |
| Disk I/O round-trip via cooperative CPUs | Partially settled (concept clear, switch trigger open). Part 8 traces the full round-trip. |
| BDOS init and CCP prompt | Open (relies on relocation) |
Three of the four “open” items revolve around the same question: what runs between the 6502 finishing its loader and the Z-80 reaching its BIOS? The instructions in that interval live in regions written by mechanisms not yet traced, or behind hardware behavior the 6502 code only triggers (the CPU switch, LC RAM bank-switch side-effects).
Part 8 covers the cooperative-CPU disk I/O bridge and the concrete trace of why 2.20 hangs on a Videx. Part 9 is the categorical inventory of every byte that differs between 2.20 and 2.23.
Deep dives
- Real Z-80 code at trk2:physA — past where LOAD_CPM reads — disk sectors past the main LOAD_CPM range that contain real Z-80 code calling BIOS routines, but aren’t in the staging the 6502 loader produces. Strong candidate for one of the runtime-population sources.