Part 2 decompiled a single .COM file by running it under the Z-80 emulator and watching which addresses executed. That works because a .COM file is self-contained: raw Z-80 code that CP/M loads at $0100 and jumps into, nothing else in the way. A floppy is not self-contained. It is a boot sector that the Disk II ROM runs cold, a multi-stage 6502 loader, an operating system parked on reserved tracks in a layout that belongs to neither processor cleanly, a file system, and twenty-odd utilities. The same decompiler points at all of that at once. Before any byte disassembles, it has to figure out where the bytes physically live on the disk.
One command, and what it has to know first
The front door is one verb:
python -m cpm_pipeline decompile-disk CPMV233.DSK out/
Before it disassembles a single opcode it has to answer three questions that a .COM file never raises. Is this even a SoftCard CP/M disk, and which of the three releases is it. What order do the sectors sit in on the physical track. And for every byte it decides is code, which processor runs it. Get any of those wrong and the disassembly is confident garbage. Scaling from one file to a whole disk is mostly the fingerprinting and the sector arithmetic; the disassembler from Part 2 rides on top unchanged.
Fingerprinting the image
detect reads the disk’s shape before it trusts anything downstream:
$ python -m cpm_pipeline detect CPMV233.DSK
CPMV233.DSK: DSK format, 143360 bytes (35T x 16S x 256B)
boot stub: count=1, loads 10 sectors of track 0
skew table: 0 2 4 6 8 A C E 1 3 5 7 9 B D F
variant: softcard_cpm_2_23 (confidence: high)
The boot stub is the 256-byte program in sector 0 that the Disk II ROM loads at power-on and runs. Its first job is to pull the rest of track 0 off the disk in a scrambled order, and that order, the skew table above, is sitting in its bytes. detect recognizes the stub shape, which is what confirms a SoftCard CP/M disk and not some other Apple image, reads the skew straight out of it, and then checks a signature deeper in the stage-2 loader to decide which release it is holding. The distribution is three releases, inventoried in Part 6. The release matters here because the BIOS lives at a different address in each and the loader scans sectors in a different order.
The fingerprinting confirms one more thing for free. The 2.23 disk ships as a .dsk (DOS 3.3 physical sector order) and the 2.20 disk as a .po (ProDOS order). Those are different interleaves on the physical track. But the boot stub at file offset 0 is byte-for-byte identical across both, and the bytes the stub loads to $1000 are identical too, starting AD 81 C0 AD 81 C0, which is LDA $C081 twice, an Apple bank soft-switch read. One program staged two ways. If detect read different stub bytes out of the two, the format handling would be wrong, and the byte arithmetic would produce clean-looking garbage.
The two skews, stacked
Reading a file off a CP/M floppy on an Apple means undoing two independent scramblings. Undo both or every file comes out shredded.
The first is CP/M’s own. CP/M numbers its sectors logically, 0 through 15 on a track, but it does not lay them down in that order on the medium. It interleaves them so that by the time the OS has finished processing sector N, sector N+1 is rotating under the head rather than already past it. For this disk the map is a clean three-sector skew: logical sector L lands at physical position (L * 3) % 16. Run it out and you get 0 3 6 9 12 15 2 5 8 11 14 1 4 7 10 13, which is the skew table detect printed, expressed the other direction.
The second scramble is the disk image format. A .dsk stores its sectors in DOS 3.3 physical order; a .po stores them in ProDOS order. That is a second permutation sitting underneath CP/M’s, between CP/M’s logical sector and the byte offset in the file on disk.
Rather than guess how those two compose, I derived it against ground truth. The directory of CPMV233.DSK is documented. CPM60.COM should be 88 records, and a CP/M record is 128 bytes, so 88 times 128 is 11,264 bytes. When the reader walked the directory and reported CPM60.COM at exactly 11,264 bytes, and PIP, MBASIC, and STAT all came out beginning with the JP instruction a CP/M program conventionally starts with, both skews were composed correctly. A wrong skew does not hand you a slightly-off file. It hands you a file assembled out of the wrong sectors entirely, and the size and the opening opcode are cheap, unforgiving checks.
list-files: walking the directory
Tracks 0 through 2 are the reserved system area. Track 3 and up are an ordinary CP/M 2.2 file system, and once the skew is solved list-files walks the directory the same way CP/M itself would:
$ python -m cpm_pipeline list-files CPMV233.DSK
It reads the directory entries, follows each file’s allocation blocks through the skew, and prints all twenty-two entries with their sizes: the Digital Research tools (ASM, DDT, ED, PIP, STAT, LOAD, SUBMIT), the Microsoft additions (MBASIC, GBASIC, CPM60, COPY, CAT), and the rest. Each one can then be handed to the per-file decompiler from Part 2.
decompile-os: the part that spans both CPUs
The operating system is the hard region, because it is not one program in one address space. It is 6502 code and Z-80 code interleaved across the reserved tracks, and decompile-os disassembles each region with the right disassembler at the right load address.
On the 6502 side: the boot loader, the RWTS (the routine that physically reads and writes sectors, with its GCR encode and decode tables), and the small install fragments the loader copies into place during boot. On the Z-80 side: the BIOS jump table and the console and disk primitives behind it, the thunks that bridge the Z-80’s disk requests over to the 6502’s RWTS, and the system image, CP/M’s CCP and BDOS themselves.
The load address of each region is fixed, and getting it wrong is where the older write-ups failed. The Z-80 does not see Apple addresses directly; the SoftCard remaps them. Z-80 $0000-$AFFF is Apple +$1000 (the contiguous TPA). Z-80 $B000-$DFFF is Apple $D000-$FFFF, the memory behind the I/O page. Z-80 $E000-$EFFF is Apple $C000-$CFFF, the I/O page and slot space. Z-80 $F000-$FFFF is Apple $0000-$0FFF, zero page and stack and the text screen. The 2.23 BIOS jump table lives at Z-80 $FA00, which is Apple $0A00; the 2.20 jump table lives at Z-80 $DA00, which is Apple $FA00 in the bankable RAM behind the I/O page. decompile-os has to load each BIOS at its true base or every operand it renders points at the wrong byte. An earlier model translated Z-80 to Apple addresses with a bit-12 XOR, and it placed the BIOS off by $B8, which made a clean jump table look like it landed mid-instruction. That disassembly was internally consistent and pointed at the wrong byte at every step. Loading at the real base, the 2.23 table reads as plain C3 D1 FE / C3 B8 FA / C3 10 FB, seventeen three-byte JP entries on true page boundaries.
Where those BIOS bytes come from retires a myth the overview series inherited. There is no runtime BIOS generator writing handlers from scratch. The entire 2.23 runtime BIOS arrives verbatim from track 2, file-sectors 13 down to 8, one page each into Z-80 $FA00 through $FF00. The 2.20 BIOS is six pages, $DA00 through $DF00. Five of them come verbatim from track 2 sectors 1 through 5 ascending into $DA00-$DE00; the sixth page, $DF00, is not read off track 2 at all but assembled during the 6502 boot phase. Cold boot then changes only a handful of bytes per page, a few up to around 130 in the last two pages, a sparse self-modification fixup pass over a BIOS that already exists. So decompile-os reads the BIOS straight off track 2, and the cold-boot pass is a separate, small diff on top.
Which CPU fetched the byte
Static analysis cannot settle which processor owns a given byte. The OS is 6502 and Z-80 code sharing one image, and the load address alone does not decide it. Full emulation decides it by observation. When the whole system boots in the emulator, every fetch is attributed to whichever CPU made it: a byte is code because it executed, and 6502 code because the 6502 is what executed it. That attribution is what lets decompile-os route each region to the correct disassembler without guessing. Settle it by static inference instead and you get the confident wrong answer the overview series describes.
It also lets you read the actual handoff in place. The CPU-handoff loop that flips between processors decompiles to real, legible 6502:
$03C0: AD 83 C0 LDA $C083 ; bank soft-switch, read twice
$03C3: AD 83 C0 LDA $C083
$03C6: 8D 00 C7 STA $C700 ; the bus flip: an access to slot 7 I/O space
$03C9: AD 81 C0 LDA $C081
$03CC: 20 36 0E JSR $0E36 ; restore A,X,Y,P, then RTS
The STA $C700 is the CPU switch. The 2.20 loader ships STA $C400 here and the stage-2 slot scanner patches the operand to the SoftCard’s actual slot at install time. That $C700 access is also the load-bearing event in the real bug: it is an access to another slot’s page, and it tears the $C800-$CFFF expansion-ROM window claim out from under the console firmware. The full mechanism is in the overview series (Two Right Parts, One Dead Machine) and the deep read is Part 5 of this series. The decompiler renders the switch as the ordinary store instruction it is, STA $C700, so reading the two releases side by side, the difference shows up in plain code.
Runtime disk reads, the same data path
Decompiling the whole disk means modeling its disk path well enough that the OS can run during decompilation, including its own file reads. All of 2.23’s sector I/O, boot-time and runtime alike, funnels through one primitive at $BE11, the same $BE11 sector-read contract documented in Emulator Part 3. Knowing that contract, the emulator services a sector request straight from the disk image, the same trick the per-file decompiler uses, scaled to the whole system. 2.20’s RWTS sits at different addresses and runs the real nibble-level path instead, one more reason the fingerprint in step one has to be right.
decompile-disk: the whole thing
decompile-disk chains all of it: fingerprint, decompile the OS across both CPUs, list the files, decompile a program you pick (or pass --select NAME). For the two releases studied to exhaustion it emits hand-annotated gold source; for any other SoftCard-shape disk it falls back to a clean machine disassembly with the jump tables and symbol tables already applied. Either way you get readable assembly for the boot loader, the RWTS, both BIOS variants, the disk thunks, the CCP and BDOS, and every utility on the disk.
And the whole pile reassembles byte-for-byte. That round-trip proves the decompilation is correct. It is also the next article, which holds the rule behind it: what you decompile is the disk exactly as shipped, every sector in its place.

Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.