#!/usr/bin/env python3 """Pass 1 tracer for a banked PRG image (MMC3 and friends), seeded from a Mesen CDL. Why this exists --------------- `nesdis.py` traces one flat CPU window. That is fine for NROM and for the 32 KB window of a simple UxROM game, but it is wrong for a cartridge whose PRG is larger than the 6502 can address: the same CPU address holds different bytes depending on which bank the mapper has selected, so a flat trace both loses code and invents addresses. This tool runs the same recursive tracer per *bank* of the cartridge, giving each bank the CPU window it is actually visible at, and it seeds every bank from a Mesen Code Data Logger file so that code the static flow analysis cannot reach (computed dispatch, banked calls) is still disassembled. The CDL is the oracle for *where* code is; the tracer is the oracle for *instruction boundaries and control flow*. Both are needed: the CDL alone does not say where an instruction starts, and the tracer alone cannot see through a bank switch. Inputs ------ --prg FILE the PRG image (file order) --cdl FILE a Mesen CDL file (CDLv2) or a raw PRG-sized CDL image --project NAME name used in the file header --out FILE the ca65 source to write --banks SPEC comma list of FILEOFF:CPUBASE, one per bank, in file order. e.g. 0:8000,2000:A000,4000:C000 --prefix SPEC comma list of FILEOFF:PREFIX; PREFIX is prepended to every label the bank emits. Use it when two banks share a CPU window: without it the labels collide. --symbols FILE symbols.inc-style hardware register names Output ------ A single `.segment "PRG"` source holding the whole PRG image in file order, with one section per bank. Code is emitted as instructions with the repo's audit comment (`; CPU:$AAAA PRG:BBBB BB BB`), everything else as `.byte` blocks that keep their ROM provenance. The file assembles back to the exact bytes of `prg.bin`. """ import argparse import re import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import nesdis # noqa: E402 def load_cdl(path): """Return a PRG-sized CDL image (one byte per PRG byte).""" raw = Path(path).read_bytes() if raw[:5] == b"CDLv2": # 9-byte header, then one byte per PRG byte (and one per CHR byte). # The payload is as long as the ROM's PRG; a 128 KB UNROM image is # not 64 KB, so do not truncate it. return bytearray(raw[9:]) if len(raw) >= 16384: return bytearray(raw) raise SystemExit(f"error: {path} is neither a CDLv2 file nor a PRG-sized image") def cdl_runs(cdl, start, length): """Contiguous runs of CDL 'code' bytes inside [start, start+length).""" runs = [] i = start end = start + length while i < end: if cdl[i] & 1: j = i while j < end and cdl[j] & 1: j += 1 runs.append((i, j - i)) i = j else: i += 1 return runs def trace_bank(img, cdl, off, base, rounds=12): """Trace one bank, seeded from the CDL, and keep seeding until every byte the CDL saw as code is covered. A CDL run can hold several routines: the tracer follows the flow from the run's first byte and may stop long before the run's end, because the rest is reached through a jump table or an indirect call. Every uncovered stretch of a CDL run therefore becomes a new seed and the trace is redone, until nothing is left uncovered. The CDL seeds are queued *first*, because they are the authority on where an instruction starts: a fall-through decode that spans a byte the CPU really fetched an opcode at is the wrong decode, and the tracer has to be allowed to overwrite it. `nesdis.emit_code` drops such a spanning decode afterwards. """ size = len(img) runs = cdl_runs(cdl, off, size) seeds = [base + (a - off) for a, _ in runs] # Mesen also marks the first byte of every called routine (bit 3), but only # where the byte is code: a byte the CPU read as data is not an entry. for i in range(size): if (cdl[off + i] & 0x09) == 0x09: addr = base + i if addr not in seeds: seeds.append(addr) result = None blocked = set() branch_targets = set() for _ in range(rounds): # A branch target is always code, so it can never be blocked. blocked -= branch_targets insns, invalid, indirect, targets, overlap = nesdis.trace( img, base, seeds, blocked) # The CDL is the authority: a decode the CPU never fetched is wrong. for k in list(insns): if cdl_contradicts(cdl, off, k): blocked.add(k) # A call into a routine that never returns leaves data behind it. for d in dead_fallthroughs(insns, base): blocked.add(d) branch_targets = set() for ins in insns.values(): if ins.mnemonic in nesdis.BRANCHES and ins.target is not None: branch_targets.add(ins.target - base) if ins.mnemonic in ("jsr", "jmp") and ins.jsr_target is not None: branch_targets.add(ins.jsr_target - base) covered = set() for k, ins in insns.items(): for j in range(k, k + ins.length): covered.add(off + j) extra = [] for a, length in runs: i = a while i < a + length: if i in covered: i += 1 continue addr = base + (i - off) if addr not in seeds: extra.append(addr) while i < a + length and i not in covered: i += 1 if not extra: result = (insns, invalid, indirect, targets, overlap, runs, seeds) break seeds.extend(extra) if result is None: result = (insns, invalid, indirect, targets, overlap, runs, seeds) return result def cdl_contradicts(cdl, off, offset): """True when the CDL says the byte at this offset was read as data. The tracer follows a `jsr` into the table that follows it, because the routine it called may be a dispatcher that jumps elsewhere and never returns. The CPU never fetched those bytes as opcodes, and the CDL says so: the byte carries the data bit and not the code bit. Such a decode is wrong and has to go. """ b = cdl[off + offset] return bool(b & 0x02) and not (b & 0x01) def dead_fallthroughs(insns, base): """Offsets of instructions that sit behind a `jsr` whose target never returns to them. A `jsr` pushes the address of the next instruction, but the routine it calls may end in a `jmp` (a shared exit) or in a dispatcher that jumps somewhere else entirely - the game uses that trick a lot, with a jump table right behind the call. The fall-through decode is then a decode of data, and the repo's rule is not to seed past a `rts`/`jmp`/`brk`. """ dead = [] for off, insn in insns.items(): if insn.mnemonic != "jsr": continue target = insn.jsr_target if target is None: continue cur = target - base seen = set() term = None while cur in insns and cur not in seen: seen.add(cur) inner = insns[cur] if inner.mnemonic in ("jmp", "rti", "brk", "rts"): term = inner.mnemonic break cur += inner.length if term in ("jmp", "rti", "brk"): dead.append(off + insn.length) return dead def main(): ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--prg", required=True) ap.add_argument("--cdl", required=True) ap.add_argument("--project", required=True) ap.add_argument("--out", required=True) ap.add_argument("--banks", required=True, help="comma list of FILEOFF:CPUBASE, in file order") ap.add_argument("--prefix", default="", help="comma list of FILEOFF:LABELPREFIX") ap.add_argument("--symbols", default=None) ap.add_argument("--label", action="append", default=[], metavar="FILEOFF:CPUADDR", help="force a label to be emitted at CPUADDR in the bank that " "starts at FILEOFF; used for a branch target that lives in " "another bank") ap.add_argument("--undocumented", action="store_true") args = ap.parse_args() prg = Path(args.prg).read_bytes() cdl = load_cdl(args.cdl) banks = [] for tok in args.banks.split(","): tok = tok.strip() if not tok: continue off, _, rest = tok.partition(":") base, _, size = rest.partition(":") banks.append((int(off, 16), int(base, 16), int(size, 16) if size else None)) if not banks: raise SystemExit("error: --banks is empty") prefixes = {} for tok in args.prefix.split(","): tok = tok.strip() if not tok: continue off, _, pre = tok.partition(":") prefixes[int(off, 16)] = pre forced_labels = {} for spec in args.label: off, _, addr = spec.partition(":") forced_labels.setdefault(int(off, 16), set()).add(int(addr, 16)) if args.symbols and Path(args.symbols).is_file(): n = nesdis.set_hardware(args.symbols) print(f"symbols : {n} hardware register names from {args.symbols}") nesdis.set_undocumented(args.undocumented) header = [] header.append(f"; {args.project} NES disassembly - Pass 1") header.append("; Byte-oriented ca65 source generated from the supplied PRG image.") header.append("; Recursively reachable instructions are emitted explicitly with address/byte comments.") header.append("; Unreached bytes remain explicit .byte blocks; no ROM byte is deleted or reformatted.") header.append("; Pass 2 replaces address-derived labels, RAM aliases, and table names while") header.append("; preserving byte identity.") header.append(";") header.append("; The PRG image is bank switched, so every bank is traced separately and is") header.append("; given the CPU window it is visible at. Labels carry a bank prefix when two") header.append("; banks share a window.") header.append(";") header.append("; Pass 1 trace provenance:") header.append("; Code/data split taken from a Mesen Code Data Logger capture; the") header.append("; instruction boundaries and control flow come from the recursive tracer,") header.append("; seeded at every entry the CDL observed.") body = [] total_code = 0 label_list = [] for index, (off, base, bank_size) in enumerate(banks): # A bank is 8 KB unless the spec says otherwise (UNROM banks are 16 KB). # The next bank's file offset is the authority when the size is omitted, # so a spec written as FILEOFF:CPUBASE still works. if bank_size is None: if index + 1 < len(banks): bank_size = banks[index + 1][0] - off else: bank_size = 0x2000 size = min(bank_size, len(prg) - off) img = prg[off:off + size] (insns, invalid, indirect, targets, overlap, runs, seeds) = trace_bank(img, cdl, off, base) # A branch in another bank can target an address in this one. The # reference is already spelled `Label_XXXX`; the definition has to # exist here or the file will not assemble. targets |= forced_labels.get(off, set()) covered = set() for k, ins in insns.items(): for j in range(k, k + ins.length): covered.add(off + j) missed = [] for a, length in runs: for j in range(a, a + length): if j not in covered: missed.append(a) break code_bytes = sum(i.length for i in insns.values()) total_code += code_bytes pre = prefixes.get(off, "") header.append(";") header.append(f"; ---- bank at file ${off:04X}-${off + size - 1:04X}, " f"CPU ${base:04X}-${base + size - 1:04X}" + (f", label prefix '{pre}'" if pre else "") + " ----") header.append(f"; {len(seeds)} CDL code runs, {code_bytes} bytes of code, " f"{len(insns)} instructions") if missed: header.append(f"; {len(missed)} CDL code runs not reached by the tracer " "(left as data): " + ", ".join(f"${m:04X}" for m in missed[:12])) if indirect: header.append("; indirect dispatch through zero page (not followed statically):") for addr, ptr in indirect: header.append(f"; jmp (${ptr:04X}) at ${addr:04X}") sub = [] nesdis.emit_code(img, base, insns, targets, sub, args.project, {}, nesdis.build_refs(img, base, insns, nesdis.scan_orphans(img, base, insns, 1)), {}, {}) # Every bank gets its own segment, and the segment is addressed at the # CPU window the bank is visible at. The linker puts the bytes at the # bank's file offset, so the file image is byte identical while the # assembler still knows the CPU address of every label - which is what # makes `jsr SomeRoutine` assemble to the right bytes. body.append(f'.segment "PRG{index}"') body.append(f".org ${base:04X}") body.append("") # The emitter numbers PRG offsets from the start of the slice it was # given; re-base them onto the whole PRG image, so the audit # comment's PRG field is the offset of the byte in prg.bin - the # same meaning it has in every other project. An image larger than # 64 KB needs a five-digit field. for line in sub: line = re.sub( r"PRG:([0-9A-F]{4})", lambda m: f"PRG:{off + int(m.group(1), 16):05X}", line) if pre: line = re.sub(r"\bLabel_([0-9A-F]{4})\b", lambda m: f"{pre}_Label_{m.group(1)}", line) body.append(line) for t in sorted(targets): label_list.append((off, base, t, pre)) out = header + [ "", '.setcpu "6502"', "", '.include "symbols.inc"', '.include "locals.inc"', "", ] + body Path(args.out).write_text("\n".join(out) + "\n") print(f"PRG : {len(prg)} bytes in {len(banks)} banks") print(f"code : {total_code} bytes ({total_code * 100.0 / len(prg):.1f}%)") print(f"labels : {len(label_list)}") for off, base, t, pre in label_list: print(f" ${off:04X} CPU:${t:04X} {pre + '_' if pre else ''}Label_{t:04X}") if __name__ == "__main__": main()