cpm-videx-series — Part 11

Verifying It in the Emulator

Stage-1 and Stage-2 of a custom SoftCard CP/M emulator confirm what's true and reveal what's still wrong. Empirical proof that the 6502 never writes $FA00-$FFFF and that the BIOS jump table actually lives at Z-80 $1A00, not $FAB8.

10 min read advanced
apple-ii6502z80cpmsoftcardemulationreverse-engineeringoperating-systemsretrocomputingcpm-videx-seriesdeep-dive

Part 11 of the cpm-videx series. Part 10 closed the static-analysis phase with three runtime-only open items: the actual byte-for-byte mechanism of $FA00-$FFFF population, the runtime mechanics of the trap-marker-page rewrite, and the 2.20 hang’s exact failure mode. This article reports what an emulator built from scratch this week settled, what it broke, and what it left for further work.


Why an emulator at all

Static analysis of CP/M 2.23 on the SoftCard reached its useful limit around Part 10. Three of the project’s threads converged on the same gap: each required watching memory while code was actually executing. The disassembly says “the cold-boot generator at $FB3A populates the trap-marker pages” but doesn’t say where the cold-boot generator’s bytes come from. The disassembly says “Z-80 starts with JP $FA00” but $FA00 is uninitialized when the 6502 hands off, on disk and after every PREP_HANDOFF copy. The disassembly says “2.20 hangs because $DFBE → CALL $DAC5 lands in the runtime-generator zone” but doesn’t say which of the two static-evidence-consistent failure modes actually fires.

So I built a SoftCard emulator. Two stages, two findings, one open mechanism that points at the next stage.

Stage 1: 6502-only

The first stage emulates only the 6502 side: a custom Disk II model that synthesizes GCR nibble streams from the .dsk sector image, the real Disk II P6 PROM bytes installed in slot 6, the standard Apple monitor RTS-stubs at the routines stage-2 calls ($FB2F, $FE89, $FE93, $FCA8, etc.), and a write-logger that records every byte the 6502 stores anywhere in memory.

The boot completes. The 11 boot-stub iterations load track 0 sectors $00-$0F into Apple $0A00-$13FF. Stage-2’s three install loops run. The slot scanner runs. PREP_HANDOFF copies all execute. LOAD_CPM reads its 29 sectors. The Z-80 reset vector lands at Apple $1000-$1002 as C3 00 FA. The reset-vector-patch loop writes 6 bytes at $FFF9-$FFFF. Then the 6502 enters the warm-boot loop at $03C0 and starts polling.

Total writes by the 6502 to anywhere in $FA00-$FFFF over the entire boot: 6. All 6 are the reset-vector patch at $FFF9-$FFFF. The remaining 1530 bytes from $FA00-$FFF8 are zero when the 6502 hands off.

This was the first hard finding. The static analysis had assumed the BIOS bytes “land at $FA00-$FFFF” some way; static evidence couldn’t say whether that was a 6502 copy I hadn’t traced, a SoftCard ROM overlay, or runtime Z-80 generation. The emulator settles it: not the 6502.

(One slot-scanner edge case showed up as a side finding. The path that increments the scanner’s iteration counter $3E is reached only via BEQ $1086 when $3E = 0, but $3E is initialized to $FF at $105E and no other instruction in the loader writes it. So SCAN_INIT_SLOT looks unreachable on a cold start. The boot must work on real hardware — Microsoft shipped this — so something else clears $3E, possibly a SoftCard hardware side effect on a specific memory access. I patched $3E = 0 externally and noted the open question.)

Stage 2: Z-80 takeover

Stage 2 added a from-scratch Z-80 emulator (about 700 lines: registers, ALU with full flag emulation, all standard 8-bit and 16-bit loads, jumps, calls, returns, conditional variants, RST, DJNZ, JR, EXX, EX variants, IN/OUT, rotate-accumulator family, CB-prefix bit operations on r and (HL), ED-prefix subset including LDIR/LDDR/IM 0..2/LD 16-bit with (nn)). The DD/FD-prefixed IX/IY family is stubbed — execution halts with a clear error if hit.

The SoftCard memory mapper is a single function that XORs bit 12 of any Z-80 address below $2000 and passes higher addresses through unchanged. The Z-80’s read and write hooks route every access through it.

The CPU-switch trigger is modeled as a 6502 PC breakpoint at Apple $0E36. When the 6502 reaches it (called from the warm-boot’s JSR $0E36 at $03CC), the breakpoint stops the 6502 and starts the Z-80 at softcard_xor($0E36) = $1E36 with SP $0080.

After the switch, the Z-80 executes for as long as I let it. With 500K instructions of headroom it walks from $1E36 through the polling-loop area, drifts into uninitialized $FAxx (which my flat-RAM emulator returns as zero, executing as NOP NOP NOP...), and walks through that region forever. Total writes to $FA00-$FFFF after Z-80 takeover, in 2 million instructions: zero.

So the Z-80 also doesn’t populate the BIOS region — at least not via the path my emulator takes. The cold-boot generator must run from a different entry point, or it requires opcodes my emulator hasn’t implemented, or it depends on the cooperative-CPU disk-read sequence that requires bouncing back to the 6502 for actual sector fetches.

But the trace did surface one major static-analysis correction.

Correction: the BIOS jump table is at $1A00

Looking at memory after the 6502 finishes its boot:

$0A00: C3 D1 FE C3 B8 FA C3 10 FB C3 1A FB C3 4D FB ...

That is the BIOS jump table — 17 three-byte JP nn entries. BOOT → $FED1, WBOOT → $FAB8, CONST → $FB10, CONIN → $FB1A, CONOUT → $FB4D, LIST → $FB70, PUNCH → $FB7F, READER → $FB91, HOME → $FE6C, SELDSK → $FE8E, and so on.

That table sits at Apple $0A00. Z-80 sees it at $1A00 via the SoftCard’s bit-12 XOR. The annotated source files in the repo (docs/CPM223_BIOS.asm and the prose in Part 7) both placed the jump table at $FAB8 based on the bytes in bios_223.bin — but those bios_223.bin bytes are handler bodies the table points to, not the table itself. The 1208 bytes at $FAB8-$FFFF in the static disassembly are the destinations of the table’s JP entries.

So the BIOS architecture is:

WhatWhere (Apple)Where (Z-80)Source
Jump table (17 × 3-byte JP)$0A00-$0A2F$1A00-$1A2FPREP_HANDOFF #2 from staging $9700-$972F
BIOS first 1 KB code (dispatch helpers, polling loop, cold-boot gen)$0C00-$0FFF$1C00-$1FFFPREP_HANDOFF #2 from staging $9900-$9CFF
Handler bodies (BOOT, WBOOT, CONST handlers, etc.)$FA00-$FFFF$FA00-$FFFFruntime-generated by the cold-boot generator

Calling any BIOS function (e.g. CONOUT) from CCP or BDOS goes via $1A0C (CONOUT entry → JP $FB4D), which then jumps to the handler body at $FB4D. Until the cold-boot generator builds those handlers, every BIOS call is a jump into uninitialized memory.

This refines Part 7’s framing. The “BIOS at $FAB8-$FFFF” wasn’t wrong — that’s where the handler bodies are at runtime — but it conflated table location with target location. The annotated source needs an update.

The remaining puzzle

The Z-80 reset vector planted by the 6502 is JP $FA00. $FA00 is empty at the moment the Z-80 takes over. So the question becomes: how does the cold-boot generator’s first byte ever get there? Static analysis and runtime trace agree it doesn’t come from the 6502. So one of these must be true:

  • (A) The Z-80 doesn’t actually start at $0000 after the SoftCard switch. Maybe the switch hardware sets the Z-80 PC to whatever Apple address the 6502 was attempting to fetch — $0E36 — which under the bit-12 XOR is Z-80 $1E36, in the BIOS first 1 KB code. The cold-boot generator might live there and write to $FAxx first, then set up the JP $FA00 reset vector for subsequent warm-boots. Stage 2 modeled the switch this way, and the Z-80 did execute from $1E36 — but the path it walked never wrote to $FAxx. Either I picked the wrong landing zone, or the generator path requires opcodes my Z-80 doesn’t yet handle (DD/FD-prefixed IX/IY, of which the static disassembly of the generator does use some).

  • (B) The SoftCard’s hardware does have something the user’s “no ROM” correction doesn’t fully cover. Even without a boot ROM, the hardware might have a small generator circuit that writes a fixed pattern to $FA00 on power-up. This contradicts the user’s note, but I list it for completeness.

  • (C) The cold-boot generator is in the BIOS first 1 KB at Z-80 $1C00-$1FFF, runs cooperatively with the 6502 to fetch additional sectors that contain the $FA00-$FFFF body bytes, and uses the $E000/$E010 sync-flag mechanism to bounce back and forth. This requires my emulator to model the SoftCard switch as bidirectional — Z-80 can flip back to 6502, 6502 services a request, then flips back. My current Stage-2 model is one-way (6502 → Z-80, no return).

Most likely (C). The byte-level trace evidence in Part 8 (cooperative-CPU model) already pointed at this; the emulator just hasn’t implemented the bidirectional switch yet.

What this leaves for Stage 3

Three concrete next steps:

  1. Implement the IX/IY (DD/FD prefix) opcodes in the Z-80 emulator. The cold-boot generator’s static disassembly uses them.
  2. Make the SoftCard switch bidirectional. Add a hardware-soft-switch model that the Z-80 can hit to flip control back to the 6502, mirroring whatever real-hardware mechanism the cooperative-CPU model relies on.
  3. Add a Videx Videoterm slot-3 model (the a2fpga ROM 2.4 disassembly is the reference). Boot 2.20 with the Videx in slot 3. Trace which CPU is hung where. That settles the 2.20 hang’s failure mode (the byte-level static trace showed two scenarios consistent with the observed hang; runtime distinguishes them).

What’s settled

What an emulator-driven look settled this week:

  • 6502 doesn’t populate $FA00-$FFFF. Empirically zero writes from the entire boot loader.
  • BIOS jump table is at Z-80 $1A00, not $FAB8. The static disassembly conflated table location with target location.
  • BIOS handler bodies at $FAxx-$FFxx are runtime-generated. Confirmed by absence at handoff and the fact that the table targets them.
  • The CPU switch fires at Apple $0E36. The 6502 trying to execute Z-80 bytes installed there triggers the switch, regardless of how the SoftCard hardware detects it.

What’s still open after Stage 2:

  • The exact entry point the Z-80 first executes after the switch.
  • Whether the cold-boot generator is in the BIOS first 1 KB at $1C00-$1FFF or somewhere else.
  • The 2.20 hang’s actual failure mode (Case A vs B from the static-evidence trace).

Status

Series Part 11. The investigation now has a working emulator skeleton plus two settled corrections. Stages 3 and beyond will close the remaining items via Videx slot-3 modeling and a bidirectional CPU switch.