Walking the 6502 RWTS the SoftCard borrowed from

5 min read
6502cpmsoftcardapple-iireverse-engineeringretrocomputingcpm-videx-series

Earlier entries traced the boot pipeline by following control flow: the boot stub at $0801, the staged LOAD_CPM call that pulls 29 sectors into $8000-$9CFF, the PREP_HANDOFF copies. What got skipped was the quality of the 6502 disk routines that the cooperative-CPU model later reaches into. This entry is the disassembly. It nails down what each sector of the RWTS actually contains, where the Z-80 BIOS code is interleaved with 6502 code, and where the GCR codec tables sit.

What the boot stub loads at $0A00-$0FFF

The boot stub iterates from track 0 in CP/M sector skew, reading sectors 2, 4, 6, 8, A, C into Apple pages $0A00, $0B00, $0C00, $0D00, $0E00, $0F00 in turn. That gives 1536 bytes (six 256-byte pages) of mixed content. Treating the whole region as 6502 code from byte one almost works — the first two pages and most of the last two pages are clean 6502 — but two pages and a chunk of the third are not.

The actual layout, from a fresh disassembly:

RangeContentWho reads it
$0A00-$0A98WRITE_SECTOR + helper6502
$0A99-$0B02READ_DATA_FIELD6502
$0B03-$0B5EREAD_ADDR_FIELD6502
$0B5F-$0BBBSEEK_TRACK6502
$0BBC-$0BD0STEP_DELAY6502
$0BD1-$0BE8phase-on/off delay tables6502 (SEEK_TRACK indexed lookup)
$0BEE-$0C38LOAD_CPM_LOOP6502
$0C39-$0CFFZ-80 BIOS init codeZ-80 (at $1C39-$1CFF)
$0D00-$0DFFGCR codec tables6502 data (decode $BD04, encode $BD5A)
$0E00-$0E03padding
$0E04-$0E10LOAD_CPM_PRIM_OUTER6502
$0E11-$0EBFLOAD_CPM_PRIM6502
$0EC0-$0F49SECTOR_RW_RETRY6502
$0F4A-$0F52WRITE_SECTOR_CALL6502
$0F53-$0F5ASEEK_RECAL6502
$0F5B-$0F84TRACK_STATE_SET6502
$0F85-$0F9DTRACK_STATE_GET6502
$0F9E-$0FADCPM_SKEW_TABLE (16 bytes)6502
$0FAE-$0FD2SPLIT_BUFFER6502
$0FD3-$0FEAMERGE_BUFFER6502
$0FEB-$0FFFzero-padded

Two 6502 code blocks ($0A00-$0C38 and $0E04-$0FEA), one Z-80 code block in the middle ($0C39-$0CFF), one 256-byte GCR data page ($0D00-$0DFF). Six sectors, not all of them homogeneous.

The PREP_HANDOFF relocation

When the 6502 calls JSR $BA90, JSR $BA8F, JSR $BB03, JSR $BE11, those targets don’t exist yet at boot time — the disassembled code references the post-relocation addresses. PREP_HANDOFF #1 is the step that makes them real: the 6502 copies $0A00-$0FFF to $BA00-$BFFF (1536 bytes into LC RAM). After that copy:

  • $0A00 -> $BA00 (WRITE_SECTOR)
  • $0A8F -> $BA8F (WRITE_BYTE_4US)
  • $0A99 -> $BA99 (READ_DATA_FIELD)
  • $0B03 -> $BB03 (READ_ADDR_FIELD)
  • $0B5F -> $BB5F (SEEK_TRACK)
  • $0BBC -> $BBBC (STEP_DELAY)
  • $0BD1 -> $BBD1 (phase-on delay table)
  • $0BDD -> $BBDD (phase-off delay table)
  • $0D04 -> $BD04 (GCR decode table)
  • $0D5A -> $BD5A (GCR encode table)
  • $0E11 -> $BE11 (LOAD_CPM_PRIM)
  • $0F4A -> $BF4A (WRITE_SECTOR_CALL)
  • $0FAE -> $BFAE (SPLIT_BUFFER)
  • $0FD3 -> $BFD3 (MERGE_BUFFER)

The cooperative-CPU disk callbacks (the Z-80 thunks at $1A00-$1BFF after PREP_HANDOFF #2) reach into LC RAM here whenever they need to do disk I/O. The 6502 RWTS isn’t called via JSR from Z-80 — the callback layer signals via $E000/$E010, the 6502 polls, and then JSRs into LC RAM at one of these $BA*-$BF* entry points.

The GCR codec living between two code blocks

The most striking layout choice is page $0D00. It’s not code at all — it’s the standard Apple Disk II 6-and-2 GCR translation tables:

  • $0D04-$0E03: 256-byte decode table. Indexed by an 8-bit nibble read from the disk; produces a 6-bit value (0-63), or $FE for an invalid GCR nibble. READ_DATA_FIELD reaches it via EOR $BD04,Y.
  • $0D5A-$0D9D: 64-byte encode table. The 64 valid GCR nibbles in order: 96 97 9A 9B 9D 9E 9F A6 A7 AB AC AD AE AF B2 B3 B4 B5 B6 B7 B9 BA BB BC BD BE BF CB CD CE CF D3 D6 D7 D9 DA DB DC DD DE DF E5 E6 E7 E9 EA EB EC ED EE EF F2 F3 F4 F5 F6 F7 F9 FA FB FC FD FE FF. WRITE_SECTOR reads it via LDA $BD5A,X.

That the encode table lives inside the decode-table page (offset $5A rather than $00) is a small but typical SoftCard trick — two distinct lookups packed into 256 bytes that nominally hold one. The boot stub doesn’t need to know the difference; it just loads the page.

The Z-80 sees the same 256 bytes mapped at $1D00-$1DFF via the SoftCard’s bit-12 XOR. As far as I can tell, no Z-80 code reaches into this page; the Z-80 BIOS first 1 KB at $1C00-$1FFF uses page $1C for code and pages $1D-$1F for either data or 6502-only territory. The interleave is asymmetric.

The Z-80 code at $0C39-$0CFF

The 199 bytes after LOAD_CPM_LOOP are not 6502. As 6502 they decode into illegal opcodes and absurd branches. As Z-80, they decode cleanly:

$0C39: AF              XOR A
$0C3A: 32 DD FE        LD ($FEDD),A
$0C3D: 3E 02           LD A,$02
$0C3F: 21 DA FE        LD HL,$FEDA
$0C42: 77              LD (HL),A
$0C43: 23              INC HL
$0C44: 77              LD (HL),A
$0C45: 23              INC HL
$0C46: 77              LD (HL),A
$0C47: 18 48           JR +$48

This is BIOS state initialization — writing zeros to $FEDD, planting $02 into the 3-byte block at $FEDA. The full disassembly is the subject of CPM223_BIOS.asm; what matters here is that between the two 6502 code blocks lives 199 bytes of Z-80 BIOS init that the 6502 must skip past. The boot stub doesn’t enforce that skip — the 6502 PC just never reaches $0C39 because LOAD_CPM_LOOP ends with RTS at $0C38, and the next 6502 entry point is $0E04, jumped to indirectly via JSR $BE11 from $BFAE and friends after relocation.

What LOAD_CPM_PRIM actually does

The LOAD_CPM_PRIM routine at $0E11 ($BE11 after relocation) is the part the cooperative-CPU model leans on hardest. It’s invoked by LOAD_CPM_LOOP once per sector during the 29-sector boot read, and by the Z-80-side disk callbacks on every CP/M file operation thereafter. Its job is the per-sector dance:

  1. Set up drive-state for slot 6 (or whichever slot’s the SoftCard primary). Per-slot screen-hole tracks live at $0478,Y (drive 1) and $04F8,Y (drive 2); TRACK_STATE_SET and TRACK_STATE_GET swap which one’s active based on bit 7 of zero-page $35.
  2. If the desired drive ($03E4) differs from the current drive ($03E5), switch motors via $C089,X, wait for spin-up.
  3. Read the address field. JSR $BB03 looks for D5 AA 96, decodes the 4-and-4 volume/track/sector/checksum into $002C-$002F, validates the DE AA epilog.
  4. Compare the decoded sector against the desired CP/M sector via the skew table at $BF9E,Y (where Y is the logical CP/M sector number 0-15).
  5. If the sector doesn’t match, retry. There are 48 retries (LDY #$30 at $0EC0) before declaring failure and falling back to recalibration via JSR $BF85 — which steps the head to track 0, re-seeks, and re-enters the read loop.
  6. On match, read the data field. JSR $BA99 looks for D5 AA AD, decodes 342 GCR-encoded nibbles into 256 primary + 86 secondary bytes (at $0C00,Y and $0900,Y respectively), validates DE AA EB.
  7. Call MERGE_BUFFER (JSR $BFD3) to reconstruct the original 256-byte sector at ($3E/$3F).

MERGE_BUFFER is the inverse of SPLIT_BUFFER. The pair packs/unpacks 8-bit data into the 6+2 form GCR needs:

$0FD3: LDY #$00
$0FD5: LDX #$56
$0FD7: DEX
$0FD8: BMI $0FD5         ; X wraps from 0 to $FF, then BMI catches and reloads
$0FDA: LDA $0900,Y
$0FDD: LSR $0C00,X       ; shift one of the secondary buffer's 2-bit pairs into carry
$0FE0: ROL                ; rotate into A
$0FE1: LSR $0C00,X       ; shift the other pair
$0FE4: ROL                ; rotate into A
$0FE5: STA ($3E),Y
$0FE7: INY
$0FE8: BNE $0FD7

Three 2-bit pairs packed per byte of $0900, three primary bytes per pass, 256 bytes total reconstructed. Standard 6-and-2.

SPLIT_BUFFER at $0FAE is the write-side counterpart. It uses a BIT-instruction trick at $0FBB (2C A2 AA) to give the routine two entry points — one starts with LDX #$AC, the other with LDX #$AA — because the BIT $AAA2 opcode is three bytes that also contain a valid LDX #$AA. Two routines, one set of bytes, no jumping. Classic.

What’s settled

Every byte of $0A00-$0FFF is now classified. The annotated source lives in docs/CPM223_RWTS.asm (43 KB, 669 lines). The disk-sector map at docs/CPM_DiskSectorMap.md gets a corresponding precision boost: the rows for track 0 sectors $02, $04, $06, $08, $0A, $0C can now name the routine each one carries.

What this doesn’t settle

This entry is purely 6502. The Z-80 BIOS first 1 KB at $1C00-$1FFF — bytes that overlap the same physical RAM as the GCR data page and the 6502 RWTS extension block — still wants its own walkthrough, and the relationship between the boot-stub-loaded BIOS first 1 KB and the runtime-generated BIOS at $FA00-$FFFF (per Part 7) deserves a separate trace. That’s a future devlog.

For now: the 6502 side of the SoftCard’s disk machinery is fully mapped from byte to behavior.