Stage 2 emulator: BIOS jump table is at Z-80 $1A00, not $FAB8

5 min read
6502z80cpmsoftcardapple-iiemulationreverse-engineeringretrocomputingcpm-videx-series

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 tableC3 D1 FE C3 B8 FA C3 10 FB C3 1A FB ... These are CP/M’s standard 17 JP nn entries: 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-$9CFF via 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 on JSR $FBxx calls, 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 $FA00 reset 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.bin does use IY-prefixed instructions per the static disasm.
  • (c) Is dispatched via the cooperative-CPU sync flag at $E000/$E010 and 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.