Factual byte-level trace of the v2.20 hang

5 min read
6502z80cpmvidexsoftcardreverse-engineeringretrocomputingcpm-videx-series

The series narrative (Part 8) describes the v2.20 hang as “the 6502 stuck inside the Videx ROM, with the Z-80 polling forever.” That’s directionally right but says “in practice it hangs” without a byte-level trace. This entry is that trace. It nails down what’s statically provable and where the boundary to dynamic verification lies.

The dispatch path

Slot scanner sees Videx in slot 3 ($C305 = $38, $C307 = $18), matches signature index 3 (Pascal 1.0), assigns device code 4 to the slot. Stored at Apple $03BB.

CONOUT in 2.20 dispatches via the per-slot device code. The dispatch table at $DAFF-$DB5E (six 16-byte entries — see the dispatch-table devlog) maps the device code to a handler pair. Device code 4 → dispatch entry 3 (the device-code-to-entry mapping is code - 1, since device codes 1-5 from the slot scanner map to entries 0-4 with entry 5 reserved). Entry 3’s handler-1 address is at offset 12-13 of the entry: $DBBE.

What’s at handler-1 ($DFBE)

Disassembled from bios_220.bin at file offset $0F2:

$DFBE: 32 F8 F6        LD ($F6F8),A      ; store char in BDOS state
$DFC1: 32 47 F0        LD ($F047),A      ; store char in BIOS state
$DFC4: 3A FF EF        LD A,($EFFF)      ; load some control byte
$DFC7: CD C5 DA        CALL $DAC5        ; into runtime-generator zone

12 bytes total. No RET. Falling through after CALL $DAC5 lands in entry 4’s handler-1 area at $DFCA:

$DFCA: D6 20           SUB $20
$DFCC: 32 E5 E5        LD ($E5E5),A
$DFCF: E5              PUSH HL
$DFD0: E5              PUSH HL
... E5 spam through $DFFF

$E5 is the Z-80 opcode for PUSH HL. Repeated PUSH HL overflows the Z-80 stack within ~256 iterations and starts overwriting other memory.

What’s at $DAC5

$DAC5 is in the BIOS runtime-generator zone ($DA00-$DACB, the 204 bytes immediately preceding the BIOS jump table base at $DACC). On disk, those bytes are $E5 filler — the 2.20 marker pattern for runtime-generated regions. The cold-boot generator populates them at boot time.

What gets generated there depends on the slot scanner’s device-code table and the BIOS factory logic. Without dynamic execution, the runtime contents are not directly extractable.

Two cases to consider:

Case A — generator populates with real handler code. The 2.20 BIOS factory writes a routine into $DAC5+ that does the actual I/O for the device. For Pascal 1.0 cards, this routine signals the 6502 (via the cooperative-CPU sync — see Part 8) to perform JSR $Cn07 on the slot card. The 6502 enters the Videx ROM at $C307 without the V-flag preamble. The Videx executes:

$CB07: 18              CLC
$CB08: B8              CLV
$CB09: 50 2B           BVC ENTR     ; V=0, branch taken to $CB36
$CB36: 8D FF CF        STA $CFFF    ; deselect expansion ROM
$CB39: 48 ...          PHA / save state
$CB4A: 50 10           BVC IO       ; V still 0, branch taken
$CB5C: 90 6F           BCC BASOUT   ; C=0 (cleared at $CB07), branch taken
$CBCD: 68              PLA / start BASOUT body
... eventually JSR BASOUT1

BASOUT1 writes a character to the Videx VRAM. The Videx hardware needs to be initialized first — the 6845 CRTC needs registers programmed, the VRAM mode needs to be set, the timing has to be active. The naive entry through OUTENTR skips the BIT IORTS preamble at $CB00 that would normally trigger the CSW/KSW installer at $CB4C — the Pascal-1.0-style bootstrap step that programs the CRTC and sets up state. Without that init, BASOUT1 writes to an uninitialized VRAM and possibly polls a status bit that is never set. The 6502 is stuck inside the Videx ROM.

The 6502 doesn’t return, so it never sets $E000’s high bit. The Z-80 polls $E000 forever via the loop at $1E39. Both CPUs are alive; neither makes progress.

Case B — generator leaves $DAC5+ as $E5 filler. If the cold-boot generator skips populating this region (because device code 4 isn’t one of the codes the generator knows how to handle in the way needed for an unknown card), then CALL $DAC5 lands in $E5-filled memory. The Z-80 executes PUSH HL repeatedly. After ~256 pushes, the stack pointer wraps and starts overwriting other memory, including the BIOS state area and eventually the BDOS or CCP. The system loses coherence and either hangs in some loop or crashes silently.

Which case actually happens?

Without a Z-80 emulator running 2.20 with a Videx in slot 3, the runtime contents of $DAC5 are unknown. But:

  • Case A is consistent with Part 8’s narrative — the 6502 hung inside the Videx ROM.
  • Case B is consistent with the static byte pattern ($E5 fill in the cold-boot zone, no RET in the handler at $DFBE).
  • Both produce the observed symptom: the system stops responding after the cold-boot banner, no prompt appears, recovery requires power-cycle.

The difference between Case A and Case B is where the hang happens, not whether. In Case A, the 6502 is the stuck CPU; the Z-80 polls. In Case B, the Z-80 is the stuck CPU; the stack overflows; the 6502’s warm-boot loop continues (because warm-boot doesn’t depend on Z-80 progress) but no useful work happens.

What’s factually settled

  • 2.20’s slot scanner assigns device code 4 to a Videx (Pascal 1.0 ID match, no Pascal 1.1 detection).
  • 2.20’s BIOS dispatch table maps device code 4 to handler at $DFBE.
  • The handler at $DFBE is exactly 12 bytes, ends with CALL $DAC5, no RET.
  • $DAC5 is in the runtime-generator zone ($DA00-$DACB); statically $E5-filled.
  • The bytes at $DFCA (which would execute on fall-through) are D6 20 32 E5 E5 ...SUB $20, then E5 spam.
  • Videx ROM byte-trace from $C307 without V-flag preamble: documented end-to-end above; lands in BASOUT, which depends on Videx init that never ran.

What needs dynamic verification

  • The actual contents of $DAC5+ after the cold-boot generator runs.
  • Whether the generated handler hands off to the 6502 or executes inline on the Z-80.
  • Which of Case A vs Case B is the actual failure mode in practice.

Both cases are documented because both are plausible from the static evidence, and both produce the observed hang symptom. The investigation can fully verify by booting 2.20 on Joshua’s hardware (or in a Z-80 emulator) and reading memory at $DAC5.

Update (2026-05-01) — Case B settled

The emulator-driven 2.20 hang reproduction devlog ran the dispatch path for real. Result: Case B is what fires. $DFBE is pure $E5 fill, the Z-80 executes PUSH HL repeatedly, SP wraps from $0080 through $0000 to $FFFE, and high memory gets overwritten with the HL marker value. No 6502 hang in the Videx ROM (Case A); the Z-80 corrupts itself first.

Status

Trace settled to the boundary of static analysis. The dispatch path through $DFBE → CALL $DAC5 is concrete. The Videx ROM byte path on naive JSR $C307 entry is concrete. The link between them — whether $DAC5 triggers Case A (6502 hang in Videx) or Case B (Z-80 stack overflow on $E5 spam) — is the dynamic-verification gap. Either way, the system doesn’t reach A>. That’s why 2.20 hangs.

2.23 fixes this by detecting Pascal 1.1 in the slot scanner and routing through device code 6, which the BIOS factory generates a Pascal-1.1-aware handler for (using $Cn0D-$Cn10 vector table entries instead of naive JSR $Cn07). The fix avoids the broken path entirely.