RSSAmplifier

Wise Owl · Jun 16, 2026

Two Disassemblers, and Telling Code From Data

0
Sign in to vote or save

Brent Rector · Wise Owl

A decompiler turned a 1980 SoftCard CP/M floppy back into commented source that reassembles to the original disk byte for byte. The overview series (Two Tools and a Whole Distribution) said that decompiler exists. Underneath it sits a disassembler, and the decompiler is worth nothing if you can’t trust what the disassembler emits. Two checks make it trustworthy. The first is mechanical and quick. The second cost most of the time on this part of the project, and the rest of this series (decompiling a single running program, walking the whole file system, rebuilding three releases from one source tree) depends on both.

There are two disassemblers because the disk holds machine code for two processors. The 6502 inside the Apple ][ runs the boot sector, the slot-scanning code, the Disk II read/write routines, and a small island of 6502 service code that lives inside the BIOS pages. The Z-80 on Microsoft’s SoftCard runs CP/M proper: the CCP, the BDOS, the Z-80 side of the BIOS. Nothing on the disk marks where one instruction set stops and the other begins. The byte $C3 is a JMP absolute to a 6502 and a JP nn to a Z-80, and both take their operand bytes in the same order, so a long stretch decodes plausibly under either instruction set and means two different things. No flag, no header, no separator. One disassembler per instruction set, then: disasm6502/ and disasm_z80/. Picking which one decodes which bytes is itself part of the problem.

Stage one: the source has to reassemble to the original

A disassembler’s output has to correspond exactly to the program. If I’m going to read a screen of mnemonics and reason about a forty-five-year-old operating system from it, those mnemonics had better be the program and not a near-miss. The check is mechanical. Take the source the disassembler emitted, run it back through a real assembler, and compare the resulting bytes against the bytes you started from.

rebuilt  = assemble(asm_path)      # ca65 + ld65 for 6502, sjasmplus for Z-80
original = original_bin_path.read_bytes()
assert rebuilt == original

If they match, the source represents those bytes faithfully. If they don’t, the source is wrong somewhere and you want to find out now, not a week into reasoning from it. Getting to a clean pass took several tries. A data byte got rendered as an instruction that re-encodes to a slightly different opcode. A label landed half a byte off, so a relative branch computed a different displacement. An undocumented opcode came out that the assembler refuses to accept. You can’t see any of these by reading the output. The mnemonics are all legal, the addresses all ascend, and the hex in the comments matches the prose because both came from the same place. The defect only exists in the bytes the file emits when assembled, and only reassembling surfaces it.

So that comparison went in as a permanent regression test, one per source file, and stayed running for the rest of the project. Over weeks of hand-editing annotated sources, renaming labels, adding comments, splitting routines, it caught drift I’d never have caught by eye, and it never gives a false pass. The bytes either match or they don’t. Whatever caused the drift, the test hands me the offset of the first differing byte and I go look. That’s stage one, and it’s the part most people mean when they say “a disassembler.” With the round-trip check in place, stage one was straightforward. Stage two was not.

Stage two: round-trip cannot tell you what the bytes are

A run of bytes that round-trips perfectly can still be wrong about which bytes are instructions and which are data. Round-trip says nothing about that distinction. It’s the oldest problem in disassembly and the check does nothing for it.

The Disk II GCR encode table is the block of nibble values the 6502 read/write routines use to translate bytes for recording on the floppy. Those values sit right in the middle of the 6502 opcode space. Point a naive disassembler at them and it walks left to right and produces a tidy run of STX, LDA, LAX, CMP: real mnemonics with real encodings, and total nonsense, because the processor never executes a single one of them. They’re data. Reassemble that run and most of the bytes survive unchanged, so it passes stage one. The output reproduces the bytes and misclassifies them, and no byte carries a tag for its own type.

The fix is to stop decoding blindly and only call something code when there’s positive evidence for it. Instead of walking the binary front to back, the disassembler does recursive descent: it starts at the addresses control actually enters from and follows the program. For this disk those entry points are concrete. The BIOS jump table is the obvious set. For 2.23 it sits at Z-80 $FA00, seventeen JP entries deep:

$FA00:  C3 D1 FE   JP $FED1    ; BOOT
$FA03:  C3 B8 FA   JP $FAB8    ; WBOOT
$FA06:  C3 10 FB   JP $FB10    ; CONST
        ...

The reset vector at $1000 reads C3 00 FA (JP $FA00), landing on the BOOT entry, and the warm-boot rewrite turns it into C3 03 FA (JP $FA03), the WBOOT entry three bytes in. For 2.20 the same table is at $DA00 instead, because 2.20’s BIOS lives in a different SoftCard mapping window. Those bases come straight from post-boot memory, where Apple $0A00 (which the Z-80 addresses as $FA00) holds the seventeen-entry table. From each of those entries the walker follows: down each branch, into each CALL, across each JP. Every byte it reaches that way is code, because the program can reach it. It stops at the instructions that end a path (RET, JP (HL), HALT) and at undocumented opcodes, which essentially never appear in real code. Anything it never reaches is data, by construction. The GCR table is that: nothing branches to it, nothing calls it, nothing falls through into it, so the walker never marks it as code and it stays data. A byte is code only when the walker traces into it. That one rule is the difference between a disassembler you can trust and one that emits plausible mnemonics over data.

Keeping the walker and the data analyzer apart

Knowing a region is data is half the job. The other half is emitting it as the right directive, and that pass stays separate from the walker, because the two decide things differently. The walker is strict: it calls a byte code only when it has proof it traced into it. The data analyzer is probabilistic: given a run that is not code, it picks the most likely shape, a fill directive for a long constant run, a quoted string for printable bytes ending in a terminator, a pointer table for 16-bit values that aim at real code, a jump table for a run of JP opcodes. The analyzer is sometimes wrong, which is why it stays walled off. Fold the two passes together and you wreck both: the walker starts guessing and the analyzer’s fuzzy thresholds tangle into control-flow tracing. The walker’s “this byte is code because I traced into it” rule only stays sound while it makes no guesses.

One case taught me to keep them apart. A boot-time table of screen-line offsets, sixteen bytes of an even-then-odd interleave:

00 02 04 06 08 0A 0C 0E 01 03 05 07 09 0B 0D 0F

An early version of the data analyzer read the front of that as a pointer table, because the first 16-bit value, $0200, happens to be the Apple ][ input buffer (the line-input buffer GETLN fills, carried in the symbol table as IN), and the next few values were also in range. So it emitted .word IN, .word $0604, and so on. It round-tripped perfectly; the bytes the assembler produced were the same 00 02 04 06 the original had. The bytes were correct and the classification was fabricated: a pointer table aimed at addresses in the middle of routines that no pointer ever targets. Round-trip can’t catch this, because the bytes are correct either way.

The repair was to make the analyzer lean on the only hard evidence it has. A 16-bit value counts as a pointer-table entry only if it aims at a label the walker 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 beginning 00 02 is not a pointer just because $0200 happens to have a name. Each classifier earns its own threshold the same way. The jump-table detector runs looser, because a $C3 (JP) 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, and you keep that judgment out of the walker entirely.

A related structural problem shows up once byte-identity is enforced, and then shows up constantly: Z-80 code legitimately enters the middle of an instruction, so two paths can disagree about where one instruction ends. Naming those targets without breaking reassembly is its own piece of work, covered in Part 2, where the emulator’s execution trace supplies the entry points the walker starts from.

The three pieces

Two disassemblers, one per instruction set, because the disk mixes 6502 and Z-80 code with no boundary marked. A round-trip byte-identity check wired in as a permanent regression test, so the emitted source provably reassembles to the original program, and the same check doubles as a bug detector for every later stage. A strict recursive-descent walker that calls a byte code only when control flow reaches it, kept rigidly separate from a probabilistic data analyzer, so the GCR table stays a table and a screen-offset list stays a list. The rest of this series builds on those three. The code is in the Orchard repository: disasm6502/, disasm_z80/, and the shared disasm_common/analyzer.py. None of it is specific to CP/M or the SoftCard. It’s the foundation under decompiling one program by running it and taking the whole disk apart, where this series goes next.

Read the original on wiseowl.com

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.