A CP/M .COM file is raw Z-80 bytes with no marker for code versus data. The hardest one on a SoftCard disk to take apart is CPM60.COM, the relocatable image of the 60K CP/M system. The verb that does it is decompile-file. Run the program first and every byte the processor fetches an instruction from is code you didn’t have to guess at.
Part 1 built the two disassemblers and laid out the code-versus-data problem. This part gives that problem a file and a fix: run the program before you disassemble it, so execution marks the code bytes for you.
I was reading 1980 machine code at all because a Videx Videoterm re-creation reproduced a real boot failure, 2.20 hangs and 2.23 boots (The Card That Wouldn’t Boot CP/M), and reading the recovered source of both versions side by side is how the real cause surfaced (Two Right Parts, One Dead Machine). This article is the tool that recovered that source.
A CP/M .COM is the simplest executable format there has ever been. No header, no relocation table, no segment list, no entry-point field. The file is raw Z-80 machine code, CP/M reads it into memory starting at $0100 (the Transient Program Area, the TPA, the block of RAM where every CP/M program runs), and jumps to $0100. That is the entire loader contract. The first byte of the file is the first instruction executed.
That suits the operating system fine and leaves a disassembler with nothing, because the file hands you no map. You get a run of bytes and you have to decide, for each one, whether it is the start of an instruction, the middle of an instruction, or data that no instruction ever decodes. Get that wrong by one byte and everything after it disassembles into garbage until the stream happens to re-sync. On the Z-80 the re-sync can take a while, because instructions are one to four bytes and the prefix bytes $DD, $ED, $FD, $CB shift the meaning of the byte that follows.
Where static disassembly goes blind
The disassembler from Part 1 is recursive-descent. You give it a seed address, it decodes the instruction there, marks those bytes as code, and follows the control flow: fall through to the next instruction, branch to a target, call a subroutine and continue. It keeps going until it hits something terminal. The stop list from the Z-80 walker:
if cf == ControlFlow.JUMP_ABS: # JP nn / JR e (unconditional)
return
if cf == ControlFlow.RET:
return
if cf == ControlFlow.INDIRECT: # JP (HL) / JP (IX) / JP (IY)
return
if cf == ControlFlow.HALT:
return
Two of those are honest dead ends. After an unconditional JP $1234 the bytes that physically follow are not reached by falling through, so the walker stops and waits to arrive there some other way (a branch, a call, a seed). After RET the same. Those stops are correct and the walker resumes wherever else control actually lands.
INDIRECT is the blind spot. JP (HL) is opcode $E9, a single byte, and it jumps to whatever address is currently in HL. The disassembler has no idea what that is, because the value of HL is a runtime value, computed by instructions that ran before this one, often indexed into a table. So the walker does the only safe thing: it stops. Everything reachable only through that computed jump is now invisible to static analysis. It never gets seeded, so it never gets decoded, so it falls into the “data” bucket by default and comes out as a DEFB run of hex.
This pattern is everywhere. Dispatch tables are how you write a command interpreter, a BDOS-style function multiplexer, a tokenized BASIC. CPM60.COM is full of them. Disassemble it cold from $0100 only and large stretches of real code, the bodies the dispatch tables jump to, sink into data directives that happen to reassemble correctly but explain nothing.
The cheat - run it first
The disassembler does not know the runtime values that decide where computed jumps land. So compute them. Run the program. Every address the processor fetches an instruction from is code, and you do not have to deduce it because you watched it happen.
decompile_com.py loads the extracted .COM into a Z-80 core (the same nibbler core the SoftCard emulator uses, so I am not maintaining two Z-80 implementations) and single-steps it, recording the start address of every instruction. The harness is small because CP/M’s runtime contract is small. It plants just enough of page zero to keep a normal program happy:
cpu.mem[TPA:end] = com # load .COM at $0100
cpu.mem[0x0000] = 0xC3 # JP WBOOT_SENTINEL (warm boot)
cpu.mem[0x0001] = WBOOT_SENTINEL & 0xFF
cpu.mem[0x0002] = WBOOT_SENTINEL >> 8
cpu.mem[0x0005] = 0xC3 # JP $E400, so LD HL,(6) yields a memtop
cpu.mem[0x0006] = 0x00
cpu.mem[0x0007] = 0xE4
cpu.mem[WBOOT_SENTINEL] = 0x76 # HALT
cpu.pc = TPA
cpu.sp = 0xE000
Three things to notice in those bytes. The JP at $0000 is the warm-boot vector; many CP/M programs exit by jumping to $0000 rather than calling a BDOS function, so I point that vector at a sentinel address that holds a HALT ($76). When the program “reboots” to exit, it lands on the HALT and the harness stops cleanly. The vector at $0005 is the BDOS entry every program calls; I point it at a high address so that the common idiom LD HL,($0006) (read the address just past BDOS to find the top of usable memory) returns something sane instead of zero. And SP gets a plausible value, though most programs set their own stack in the first few instructions.
The BDOS itself is a shim, not an implementation. When PC reaches $0005 the harness reads the function number out of register C, records it, and fakes the simplest possible success:
if pc == BDOS:
fn = cpu.c
tr.bdos_calls.append(fn)
if fn in BDOS_EXIT: # function 0 = P_TERMCPM
break
cpu.a = 0; cpu.l = 0; cpu.h = 0
ret = cpu.read16(cpu.sp) # pop return address
cpu.sp = (cpu.sp + 2) & 0xFFFF
cpu.pc = ret # and RET to the caller
continue
It does not print, it does not read a real character, it does not open a file. It returns A=0 to everything and returns to the caller, except function 0, which means “terminate,” and stops the run. The recorded bdos_calls list is a free byproduct: at the end you can report which BDOS functions the program used, which is a decent thumbnail of what it does (function 9 prints a string, function 2 prints a char, function 1 reads one, function 15 opens a file).
The run is bounded two ways. There is a hard ceiling of two million instructions, and there is a stall detector: if the trace goes 100,000 steps without discovering a single new instruction address, it declares itself converged and stops. That second limit decides which programs decompile well, and the next section is about it.
What the run gives you, and what it does not
When the program exits or converges, the trace holds a set of addresses that definitely executed. Those become disassembler seeds, unioned with the entry point:
tr = trace_com(com)
entries = sorted({TPA} | tr.executed)
asm_path = _disassemble(com, entries, out_base, source_name=name)
Now the recursive-descent walker starts from dozens or hundreds of confirmed-code addresses instead of one. The body that JP (HL) jumped to has its first instruction in tr.executed, because the processor really fetched it, so it is a seed, so the walker decodes it and follows its control flow outward like any other routine. The computed-jump blind spot is filled in exactly where execution went. The static pass still does the heavy lifting of following ordinary branches and calls from each seed, so you get more code than the run alone touched, but the seeds are what aim it at the right places.
This is partial, and the limit decides which programs decompile well. A single run follows only the paths that run’s inputs take. The harness supplies no keystrokes and no files. So a batch-style utility that does its work and exits, something like STAT or LOAD or a dump tool, runs to completion under the shim and you get nearly all of it as explained code. An interactive program is the opposite case. Point the harness at MBASIC or ED and within a few hundred instructions it has finished its banner and sat down to wait for a keystroke the shim will never send. It polls the console forever, discovering nothing new, and the stall detector trips at converged. You captured the startup path and nothing past the input prompt, so the command-handling bulk of the program comes back as data.
That is the expected result, not a failure. The fallback when emulation finds little (or nothing, if a program blocks on hardware the shim does not model) is a static disassembly seeded at $0100, and the output still reassembles to the original bytes either way. A DEFB run of the right hex is as faithful as a decoded instruction; it is only less explained. The byte-for-byte rebuild check, which is the spine of this whole project and the subject of Part 4 and beyond, does not care whether a region came out as instructions or as data, only that it reassembles. So the emulation pass never risks correctness, it only ever adds explanation.
CPM60.COM is the file I most wanted this technique for, because it is not an ordinary transient program. It is the relocatable image of the 60K CP/M system, the thing that, when run, rearranges memory and brings up the 60K layout of CP/M 2.23. Statically it is a maze: dispatch through JP (HL), blocks that are relocated to a different address before they execute, and stretches of pure data (the relocation bitmap, the system image it carries) sitting in the same file as the code that processes them. Decompile it cold and you cannot tell the relocation tables from the relocator.
Run it, and the relocator executes, the dispatch lands, and the addresses that mattered show up in tr.executed. The seeds aim the disassembler at the relocation loop and the setup code; the data blocks it carries stay data, correctly, because nothing ever fetched an instruction from them. You end up with the active code explained and the payload left as the data it is, which is the right split. The whole 60K system rebuilds byte-exact from one master source, which is the subject of Part 5; here CPM60.COM is the best demonstration that emulation-seeding earns its keep on the hardest single file on the disk.
One thing connects to a correction in the overview series. The early wrong answer that The Answer That Agreed With Itself takes apart came partly from reasoning about code paths on a memory map that was itself wrong, and emulation-assisted decompilation can fall into the same trap: run the program under a mismatched model of the machine and every “this byte executed” record you collect describes the wrong machine. The seeds are only as trustworthy as the emulator. For a plain .COM running in the flat TPA the model is trivial and safe. The exposure lives in the whole-system case, where the SoftCard’s Z-80-to-Apple address translation has to be modeled exactly, and that is where the wrong map did its damage. For a single transient program, page zero plus a BDOS shim is the entire contract, and there is very little to get wrong.
Running it
python -m cpm_pipeline decompile-file CPMV223-60K.DSK CPM60.COM out_cpm60
It extracts the file from the CP/M directory, runs it under the harness, seeds the Z-80 disassembler with every executed address, and writes commented source that reassembles to the original bytes. The summary tells you whether emulation contributed (emulation-assisted) or found nothing (static), how many instructions ran, how many distinct addresses executed, why the run stopped (bdos-exit, warm-boot, converged, unsupported-opcode), and which BDOS functions the program called. Add --ai for plain-language [AI]-tagged comments on each routine; because they are only comments, the source still reassembles identically.
That is one program. Part 3 does the same at scale: walk the CP/M file system, decompile every program on the disk, and bring the same emulation-seeded code-versus-data split to all of them at once.
Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.