RSSAmplifier

Wise Owl · Jun 16, 2026

One Source, Two Addresses: Single-Source Rebuilds and Diffing Versions

0
Sign in to vote or save

Brent Rector · Wise Owl

A SoftCard CP/M disk now reassembles from a clean, byte-identical source tree, built across the two disassemblers, decompiling one program by running it, and decompiling the whole disk. That tree does two things a disassembly listing cannot. It builds a relocating program image from one readable file, and it aligns two whole releases against each other to show which bytes carry a behavioral change. The build either reproduces the original bytes or it fails loudly. The diff compares those same bytes, not a description of them.

The problem a relocating image creates for a single source

A program that runs at the address where it loads is easy. Disassemble at the load org, every label resolves to where the bytes physically sit, reassemble, done. SoftCard CP/M is full of programs that do not do that.

GBASIC.COM is the cleanest case. It is a CP/M .COM, so the CCP loads it into the Transient Program Area at $0100 and jumps there. Its $0100 entry is just JP $1000. The real work is at $1000, which block-copies the Microsoft BASIC interpreter from where it sits in the file up to $3000 and runs it there:

LD HL,$6490
LD DE,$8482
LD BC,$5483
LDDR
JP $81D3

LDDR copies BC bytes from (HL) down to (DE), so this moves the span ending at file offset $6490 to the run span ending at $8482, then enters the interpreter at $81D3. Disassemble GBASIC.COM at its file org and that $100E-$6490 span is garbage. The bytes are Z-80 code, but they are Z-80 code assembled to run at $3000+, and at the file org every relative jump displacement and every absolute operand points to the wrong place. The first pass decompiled this span as a 21 KB data blob.

DISP: separate the load address from the run address

sjasmplus has a directive for this. DISP <addr> tells the assembler to keep emitting bytes contiguously at the current physical position (the file offset) while resolving every label and every absolute operand to <addr> and up. ENT ends the displaced region and snaps the label counter back to the physical position. So you write the interpreter once, at its real run address, and the bytes still land at the file offset the loader expects:

    ORG $0100
    ...
    DISP $3000
INTERP_RUN_START:
    ; the interpreter, assembled for $3000+
    ...
INTERP_RUN_TOP:
    ENT

The payoff is that the relocator’s own operands stop being magic numbers. $6490, $8482, and $5483 in that LDDR setup are not literals in the master source; they are derived from the labels that mark where the interpreter loads, where it runs, and where it ends:

INTERP_RELOC_LEN  EQU INTERP_RUN_TOP - INTERP_RUN_START   ; the LDDR count ($5483)
INTERP_LOAD_END   EQU INTERP_LOAD_START + INTERP_RELOC_LEN - 1   ; ($6490)
INTERP_RUN_END    EQU INTERP_RUN_TOP - 1                  ; ($8482)

If the interpreter’s size ever changed, the count and both end addresses track it. The source reassembles byte-identical to the original GBASIC.COM, and the master is one self-contained program instead of a stub followed by a wall of DEFB.

The harder case: one master, mixed CPUs, three run addresses

CPM60.COM is the Microsoft 60K-update installer, an 11,264-byte ($2C00) Z-80 .COM that carries an entire 60K CP/M system image inside itself and writes it to a disk’s system tracks. It mixes both relocation problems and adds a second CPU. The installer driver runs in place at $0100. Three operating-system modules are stored at fixed offsets inside the .COM but run high, up in the Language Card region: the CCP at $D300, the BDOS at $DC00, and the BIOS at $FA00. (That $FA00 BIOS base is the 2.23 layout, shared by both the 44K and 60K 2.23 systems; the earlier article on as-shipped sources covers why this is the genuine template and not a runtime snapshot.) And the boot loader, RWTS, and install fragments are 6502 code, not Z-80 code at all, so they cannot be Z-80 source.

The master handles each kind of piece with a directive matched to it.

The 6502 pieces are assembled separately by ca65 and pulled in as binaries with INCBIN, placed at their .COM file offsets:

    ORG $0500                    ; file $0400
    INCBIN "CPM_RWTS.bin", $0000, $05BD

The three Z-80 OS modules are INCLUDEd as real source, each wrapped in a MODULE and displaced to its run address:

    ORG $0F00                    ; file $0E00
    MODULE ccp
    DISP $D300
    INCLUDE "os/CPM_CCP.asm"
    ENT
    ENDMODULE

MODULE matters here because every one of these modules defines the same CP/M page-zero EQUs. WBOOT_VEC is $0000, BDOS_VEC is $0005, DEFAULT_DMA is $0080, and so on. CCP, BDOS, and BIOS each declare their own copies. Without namespacing, linking three modules that all define WBOOT_VEC into one assembly is a redefinition error. MODULE ccp / ENDMODULE puts each module’s labels in its own namespace, so the shared vectors and the per-module internal labels never collide.

There is one deliberate overlap the master relies on. The CCP file region ($0E00) and the BDOS file region ($1700) overlap by six bytes at $1700-$1705. Those six bytes are the shared serial number that ends the CCP and begins the BDOS. CCP is emitted first; BDOS then rewrites those same six bytes with the identical serial, so the bytes are correct either way and the overlap is harmless. The gaps between regions zero-fill, because SAVEBIN writes zeros for any byte in its window that nothing emitted.

IFNDEF: assemble standalone or link into the master

Each OS module needs to be buildable two ways: on its own (so you can work on the CCP in isolation, run the round-trip check on just that module, and reassemble it to the on-disk CCP) and linked into the CPM60.COM master. The difference is who owns the DEVICE, ORG, and SAVEBIN directives. Standalone, the module owns them. Linked, the master owns them, because the master is placing the module at a file offset with ORG and displacing it with DISP, and it is the master’s single SAVEBIN "CPM60.COM", $0100, $2C00 at the end that writes the whole image.

The guard is a define. The master does DEFINE CPM60_LINK up top, and each module brackets its own boilerplate behind IFNDEF CPM60_LINK:

    IFNDEF CPM60_LINK  ; master defines this and owns the directives; standalone keeps them
    DEVICE NOSLOT64K
    ENDIF
    ...
    IFNDEF CPM60_LINK
    ORG $D300
    ENDIF

Build CPM_CCP.asm by itself and CPM60_LINK is not defined, so the module sets its own DEVICE and ORG $D300 and produces the standalone CCP binary. INCLUDE it from the master, where CPM60_LINK is defined, and both blocks are skipped, leaving the master’s ORG $0F00 / DISP $D300 in control.

This guard pattern hid a real blocker until a verification pass caught it. Making the regeneration guard-aware left a dangling IFNDEF that silently skipped a whole module under the master link while still passing the standalone byte check. The standalone build looked fine and the linked image was quietly short a module. The fix was to normalize the guards and add a link-mode assemble to the regeneration’s own check, so both build modes get verified, not just the standalone one. A module that builds clean on its own and drops out under the link produces no warning and no error. The byte-identical round-trip on the linked image is what catches it.

Diffing two versions down to the bytes

The second thing the source tree buys you is alignment. The SoftCard distribution is three releases: CP/M 2.23 in a 44K layout, 2.23 in a 60K layout, and 2.20. The question for the SoftCard investigation was always what changed between 2.20 and 2.23. The pipeline’s diff verb runs detect, trace-z80, and handoff on each disk and compares the structured results field by field:

DiskDelta: CPM220Disk1.po (A)  vs  CPMV233.DSK (B)
  variant: A=softcard_cpm_2_20, B=softcard_cpm_2_23 (DIFFERENT)
  Z-80 reset: A=JP $DA00  B=JP $FA00 (DIFFER)
  BDOS entry: A=JP $CC06  B=JP $9C06 (DIFFER)
  CPU-switch: A=JSR $C400  B=JSR $0E36 (DIFFERENT mechanism)
  cold-boot dispatch cases:
    A: [3, 4]
    B: [3, 4, 6]
    only in B: [6]

Two of these are layout consequences. The BIOS jump table is at $DA00 in 2.20 and $FA00 in 2.23, which is why the reset vectors differ, and the BDOS entries differ for the same reason. The CPU-switch line is shown here at the 6502 code that reaches the switch; the actual switch is a write to the SoftCard’s slot page, and which slot page that is comes from the install-time patch the earlier article on as-shipped sources traces.

The only in B: [6] line points at the Videoterm fix. The overview series (The Answer That Agreed With Itself, Two Right Parts, One Dead Machine) walked back an early version of this mechanism once, so the scope of the device-6 case matters. It is the detection half of the fix and nothing more. The full detection delta is an 11-byte check that reads $Cn0B for $01, classifying a Pascal 1.1 firmware card as device 6 instead of the device 4 (Pascal 1.0) bucket that 2.20 puts it in. The Videx Videoterm supports Pascal 1.0, so 2.20 does install a console path for it. What 2.20 cannot do is enter the firmware correctly across the SoftCard’s CPU switch.

The second half is the one that hangs the Apple II, and it never appears as a dispatch case. It appears as the changed CPU-switch line, and in the two console paths the decompiler produces, where each version claims the shared $C800-$CFFF expansion-ROM window before entering the firmware. Which device owns that window, and what tears the claim down, is the subject of the Videoterm model in The Emulator series. The two decompiled console paths differ in timing. 2.20 stakes its claim on the Z-80 side, before the bus flip, and the flip’s own access undoes it, so the 6502 enters the fixed Pascal 1.0 entries ($C800 INIT, $C84D READ, $C9AA WRITE) with the window unowned. 2.23 makes the claim on the 6502 side, after the flip: $CFFF deselect at $0E30, $C330 claim at $0E33, then the $Cn0D-$Cn10 vector dispatch, so the claim survives to the fetch. The A2FPGA re-creation runs the kill sequence both ways. 2.20 plus a Videoterm produces 38 million window faults and zero characters on screen; 2.23 produces zero faults and a full directory listing. The precise electrical trigger on a physical Videoterm board is still open, though a real board does hang on 2.20.

The diff gives you the changed bytes in seconds and the decompiled console paths show you where they sit. It does not give you the mechanism; that took the emulator. But a byte-grounded list of differences plus two readable console routines is a far better place to start a day’s work than two raw disk images. The overview series used this alignment in Two Tools and a Whole Distribution, and the final part of this series uses it to rebuild all three releases from source.

Read the original on wiseowl.com

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.