Stage 2 emulator: BIOS jump table is at Z-80 $1A00, not $FAB8
Stage 1 showed the 6502 never writes $FA00-$FFFF. Stage 2 builds out the Z-80 side to find what actually populates the BIOS region. This entry reports two solid findings, an open mystery, and the new Z-80 emulator that produced both.
The new Z-80 emulator
nibbler/z80_cpu.py (~700 lines, all-Python). Implements:
- All standard registers + alternate set (AF/BC/DE/HL + AF’/BC’/DE’/HL’), IX, IY, I, R, IFF1/IFF2, IM
- 8-bit ALU with full flag emulation (including X/Y undocumented flags)
- 16-bit INC/DEC/ADD HL,rr
- All loads, jumps, calls, returns, conditional variants, RST, DJNZ, JR, JR cc
- EXX, EX DE,HL, EX (SP),HL, EX AF,AF’
- DI, EI, NOP, HALT, SCF, CCF, CPL, IN/OUT
- Rotate accumulator (RLCA, RRCA, RLA, RRA)
- CB-prefix: rotate/shift/SLA/SRA/SLL/SRL/BIT/RES/SET on r and (HL)
- ED-prefix: LDIR, LDDR, IM 0/1/2, LD (nn),rr, LD rr,(nn)
- DD/FD-prefix dispatchers (the IX/IY opcodes themselves not yet populated; halts with clear error if hit)
Memory access goes through read_hook/write_hook callbacks so the SoftCard’s bit-12 XOR for low addresses can be modeled externally.
SoftCard memory mapping
cpm-investigation/emu_softcard_full.py ties the 6502, Z-80, and shared 64K Apple memory together. The mapping function:
def softcard_xor(z80_addr):
if z80_addr < 0x2000:
return z80_addr ^ 0x1000
return z80_addr
Z-80 $0000-$0FFF ↔ Apple $1000-$1FFF and Z-80 $1000-$1FFF ↔ Apple $0000-$0FFF. Above $2000, straight-through.
The CPU-switch model: a 6502 PC breakpoint at Apple $0E36. When the 6502 reaches that address (called from the warm-boot routine’s JSR $0E36 at $03CC), the breakpoint switches the active CPU to Z-80, sets Z-80 PC to softcard_xor($0E36) = $1E36, initializes Z-80 SP to $0080, and stops the 6502 run loop.
What the trace settles
After 2.4 M 6502 instructions the boot reaches the CPU-switch trigger. Memory state at that moment:
$0A00-$0A2F: the BIOS jump table —C3 D1 FE C3 B8 FA C3 10 FB C3 1A FB ...These are CP/M’s standard 17JP nnentries: BOOT→$FED1, WBOOT→$FAB8, CONST→$FB10, CONIN→$FB1A, CONOUT→$FB4D, LIST→$FB70, etc.$1000-$1002:C3 00 FA(Z-80 reset vector = JP$FA00).$0A00-$0FFF: 1443 non-zero bytes total — Z-80 disk callbacks + BIOS first 1 KB code (sourced from staging$9700-$9CFFvia PREP_HANDOFF #2).$A300-$B9FF: 5596 non-zero bytes — CCP+BDOS+banner.$BA00-$BFFF: 1483 non-zero bytes — preserved 6502 RWTS.$FA00-$FFFF: 18 non-zero bytes, all of them either Apple-monitor-stub$60(RTS) bytes I installed to keep the 6502 from crashing onJSR $FBxxcalls, or the 6 reset-vector bytes the boot loader patched at$FFF9-$FFFF.
So the BIOS region $FA00-$FFFF is empty when the Z-80 takes over.
Correction: the BIOS jump table is at $1A00, not $FAB8
Prior static analysis (Part 7) and the annotated docs/CPM223_BIOS.asm placed the BIOS jump table at $FAB8. The runtime trace shows the table actually lives at Z-80 $1A00 (which is Apple $0A00 after bit-12 XOR). That’s where PREP_HANDOFF #2 put it — the first 6 pages of staging ($9700-$98FF) become Apple $0A00-$0BFF, and the table sits at the start.
The $FAB8 address that appears in the static disasm of bios_223.bin is a target of the table’s WBOOT entry (JP $FAB8), not the table’s own location. The 1208-byte bios_full_first.bin contents starting at $FAB8 are what the Z-80 runs after the table dispatches — they belong at $FAB8-$FFFF at runtime, populated by something we haven’t traced yet.
The annotated source files in docs/ need to be updated to reflect this: the BIOS isn’t a contiguous $FA00-$FFFF block at boot; it’s a small jump table at $1A00 plus runtime-populated code at $FA00-$FFFF.
What doesn’t work yet
I ran the Z-80 for 2 M instructions after takeover. It walked through $1Exx (the polling-loop area), wandered into uninitialized $FAxx, executed NOPs (zero bytes), and never wrote to $FA00-$FFFF. So the cold-boot generator that’s supposed to populate $FAxx-$FFxx either:
- (a) Lives at an address the Z-80 never reaches with our current entry point of
$1E36. Maybe the real Z-80 takeover starts at$1C00(BIOS first 1 KB code) or the planted$FA00reset vector behaves differently than I’m modeling. - (b) Requires unimplemented IX/IY opcodes that my Z-80 emulator currently halts on (DD/FD prefix). The cold-boot routine in
bios_223.bindoes useIY-prefixed instructions per the static disasm. - (c) Is dispatched via the cooperative-CPU sync flag at
$E000/$E010and depends on the 6502 RWTS providing data the Z-80 then writes — which would require keeping the 6502 alive concurrently to service disk reads driven by the Z-80, not the simple “switch and stop 6502” model I have now.
Most likely (b) plus (c). The next stage adds IX/IY opcodes and a cooperative-CPU model where the SoftCard switch can flip back to 6502 on demand.
What this corrects
The static-analysis claim “BIOS at $FA00-$FFFF runs as a unit after handoff” is half right. The dispatch table is in low memory at Z-80 $1A00; the handler bodies are in $FAxx-$FFxx and are runtime-generated. Until the cold-boot generator actually runs, calling any BIOS function via the table jumps into uninitialized memory.
bios_full_first.bin (the disk-staged 1208 bytes from $FAB8) is a snapshot of post-cold-boot state, not the boot-time disk content. The trap-marker pages (FF FF 00 00 / F7 F7 00 00 patterns) are what’s there on disk in the staged bytes — and what gets written as the cold-boot generator builds the BIOS.
Status
Stage-2 emulator boots through the SoftCard CPU switch and runs Z-80. Empirically settles that 6502 doesn’t populate $FAxx. Reveals the BIOS jump table’s actual location at Z-80 $1A00. Doesn’t yet reproduce the cold-boot generator’s actions; that requires DD/FD opcodes + cooperative-CPU model. Stage 3 next.