Walking the 6502 RWTS the SoftCard borrowed from
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:
| Range | Content | Who reads it |
|---|---|---|
$0A00-$0A98 | WRITE_SECTOR + helper | 6502 |
$0A99-$0B02 | READ_DATA_FIELD | 6502 |
$0B03-$0B5E | READ_ADDR_FIELD | 6502 |
$0B5F-$0BBB | SEEK_TRACK | 6502 |
$0BBC-$0BD0 | STEP_DELAY | 6502 |
$0BD1-$0BE8 | phase-on/off delay tables | 6502 (SEEK_TRACK indexed lookup) |
$0BEE-$0C38 | LOAD_CPM_LOOP | 6502 |
$0C39-$0CFF | Z-80 BIOS init code | Z-80 (at $1C39-$1CFF) |
$0D00-$0DFF | GCR codec tables | 6502 data (decode $BD04, encode $BD5A) |
$0E00-$0E03 | padding | — |
$0E04-$0E10 | LOAD_CPM_PRIM_OUTER | 6502 |
$0E11-$0EBF | LOAD_CPM_PRIM | 6502 |
$0EC0-$0F49 | SECTOR_RW_RETRY | 6502 |
$0F4A-$0F52 | WRITE_SECTOR_CALL | 6502 |
$0F53-$0F5A | SEEK_RECAL | 6502 |
$0F5B-$0F84 | TRACK_STATE_SET | 6502 |
$0F85-$0F9D | TRACK_STATE_GET | 6502 |
$0F9E-$0FAD | CPM_SKEW_TABLE (16 bytes) | 6502 |
$0FAE-$0FD2 | SPLIT_BUFFER | 6502 |
$0FD3-$0FEA | MERGE_BUFFER | 6502 |
$0FEB-$0FFF | zero-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$FEfor an invalid GCR nibble.READ_DATA_FIELDreaches it viaEOR $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_SECTORreads it viaLDA $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:
- 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_SETandTRACK_STATE_GETswap which one’s active based on bit 7 of zero-page$35. - If the desired drive (
$03E4) differs from the current drive ($03E5), switch motors via$C089,X, wait for spin-up. - Read the address field.
JSR $BB03looks forD5 AA 96, decodes the 4-and-4 volume/track/sector/checksum into$002C-$002F, validates theDE AAepilog. - 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). - If the sector doesn’t match, retry. There are 48 retries (
LDY #$30at$0EC0) before declaring failure and falling back to recalibration viaJSR $BF85— which steps the head to track 0, re-seeks, and re-enters the read loop. - On match, read the data field.
JSR $BA99looks forD5 AA AD, decodes 342 GCR-encoded nibbles into 256 primary + 86 secondary bytes (at$0C00,Yand$0900,Yrespectively), validatesDE AA EB. - 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.