The whole SoftCard CP/M investigation came down to reading machine code. A 1980 operating system would not boot when a particular video card was installed, and the only way to find out why was to read the boot code one instruction at a time. That code runs on two processors at once: the 6502 inside the Apple ][ itself, and the Z-80 on Microsoft’s SoftCard. I needed readable, trustworthy assembly source for both, and I had no tool that would give me that.
So I wrote two disassemblers, one for the 6502 and one for the Z-80. A disassembler is the reverse of an assembler: it takes the raw bytes of a program and turns them back into the assembly instructions a person can read. Pointing one at a binary and getting a screenful of mnemonics is the easy part. Getting source I could actually trust, source that reads like something a person wrote and means what it says, took much longer. It came in two stages, and the second was far harder than the first.
Stage one: source that reassembles to the original
The first thing a disassembler owes you is honesty about the bytes. If I am going to read its output and reason about a 45-year-old program from it, that output had better correspond exactly to the program. There is a simple way to check: take the source the disassembler produced, run it back through a real assembler, and compare the bytes that come out against the bytes you started with. If they match, the source is a faithful representation of the program. If they do not, the source is wrong somewhere, and you want to know before you spend a week reasoning from it.
rebuilt = assemble(asm_path) # ca65 + ld65, or sjasmplus
original = original_bin_path.read_bytes()
assert rebuilt == original
Getting there took a few tries. Early output looked perfectly fine and still failed this check: a data byte rendered as an instruction that re-encodes to a slightly different opcode, a label placed half a byte off so a branch offset shifted, an undocumented opcode the assembler refuses to accept. None of these are visible by reading. The mnemonics are all valid, the addresses all ascend, and the bytes printed in the comments match the prose, because I copied them from the same place. The error is in the bytes the file emits when assembled, and nothing but reassembly will surface it.
I wired that comparison in as a permanent regression test, one per source file, and left it running for the rest of the project. It kept earning its place. Over weeks of hand-editing the annotated sources, it caught drift I would never have seen by eye, and it never gives a false pass: the bytes either match or they do not. The check does not care why they drifted. It just hands me the offset of the first difference and lets me go look.
That is stage one, and it is the part most people mean by “a disassembler.” It is also the part that turned out to be straightforward once the round-trip check was in place. The hard part was everything after it.
Stage two: telling code from data
Reassembling correctly is necessary but nowhere near sufficient, and this is where the real work was.
A run of bytes that round-trips perfectly can still be a complete fiction as a reading. The bytes of a lookup table will cheerfully decode into a sequence of valid-looking instructions, reassemble to the exact same bytes, and pass the round-trip test, while telling you a confident lie about what the program does. The processor never executes those “instructions.” They are data. But nothing about the bytes themselves announces that.
The Apple Disk II’s GCR encode table is the textbook case. It is a block of nibble values the disk routines use to translate bytes for recording, and those values land squarely in the middle of the 6502 opcode space. A naive disassembler reads them left to right and produces a tidy run of STX, LDA, LAX, CMP and friends: real mnemonics, real encodings, total nonsense. Reassemble it and most of the bytes survive, so it would have sailed through stage one. It is not wrong about the bytes. It is wrong about what they are.
The fix is to stop decoding blindly and only call something code when there is a reason to. Instead of walking the binary byte by byte from the front, the disassembler starts at the addresses control actually enters from (the BIOS jump-table entries, the JMP $1000 the boot stub ends on, and so on) and follows the program: down each branch, into each call, across each jump. Every byte it reaches that way is code, because the program can actually run it. It stops at the instructions that end a path (RTS, a jump through a register, HALT) and at undocumented opcodes, which almost never appear in real code. Anything it never reaches is, by construction, data. No guessing.
That single decision is what separates a disassembler you can trust from one that merely produces plausible output. The GCR table at the heart of the RWTS is exactly where it pays off: nothing jumps to it, nothing calls it, nothing falls through into it, so the walker never marks it as code, and the table stays data.
Rendering the data so it reads
Knowing a region is data is only half of it. I still have to show it, and a sixteen-byte blob of hex is barely more readable than the binary. So a second pass walks each run of data and makes a best guess at its shape: a long run of one value becomes a fill directive, a run of printable characters ending in a zero becomes a quoted string, a run of 16-bit values that point at real code becomes a pointer table, a run of JP instructions becomes a jump table. Done well, the structure reads like what it is.
Keeping this in a separate pass from the code walker matters, and it took me a false start to see why. The two passes know things in different ways. The walker is strict: it calls a byte code only when it has positive proof, namely that it traced into it. The data analyzer is probabilistic: it picks the most likely formatting for a run, with confidence thresholds, and it is sometimes wrong. Fold the two together and you ruin both. The walker starts guessing, and the analyzer’s fuzzy thresholds get tangled up in control-flow tracing.
Here is the false start. The 2.23 boot loader has a little table of screen-line offsets, sixteen bytes of an even-then-odd interleave a display routine uses to compute addresses:
00 02 04 06 08 0A 0C 0E 01 03 05 07 09 0B 0D 0F
My first data analyzer read the front of that as a pointer table, because the first 16-bit value, $0200, happens to be the Apple ][ keyboard input buffer (IN), a name it found in its symbol table. The next few values were also in range. So it emitted .word IN, .word $0604, and so on, and it round-tripped: the bytes the assembler produced were the same 00 02 04 06 the original had. The bytes were right. The meaning was a lie. A reader would walk away believing there is a pointer table aimed at a set of nonsensical mid-routine addresses. This is the worst kind of mistake, because round-trip cannot catch it for you; the bytes are correct either way.
The lesson I took from that one sentence sums up the whole stage: round-trip checks the bytes, not the interpretation. So I made the analyzer lean on the one source of real evidence it has. A 16-bit value only counts as a pointer-table entry if it points at a label the walker actually discovered by tracing control flow into it, not merely a name that turns up in the symbol table. Symbol tables are full of data labels and round numbers; a byte run starting 00 02 has no business being read as a pointer just because $0200 has a name. Every classifier earns its own threshold this way: the jump-table detector can be looser, because a $4C (JMP) opcode prefix is strong evidence on its own; the string detector demands both a printable run and a terminator. You tighten each criterion until the false positives are rare next to the real hits.
The labels that will not sit still
One structural problem deserves its own note, because you only hit it once byte-identity is enforced and then you hit it constantly. Z-80 code is full of shared tails and bytes that do double duty, so two control-flow paths can legitimately disagree about where one instruction ends and the next begins. A call can target an address that sits inside another instruction. The CP/M 2.23 BIOS does exactly that: a CALL $FB45 enters a routine that begins RLCA, while the surrounding code reads those same bytes as a JR NZ whose operand byte is the $FB45. The tracing handles both readings fine. The trouble is the formatter, because an assembler will not let you put a label in the middle of an instruction, and forcing one in splits the instruction so the bytes no longer reassemble.
My first fix was to define the address as a bare constant, SOME_ENTRY EQU $FB45, and write CALL SOME_ENTRY at the use site. It round-trips, because the assembler resolves the constant back to $FB45. But it asserts an address out of nowhere and says nothing about why that address cannot hold a label, and it gives identical treatment to two situations that are not alike. One is a real shared tail, where the program enters the middle of an instruction on purpose. The other is the walker having decoded a stretch of data as code, so its instruction boundaries are fiction and the mid-instruction reference is an artifact of that mistake. A bare equate makes both look deliberate.
So the address now gets named relative to the instruction it lands inside. The covering instruction takes a label of its own, and the target reads L_FB44+1, the second byte of the instruction at $FB44. The assembler evaluates that to the same $FB45, so the bytes are identical, but the source now shows what is going on. For an anonymous target the expression goes straight at the call site as CALL L_FB44+1, with no separate definition. A target that already has a meaningful name keeps it, written the same relative way, so a BIOS routine whose entry the cold-boot code patches into the middle of another routine still reads as that routine rather than a raw address.
The two cases also finally get told apart. The formatter recognizes the few deliberate idioms, the 6502 BIT skip, the Z-80 LD-immediate skip, and the shared instruction tail, and accepts those as genuine. Anything else that lands mid-instruction, where the covering bytes are not a recognized idiom and the target was never reached as code, it marks as a suspected misframe instead of dressing it up as an intentional reference. That flag is usually pointing at a data run the walker wrongly traced into, which is a classification bug to fix at the source.
What I would keep
If I built this again, three things would go in from the first commit rather than get bolted on later.
The round-trip check comes first, before a single line of annotation. Discovering all your drift at once, weeks in, is miserable; discovering each new bit of drift one commit after you cause it is painless. And it has to be cheap to add a file to the suite, two lines at most, or you will skip it for the “small” files, and the small files are the ones that quietly drift.
When a region is genuinely hard to render as source, pull the bytes straight from the original binary with an .incbin and put the explanation in a comment above it, rather than hand-transcribing a long run and hoping you got it right. That is exactly what .incbin is for, and it keeps round-trip honest without turning you into a human byte-copier.
And keep the strict walker and the probabilistic analyzer as two separate passes. The walker’s only job is “this byte is code, because I traced into it, or unknown, because I did not.” The analyzer’s only job is “given a run that is not code, here is my best guess at what kind of data it is.” Mix them and you lose the one guarantee that makes the whole thing trustworthy.
Where the code is
Both disassemblers live in the Orchard repository: disasm6502/ for the 6502, disasm_z80/ for the Z-80, and disasm_common/analyzer.py for the shared data-classification pass. None of it is specific to CP/M or the SoftCard; the same machinery works on any 6502 or Z-80 binary you want to turn into source you can read and reassemble. It is the foundation the rest of the CP/M reconstruction pipeline is built on, and the only reason I could read enough of that 1980 boot code to find the bug at all.
Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.