#!/usr/bin/env python3 """Recursive-trace NES PRG disassembler that emits ca65 source (Pass 1). Classifies every PRG byte as reachable code or data by recursive descent from the RESET/NMI/IRQ vectors, then writes a byte-identical ca65 file where each instruction carries a trailing `; CPU:$xxxx PRG:xxxx AA BB` audit comment and every unreached byte stays as an explicit `.byte` line (never deleted). Usage: nesdis.py --prg prg.bin --base 0x8000 --project "MUSCLE (U)" \ --out prg.s --locals locals.inc [--pass2] """ import argparse import re import sys from collections import OrderedDict from pathlib import Path # --------------------------------------------------------------------------- # Official NMOS 6502 opcode table: opcode -> (mnemonic, addressing mode) # --------------------------------------------------------------------------- IMP, ACC, IMM, ZP, ZPX, ZPY, ABS, ABSX, ABSY, IND, INDX, INDY, REL = range(13) OPCODES = { 0x00: ("brk", IMP), 0x01: ("ora", INDX), 0x05: ("ora", ZP), 0x06: ("asl", ZP), 0x08: ("php", IMP), 0x09: ("ora", IMM), 0x0A: ("asl", ACC), 0x0D: ("ora", ABS), 0x0E: ("asl", ABS), 0x10: ("bpl", REL), 0x11: ("ora", INDY), 0x15: ("ora", ZPX), 0x16: ("asl", ZPX), 0x18: ("clc", IMP), 0x19: ("ora", ABSY), 0x1D: ("ora", ABSX), 0x1E: ("asl", ABSX), 0x20: ("jsr", ABS), 0x21: ("and", INDX), 0x24: ("bit", ZP), 0x25: ("and", ZP), 0x26: ("rol", ZP), 0x28: ("plp", IMP), 0x29: ("and", IMM), 0x2A: ("rol", ACC), 0x2C: ("bit", ABS), 0x2D: ("and", ABS), 0x2E: ("rol", ABS), 0x30: ("bmi", REL), 0x31: ("and", INDY), 0x35: ("and", ZPX), 0x36: ("rol", ZPX), 0x38: ("sec", IMP), 0x39: ("and", ABSY), 0x3D: ("and", ABSX), 0x3E: ("rol", ABSX), 0x40: ("rti", IMP), 0x41: ("eor", INDX), 0x45: ("eor", ZP), 0x46: ("lsr", ZP), 0x48: ("pha", IMP), 0x49: ("eor", IMM), 0x4A: ("lsr", ACC), 0x4C: ("jmp", ABS), 0x4D: ("eor", ABS), 0x4E: ("lsr", ABS), 0x50: ("bvc", REL), 0x51: ("eor", INDY), 0x55: ("eor", ZPX), 0x56: ("lsr", ZPX), 0x58: ("cli", IMP), 0x59: ("eor", ABSY), 0x5D: ("eor", ABSX), 0x5E: ("lsr", ABSX), 0x60: ("rts", IMP), 0x61: ("adc", INDX), 0x65: ("adc", ZP), 0x66: ("ror", ZP), 0x68: ("pla", IMP), 0x69: ("adc", IMM), 0x6A: ("ror", ACC), 0x6C: ("jmp", IND), 0x6D: ("adc", ABS), 0x6E: ("ror", ABS), 0x70: ("bvs", REL), 0x71: ("adc", INDY), 0x75: ("adc", ZPX), 0x76: ("ror", ZPX), 0x78: ("sei", IMP), 0x79: ("adc", ABSY), 0x7D: ("adc", ABSX), 0x7E: ("ror", ABSX), 0x81: ("sta", INDX), 0x84: ("sty", ZP), 0x85: ("sta", ZP), 0x86: ("stx", ZP), 0x88: ("dey", IMP), 0x8A: ("txa", IMP), 0x8C: ("sty", ABS), 0x8D: ("sta", ABS), 0x8E: ("stx", ABS), 0x90: ("bcc", REL), 0x91: ("sta", INDY), 0x94: ("sty", ZPX), 0x95: ("sta", ZPX), 0x96: ("stx", ZPY), 0x98: ("tya", IMP), 0x99: ("sta", ABSY), 0x9A: ("txs", IMP), 0x9D: ("sta", ABSX), 0xA0: ("ldy", IMM), 0xA1: ("lda", INDX), 0xA2: ("ldx", IMM), 0xA4: ("ldy", ZP), 0xA5: ("lda", ZP), 0xA6: ("ldx", ZP), 0xA8: ("tay", IMP), 0xA9: ("lda", IMM), 0xAA: ("tax", IMP), 0xAC: ("ldy", ABS), 0xAD: ("lda", ABS), 0xAE: ("ldx", ABS), 0xB0: ("bcs", REL), 0xB1: ("lda", INDY), 0xB4: ("ldy", ZPX), 0xB5: ("lda", ZPX), 0xB6: ("ldx", ZPY), 0xB8: ("clv", IMP), 0xB9: ("lda", ABSY), 0xBA: ("tsx", IMP), 0xBC: ("ldy", ABSX), 0xBD: ("lda", ABSX), 0xBE: ("ldx", ABSY), 0xC0: ("cpy", IMM), 0xC1: ("cmp", INDX), 0xC4: ("cpy", ZP), 0xC5: ("cmp", ZP), 0xC6: ("dec", ZP), 0xC8: ("iny", IMP), 0xC9: ("cmp", IMM), 0xCA: ("dex", IMP), 0xCC: ("cpy", ABS), 0xCD: ("cmp", ABS), 0xCE: ("dec", ABS), 0xD0: ("bne", REL), 0xD1: ("cmp", INDY), 0xD5: ("cmp", ZPX), 0xD6: ("dec", ZPX), 0xD8: ("cld", IMP), 0xD9: ("cmp", ABSY), 0xDD: ("cmp", ABSX), 0xDE: ("dec", ABSX), 0xE0: ("cpx", IMM), 0xE1: ("sbc", INDX), 0xE4: ("cpx", ZP), 0xE5: ("sbc", ZP), 0xE6: ("inc", ZP), 0xE8: ("inx", IMP), 0xE9: ("sbc", IMM), 0xEA: ("nop", IMP), 0xEC: ("cpx", ABS), 0xED: ("sbc", ABS), 0xEE: ("inc", ABS), 0xF0: ("beq", REL), 0xF1: ("sbc", INDY), 0xF5: ("sbc", ZPX), 0xF6: ("inc", ZPX), 0xF8: ("sed", IMP), 0xF9: ("sbc", ABSY), 0xFD: ("sbc", ABSX), 0xFE: ("inc", ABSX), } MODE_LEN = {IMP: 1, ACC: 1, IMM: 2, ZP: 2, ZPX: 2, ZPY: 2, REL: 2, ABS: 3, ABSX: 3, ABSY: 3, IND: 3, INDX: 2, INDY: 2} # --------------------------------------------------------------------------- # Undocumented NMOS opcodes. # # Real 6502 code uses these as multi-byte NOP padding and as genuine # read-modify-write / accumulator ops, so a tracer that only knows the 151 # official opcodes halts the moment it steps on one. Wild Gunman and the Joypad # Test Cartridge both stop this way. # # OFF by default (`--undocumented` turns it on) so that projects whose prg.s is # already regen-stable keep reproducing byte-for-byte. # --------------------------------------------------------------------------- UNDOC_OPCODES = { # NOP padding, implied (1 byte) 0x1A: ("nop", IMP), 0x3A: ("nop", IMP), 0x5A: ("nop", IMP), 0x7A: ("nop", IMP), 0xDA: ("nop", IMP), 0xFA: ("nop", IMP), # NOP padding, immediate (2 bytes) 0x42: ("nop", IMM), 0x80: ("nop", IMM), 0x82: ("nop", IMM), 0x89: ("nop", IMM), 0xC2: ("nop", IMM), 0xE2: ("nop", IMM), # NOP padding, zero page (2 bytes) 0x04: ("nop", ZP), 0x44: ("nop", ZP), 0x64: ("nop", ZP), # NOP padding, zero page,X (2 bytes) 0x14: ("nop", ZPX), 0x34: ("nop", ZPX), 0x54: ("nop", ZPX), 0x74: ("nop", ZPX), 0xD4: ("nop", ZPX), 0xF4: ("nop", ZPX), # NOP padding, absolute (3 bytes) 0x0C: ("nop", ABS), # NOP padding, absolute,X (3 bytes) 0x1C: ("nop", ABSX), 0x3C: ("nop", ABSX), 0x5C: ("nop", ABSX), 0x7C: ("nop", ABSX), 0xDC: ("nop", ABSX), 0xFC: ("nop", ABSX), 0xFA: ("nop", ABSX), # SLO = ASL + ORA 0x03: ("slo", INDX), 0x07: ("slo", ZP), 0x0F: ("slo", ABS), 0x13: ("slo", INDY), 0x17: ("slo", ZPY), 0x1B: ("slo", ABSY), 0x1F: ("slo", ABSX), # RLA = ROL + AND 0x23: ("rla", INDX), 0x27: ("rla", ZP), 0x2F: ("rla", ABS), 0x33: ("rla", INDY), 0x37: ("rla", ZPY), 0x3B: ("rla", ABSY), 0x3F: ("rla", ABSX), # SRE = LSR + EOR 0x43: ("sre", INDX), 0x47: ("sre", ZP), 0x4F: ("sre", ABS), 0x53: ("sre", INDY), 0x57: ("sre", ZPY), 0x5B: ("sre", ABSY), 0x5F: ("sre", ABSX), # RRA = ROR + ADC 0x63: ("rra", INDX), 0x67: ("rra", ZP), 0x6F: ("rra", ABS), 0x72: ("rra", ABS), 0x73: ("rra", INDY), 0x77: ("rra", ZPY), 0x7B: ("rra", ABSY), 0x7F: ("rra", ABSX), # SAX = store A & X 0x83: ("sax", INDX), 0x87: ("sax", ZP), 0x8F: ("sax", ABS), 0x97: ("sax", ZPY), # LAX = load A and X 0xA3: ("lax", INDX), 0xA7: ("lax", ZP), 0xAB: ("lax", IMM), 0xAF: ("lax", ABS), 0xB3: ("lax", INDY), 0xB7: ("lax", ZPY), 0xBF: ("lax", ABSY), # DCP = DEC then CMP 0xC3: ("dcp", INDX), 0xC7: ("dcp", ZP), 0xCF: ("dcp", ABS), 0xD3: ("dcp", INDY), 0xD7: ("dcp", ZPY), 0xDB: ("dcp", ABSY), 0xDF: ("dcp", ABSX), # ISC = INC then SBC 0xE3: ("isc", INDX), 0xE7: ("isc", ZP), 0xEF: ("isc", ABS), 0xF3: ("isc", INDY), 0xF7: ("isc", ZPY), 0xFB: ("isc", ABSY), 0xFF: ("isc", ABSX), # Unstable/shift-combination opcodes 0x0B: ("anc", IMM), 0x2B: ("anc", IMM), 0x4B: ("alr", IMM), 0x6B: ("arr", IMM), 0x8B: ("xaa", IMM), 0xAB: ("lax", IMM), 0xCB: ("axs", IMM), 0xEB: ("sbc", IMM), 0xBB: ("las", ABSY), 0x93: ("sha", INDY), 0x9F: ("sha", ABSY), 0x9B: ("tas", ABSY), } _TABLE = [OPCODES] def set_undocumented(on): """Enable/disable the undocumented opcode table. Default: off.""" _TABLE[0] = ({**OPCODES, **UNDOC_OPCODES} if on else OPCODES) # Instructions after which control never falls through. TERMINATORS = {"jmp", "rts", "rti", "brk"} # Mnemonics ca65 cannot encode. V2.19 implements none of the undocumented # opcodes, so these must be emitted as explicit `.byte` values instead. CA65_UNSUPPORTED = { "slo", "rla", "sre", "rra", "sax", "lax", "dcp", "isc", "anc", "alr", "arr", "axs", "xaa", "las", "tas", "sha", "shx", "shy", } # `nop` is officially supported (0xEA) but not with an operand. CA65_UNSUPPORTED_MODE = {"nop"} BRANCHES = {"bpl", "bmi", "bvc", "bvs", "bcc", "bcs", "bne", "beq"} class Insn: """One decoded 6502 instruction.""" def __init__(self, addr, length, text): self.addr = addr self.length = length self.text = text # rendered operand text (without mnemonic) self.opcode = None self.mnemonic = None self.mode = None self.target = None # branch/jump destination (CPU address) self.jsr_target = None # for jsr/jmp abs: subroutine/entry address self.value = None # absolute operand address, when there is one def hex16(v): return f"${v:04X}" # Hardware register names, keyed by address. Only unambiguous entries in the # $2000-$4017 register window are used: an address that carries two names # ($4017 is both JOY2 and APUFRAMECOUNTER) has to stay numeric, because the # name would be a lie. HARDWARE = {} # The register window. Addresses outside it are RAM or ROM, not registers; # the controller bit masks in symbols.inc (PAD_A and friends) are constants, # not addresses, and must never be substituted. HARDWARE_LO = 0x2000 HARDWARE_HI = 0x4017 def set_hardware(path): """Load the hardware register names from a symbols.inc-style file. Returns the number of names loaded. An address that more than one name claims is skipped: the source keeps the numeric operand there and the Pass 3 `apply_symbols.py` reports it as multi-alias residue. """ global HARDWARE HARDWARE = {} names = {} for raw in Path(path).read_text().splitlines(): line = raw.split(";", 1)[0].strip() if not line or "=" not in line: continue name, _, value = line.partition("=") name = name.strip() value = value.strip() if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name): continue if not re.fullmatch(r"\$[0-9A-Fa-f]+", value): continue addr = int(value[1:], 16) if not (HARDWARE_LO <= addr <= HARDWARE_HI): continue names.setdefault(addr, []).append(name) for addr, owners in names.items(): if len(owners) == 1: HARDWARE[addr] = owners[0] return len(HARDWARE) def _hw(value): """The hardware register name for an address, or None.""" return HARDWARE.get(value) def render(prg, base, opcode, pc): """Decode one instruction at pc. Returns an Insn or None when illegal.""" if pc >= len(prg): return None entry = _TABLE[0].get(prg[pc]) if entry is None: return None mnemonic, mode = entry length = MODE_LEN[mode] if pc + length > len(prg): return None b1 = prg[pc + 1] if length > 1 else 0 b2 = prg[pc + 2] if length > 2 else 0 insn = Insn(pc + base, length, "") insn.opcode = prg[pc] insn.mnemonic = mnemonic insn.mode = mode if mode == IMP: insn.text = "" elif mode == ACC: insn.text = "a" elif mode == IMM: insn.text = f"#${b1:02X}" elif mode == ZP: insn.text = f"Zp{b1:02X}" elif mode == ZPX: insn.text = f"Zp{b1:02X},x" elif mode == ZPY: insn.text = f"Zp{b1:02X},y" elif mode == ABS: v = b1 | (b2 << 8) name = _hw(v) insn.value = v insn.text = name if name else f"{'a:' if b2 == 0 else ''}{hex16(v)}" elif mode == ABSX: # ca65 silently rewrites `,x` on a $00xx operand as zero page,X, which # emits a different opcode. The `a:` prefix forces the absolute,X form # so the bytes match the ROM. v = b1 | (b2 << 8) name = _hw(v) insn.value = v insn.text = (f"{name},x" if name else f"{'a:' if b2 == 0 else ''}{hex16(v)},x") elif mode == ABSY: v = b1 | (b2 << 8) name = _hw(v) insn.value = v insn.text = (f"{name},y" if name else f"{'a:' if b2 == 0 else ''}{hex16(v)},y") elif mode == IND: # IND (jmp ($xxxx)) has no zero-page variant, so no 'a:' prefix needed v = b1 | (b2 << 8) name = _hw(v) insn.value = v insn.text = f"({name})" if name else f"({hex16(v)})" elif mode == INDX: insn.text = f"(Zp{b1:02X},x)" elif mode == INDY: insn.text = f"(Zp{b1:02X}),y" elif mode == REL: offset = b1 - 256 if b1 > 127 else b1 insn.target = base + pc + 2 + offset insn.text = f"Label_{insn.target:04X}" return insn def trace(prg, base, seeds, blocked=None): """Recursive descent from seeds. Returns (insns, invalid, indirect, targets, overlap). `blocked` is an optional set of offsets the fall-through must stop at. It is used for a `jsr` whose target never returns: the bytes behind the call are a table, not code, and decoding them would put labels on data. """ insns = {} targets = set(seeds) invalid = [] indirect = [] overlap = [] queue = list(seeds) blocked = blocked or set() while queue: addr = queue.pop(0) if addr in insns: continue off = addr - base if off < 0 or off >= len(prg): continue cur = off while True: if cur in insns: break if cur < 0 or cur >= len(prg): break if cur in blocked: break insn = render(prg, base, prg[cur], cur) if insn is None: invalid.append(base + cur) break clash = [c for c in range(cur + 1, cur + insn.length) if c in insns] if clash: overlap.append((base + cur, insn.mnemonic, [base + c for c in clash])) insns[cur] = insn # Follow control transfers. if insn.mnemonic in BRANCHES: t = insn.target if base <= t < base + len(prg): targets.add(t) queue.append(t) elif insn.mnemonic == "jsr": t = int(insn.text.replace("a:", "")[1:], 16) insn.jsr_target = t if base <= t < base + len(prg): targets.add(t) queue.append(t) elif insn.mnemonic == "jmp": if insn.mode == ABS: t = int(insn.text.replace("a:", "")[1:], 16) insn.jsr_target = t if base <= t < base + len(prg): targets.add(t) queue.append(t) break # unconditional: no fall-through else: ptr = int(insn.text.strip("()").replace("a:", "")[1:], 16) indirect.append((base + cur, ptr)) if base <= ptr < base + len(prg) - 1: t = prg[ptr - base] | (prg[ptr - base + 1] << 8) if base <= t < base + len(prg): targets.add(t) queue.append(t) break elif insn.mnemonic in TERMINATORS: break cur += insn.length return insns, invalid, indirect, targets, overlap def scan_orphans(prg, base, insns, min_len=8): """Contiguous untraced runs, used to hunt for code missed by static trace. Coverage includes operand bytes, so only genuinely untouched ROM is reported. """ covered = set() for off, insn in insns.items(): for k in range(off, off + insn.length): covered.add(k) out = [] n = len(prg) i = 0 while i < n: if i in covered: i += 1 continue j = i while j < n and j not in covered: j += 1 if j - i >= min_len: out.append((i, j - i)) i = j return out def probe(prg, base, addr, max_insns=64): """Decode linearly from addr without recording; used to judge candidate seeds.""" cur = addr - base n = len(prg) seq = [] term = None for _ in range(max_insns): if cur >= n: break insn = render(prg, base, prg[cur], cur) if insn is None: return seq, f"illegal ${base + cur:04X}" seq.append(insn) cur += insn.length if insn.mnemonic in TERMINATORS: term = insn.mnemonic break return seq, term # Bytes that may legally precede a routine entry point. ENTRY_PREDECESSORS = {0x60, 0x4C, 0x6C, 0x40, 0x00, 0xEA, 0x6A, 0x4A, 0x2A, 0x0A} def operand_value(insn): """Absolute address of an ABS/ABSX/ABSY/IND operand, or None.""" if insn.mode not in (ABS, ABSX, ABSY, IND): return None if insn.value is not None: return insn.value token = insn.text.strip("()").split(",")[0] token = token.removeprefix("a:").lstrip("$") # ca65 absolute-forcing prefix return int(token, 16) def operand_in_prg(insn, base, n): value = operand_value(insn) return value is None or (base <= value < base + n) # Mnemonics that only read, only write, or do both, for zero-page reference counting. ZP_READ_ONLY = {"lda", "ldx", "ldy", "cmp", "cpx", "cpy", "bit"} ZP_WRITE_ONLY = {"sta", "stx", "sty"} ZP_READ_WRITE = {"inc", "dec", "asl", "lsr", "rol", "ror"} def classify_zp(insn): """Return (reads, writes) for a zero-page operand.""" m = insn.mnemonic if m in ZP_READ_ONLY: return 1, 0 if m in ZP_WRITE_ONLY: return 0, 1 if m in ZP_READ_WRITE: return 1, 1 if m in ("lda", "ldx", "ldy", "cmp", "cpx", "cpy", "bit", "sbc", "adc", "and", "ora", "eor"): return 1, 0 return 1, 1 def find_referenced_stubs(prg, base, insns, min_insns=4): """Candidate routine entries that a data table elsewhere in the image points at. This recovers code reached only through computed dispatch (jump tables feeding `jmp (zp)`, RTS tables), which a plain recursive trace from the vectors cannot see. A candidate must start right after a plausible terminator, decode linearly with no illegal opcode, run at least `min_insns` instructions, and end in rts/jmp. Offsets that are operand bytes of already-decoded instructions are rejected first: that is what separates a real entry point from a decode that merely starts inside a neighbouring instruction's operand field. """ n = len(prg) # Every 16-bit little-endian window in the image, mapped to its host offsets. refs = {} for off in range(n - 1): refs.setdefault(prg[off] | (prg[off + 1] << 8), []).append(off) # Operand bytes belong to an instruction already known to be reached code. operand_bytes = set() for off, insn in insns.items(): for k in range(off + 1, off + insn.length): operand_bytes.add(k) stubs = [] for off in range(n): if off in insns or off in operand_bytes: continue if off > 0 and prg[off - 1] not in ENTRY_PREDECESSORS: continue addr = base + off seq, term = probe(prg, base, addr) if term not in ("rts", "jmp"): continue if len(seq) < min_insns: continue # Reject anything that runs into already-decoded code mid-stream. if any((i.addr - base) in insns for i in seq): continue # Reject clean decodes that no table anywhere in the image refers to. if not refs.get(addr): continue # Reject stubs whose operands point outside the PRG window (data decode). if any(not operand_in_prg(i, base, n) for i in seq): continue stubs.append(addr) return stubs def recover(prg, base, seeds, tables=(), max_rounds=12): """Trace, iteratively seed table-referenced stubs, then drop seeds that collide. A recovered stub that turns out to sit inside another instruction's operand field is a mis-aligned decode, not a routine: it is removed and the trace is redone. `tables` are explicitly declared 16-bit dispatch tables (addr, entry count) whose entries are routine entry points; they are seeded on every round. """ vector_seeds = list(seeds) table_seeds = [] junk = [] for addr, count in tables: off = addr - base for k in range(count): lo = prg[off + 2 * k] hi = prg[off + 2 * k + 1] entry = lo | (hi << 8) # Dispatch tables leave unused slots filled with whatever followed; only # keep slots that actually begin a decodable instruction. if render(prg, base, prg[entry - base], entry - base) is None: junk.append(entry) continue table_seeds.append(entry) stub_seeds = [] rejected = [] result = None for _ in range(max_rounds): result = trace(prg, base, vector_seeds + table_seeds + stub_seeds) insns, invalid, indirect, targets, overlap = result operand_bytes = set() for off, insn in insns.items(): for k in range(off + 1, off + insn.length): operand_bytes.add(k) keep, drop = [], [] for addr in stub_seeds: (drop if (addr - base) in operand_bytes else keep).append(addr) stub_seeds = keep stubs = find_referenced_stubs(prg, base, insns) fresh = [s for s in stubs if s not in targets and s not in stub_seeds] if not fresh and not drop: break stub_seeds.extend(fresh) rejected.extend(drop) return result[0], result[1], result[2], result[3], result[4], stub_seeds, rejected, junk def parse_ranges(spec): """Parse `ADDR:LEN` pairs into (address, length) tuples.""" out = [] for tok in spec.split(","): tok = tok.strip() if not tok: continue addr, length = tok.split(":") out.append((int(addr, 16), int(length, 10))) return out def parse_tables(spec): """Parse `ADDR:COUNT` pairs into (address, entry_count) tuples.""" return parse_ranges(spec) def build_refs(prg, base, insns, regions, splits=None): """Attribute each data region to the decoded instructions that reference it. `regions` are (offset, length) pairs from scan_orphans; results are keyed by the region's start CPU address so the emitter can look them up directly. `splits` maps a block's start CPU address to its length. A region that is split into several blocks is attributed per block, so the "referenced by" note under each block lists the references that actually point into it. """ splits = splits or {} if not regions: return {} # cut the regions at the split points pieces = [] for off, length in regions: cur = off end = off + length while cur < end: nxt = end for addr, n in splits.items(): a = addr - base if cur < a < end and a < nxt: nxt = a pieces.append((cur, nxt - cur)) cur = nxt refs = {base + off: [] for off, _ in pieces} for off in sorted(insns): insn = insns[off] value = operand_value(insn) if value is None: continue addr = base + off for start, length in pieces: if base + start <= value < base + start + length: refs[base + start].append((addr, insn, value)) break return refs def byte_columns(values): return ", ".join(f"${v:02X}" for v in values) def emit_code(prg, base, insns, targets, out, project, semantic, refs=None, splits=None, notes=None): """Render the full PRG as ca65 text: code where traced, .byte elsewhere.""" n = len(prg) refs = refs or {} splits = splits or {} notes = notes or {} name_of = {} all_branch_targets = set() for _ins in insns.values(): if getattr(_ins, 'mode', None) == REL and getattr(_ins, 'target', None) is not None: all_branch_targets.add(_ins.target) # Seed semantic entry-point names. for addr, name in semantic.items(): name_of[addr] = name # A branch target must win over fall-through. If some instruction's operand # spans an address that another path branches to, the spanning decode is the # wrong one: real assemblers would never branch into the middle of an # instruction. Drop the span and keep the target. Its bytes fall out as data, # so byte accounting is unaffected. tstart = {t - base for t in targets if (t - base) in insns} changed = True while changed: changed = False for k in sorted(insns): if k in tstart: continue span = insns[k].length if any(j in tstart for j in range(k + 1, k + span)): del insns[k] changed = True break # A branch target that still falls strictly inside a surviving instruction # can never be emitted: the linear walk consumes those bytes as an operand, # so the label would be referenced but never defined and the file would not # assemble. Prune those targets up front. valid_starts = set(insns) for k, ins in insns.items(): for j in range(k + 1, k + ins.length): valid_starts.discard(j) targets = {t for t in targets if (t - base) in valid_starts} i = 0 routine_header = [ "; -----------------------------------------------------------------------------", "; Routine: {name}", "; Global: Contextual control-flow entry identified from its parent routine and incoming branch usage.", "; Local: Continues its parent routine along the condition or operation encoded in this label,", "; then returns, branches again, or rejoins shared code as documented by the instructions.", "; Parameters: Undetermined in Pass 1 unless implied by the caller.", "; Returns: Undetermined in Pass 1; status flags and registers may carry results.", "; Clobbers: Undetermined in Pass 1.", "; -----------------------------------------------------------------------------", ] while i < n: cpu = base + i if i in insns: insn = insns[i] if cpu in targets or cpu in name_of: name = name_of.get(cpu) or f"Label_{cpu:04X}" for line in routine_header: out.append(line.format(name=name)) out.append(f"{name}:") text = f"{insn.mnemonic} {insn.text}".strip() if insn.mode == REL and insn.target is not None: # render() stamps every branch as Label_XXXX; prefer the semantic # name so a branch into a vector entry (or any named routine) # references the label that is actually emitted here. text = f"{insn.mnemonic} " + ( name_of.get(insn.target) or f"Label_{insn.target:04X}") raw = " ".join(f"{b:02X}" for b in prg[i:i + insn.length]) # ca65 can only encode the *official* implied NOP, $EA. Every # other NOP-shaped opcode -- the undocumented $1A/$3A/$5A/$7A/ # $DA/$FA and the multi-byte padding forms -- must be written as # explicit bytes: ca65 would silently assemble them as $EA and # the rebuild would stop being byte-identical. unencodable = (insn.mnemonic in CA65_UNSUPPORTED or (insn.mnemonic in CA65_UNSUPPORTED_MODE and (insn.mode != IMP or insn.opcode != 0xEA))) if unencodable: # ca65 (V2.19) implements no undocumented opcodes, so these cannot be # written as mnemonics. Keep them in the code region as explicit bytes # and record the full disassembly in the comment, so the code/data # split stays correct and the bytes stay auditable. line = f" .byte {byte_columns(prg[i:i + insn.length])}" out.append(f"{line:<31}; CPU:${cpu:04X} PRG:{i:04X} {raw}" f" -- {text} (undocumented; ca65 cannot encode)") else: out.append(f" {text:<31}; CPU:${cpu:04X} PRG:{i:04X} {raw}") i += insn.length continue # Data run. j = i while j < n and j not in insns: j += 1 # A run may be split into named blocks. `splits` maps the start # address of a block to its length; the rest of the run continues # as a further block. while i < j: cpu = base + i length = splits.get(cpu) if length is None or i + length > j: length = j - i cpu_end = base + i + length - 1 out.append(f"; ---- data ${cpu:04X}-${cpu_end:04X} ({length} bytes) ----") for note in notes.get(cpu, []): out.append(f"; {note}") for addr, insn, value in refs.get(cpu, [])[:6]: text = f"{insn.mnemonic} {insn.text}".strip() out.append(f"; referenced by `{text}` at ${addr:04X} " f"(operand ${value:04X} falls inside this region)") if len(refs.get(cpu, [])) > 6: out.append(f"; ... and {len(refs[cpu]) - 6} further references from decoded code") if cpu in name_of: out.append(f"{name_of[cpu]}:") k = i while k < i + length: # split 16-byte rows at branch-target boundaries so a label can # be emitted at the exact byte a branch lands on (even inside a # data region); otherwise the label would be referenced but never # defined and the file would not assemble. row_end = min(k + 16, i + length) for bt in all_branch_targets: if k < bt - base < row_end: row_end = bt - base break if (base + k) in all_branch_targets: lbl = name_of.get(base + k) or f"Label_{base + k:04X}" out.append(f"{lbl}:") chunk = prg[k:row_end] raw = " ".join(f"{b:02X}" for b in chunk) out.append(f" .byte {byte_columns(chunk)} ; CPU:${base + k:04X} PRG:{k:04X} {raw}") k = row_end i += length return name_of def main(): ap = argparse.ArgumentParser() ap.add_argument("--prg", required=True) ap.add_argument("--base", default="0x8000") ap.add_argument("--project", required=True) ap.add_argument("--out", required=True) ap.add_argument("--locals", default=None) ap.add_argument("--zp-aliases", default=None, help="comma list addr=name pairs for semantic zero-page names") ap.add_argument("--zp-strict", action="store_true", help="use ZpXX for every zero-page operand (default)") ap.add_argument("--symbols", help="symbols.inc-style file whose hardware register names " "are applied to the operands at Pass 1. The default is " "the symbols.inc next to --out, when it exists; " "--no-symbols turns the substitution off") ap.add_argument("--no-symbols", action="store_true", help="keep the hardware registers numeric even when a " "symbols.inc is available") ap.add_argument("--extra-seed", default="", help="comma list of CPU addresses to seed in addition to the vectors") ap.add_argument("--report-orphans", type=int, default=0, help="print untraced runs of at least N bytes (missed-code hunting)") ap.add_argument("--auto-seed", action="store_true", help="iteratively seed table-referenced routine stubs to recover " "computed-dispatch code, then report every recovered entry") ap.add_argument("--table", default="", help="comma list of ADDR:COUNT declaring a 16-bit dispatch table whose " "entries are routine entry points (e.g. 0x9237:40)") ap.add_argument("--split", default="", help="comma list of ADDR:LEN cutting an untraced run into a " "block of LEN bytes at ADDR, so a data region can carry " "its own name and note") ap.add_argument("--note", action="append", default=[], metavar="ADDR:TEXT", help="explanation line printed under the data block that " "starts at ADDR; may be given more than once") ap.add_argument("--provenance", action="append", default=[], metavar="TEXT", help="extra line in the Pass 1 provenance block") ap.add_argument("--data", default="", help="comma list of ADDR:LEN forcing a region back to data even when " "static fall-through reached it (branch-over-data)") ap.add_argument("--probe", default="", help="comma list of CPU addresses to decode linearly for inspection") ap.add_argument("--undocumented", action="store_true", help="also decode the 105 undocumented NMOS opcodes (NOP padding, " "SLO/RLA/SRE/RRA/SAX/LAX/DCP/ISC and friends). Games use these as " "padding, and a tracer that halts on them loses most of the code. " "Default off so existing regen pipelines stay byte-stable.") ap.add_argument("--mapping-note", default="", help="how the PRG is mapped, for the header's 'PRG mapped at' line " "(default: 'NROM, mirrored to the upper half')") args = ap.parse_args() # Apply the hardware register names at Pass 1, so the source never shows a # bare $2002 when symbols.inc already knows it as PPUSTATUS. The default # is the symbols.inc next to the output; --no-symbols keeps the operands # numeric, which is what a project whose committed prg.s predates this # needs in order to stay reproducible. if not args.no_symbols: sym = args.symbols if sym is None: cand = Path(args.out).parent / "symbols.inc" sym = str(cand) if cand.is_file() else None if sym: n = set_hardware(sym) if n: print(f"symbols : {n} hardware register names applied at Pass 1 " f"from {sym}") set_undocumented(args.undocumented) base = int(args.base, 16) prg = Path(args.prg).read_bytes() if len(prg) < 6: sys.exit("PRG image too small for vectors") nmi = prg[-6] | (prg[-5] << 8) rst = prg[-4] | (prg[-3] << 8) irq = prg[-2] | (prg[-1] << 8) extra = [int(a, 16) for a in args.extra_seed.split(",") if a.strip()] if args.probe: for tok in args.probe.split(","): tok = tok.strip() if not tok: continue addr = int(tok, 16) seq, term = probe(prg, base, addr) print(f"probe {tok}: {len(seq)} insns, terminator={term}") for insn in seq: raw = " ".join(f"{b:02X}" for b in prg[insn.addr - base:insn.addr - base + insn.length]) text = f"{insn.mnemonic} {insn.text}".strip() print(f" ${insn.addr:04X} {raw:<9} {text}") return seeds = [rst, nmi, irq] + extra tables = parse_tables(args.table) forced = parse_ranges(args.data) splits = {base + a: n for a, n in ((int(tok.split(":")[0], 16), int(tok.split(":")[1])) for tok in args.split.split(",") if tok.strip())} notes = {} for spec in args.note: addr, _, text = spec.partition(":") notes.setdefault(int(addr, 16), []).append(text) rejected = [] junk = [] if args.auto_seed or tables: insns, invalid, indirect, targets, overlap, recovered, rejected, junk = recover( prg, base, seeds, tables) else: insns, invalid, indirect, targets, overlap = trace(prg, base, seeds) recovered = [] # A branch that is always taken leaves its fall-through bytes as data; the trace # cannot tell, so the caller names those spans explicitly. overridden = [] for addr, length in forced: for k in range(addr, addr + length): if k - base in insns: overridden.append(k) del insns[k - base] targets.discard(k) if forced: invalid = [a for a in invalid if not any(a0 <= a < a0 + n for a0, n in forced)] # a region that is forced back to data cannot also be a recovered # routine entry, so it must not appear in the recovered list recovered = [a for a in recovered if not any(a0 <= a < a0 + n for a0, n in forced)] semantic = OrderedDict() # Priority order matters: RESET and IRQ are commonly the same address, and # overwriting would rename the primary entry point to `IrqHandler`. semantic.setdefault(rst, "Reset") semantic.setdefault(nmi, "NmiHandler") semantic.setdefault(irq, "IrqHandler") regions = scan_orphans(prg, base, insns, 1) refs = build_refs(prg, base, insns, regions, splits) out = [] out.append(f"; {args.project} NES disassembly - Pass 1") out.append("; Byte-oriented ca65 source generated from the supplied PRG image.") out.append("; Recursively reachable instructions are emitted explicitly with address/byte comments.") out.append("; Unreached bytes remain explicit .byte blocks; no ROM byte is deleted or reformatted.") out.append("; Pass 2 replaces address-derived labels, RAM aliases, and table names while") out.append("; preserving byte identity.") out.append(";") out.append(f"; Vectors: NMI=${nmi:04X} RESET=${rst:04X} IRQ=${irq:04X}") mapping = args.mapping_note or "NROM, mirrored to the upper half" out.append(f"; PRG mapped at ${base:04X}-${base + len(prg) - 1:04X} ({mapping})") out.append(";") out.append("; Pass 1 trace provenance (the audit trail behind every classification below):") if tables: for addr, count in tables: out.append(f"; dispatch table ${addr:04X}, {count} x 16-bit entries, seeded as routine entries") if recovered: out.append(f"; {len(set(recovered))} further routine entries recovered from table" " references elsewhere in the image:") out.append(f"; {', '.join(f'${a:04X}' for a in sorted(set(recovered)))}") if extra: out.append("; entries seeded by hand after inspecting code-shaped but unreferenced blocks:") out.append(f"; {', '.join(f'${a:04X}' for a in extra)}") if forced: for addr, length in forced: out.append(f"; ${addr:04X}-${addr + length - 1:04X} forced back to data:" " the only path into it is a branch that is always taken") for line in args.provenance: out.append(f"; {line}") if indirect: out.append("; indirect dispatch through zero page (not followed statically):") for addr, ptr in indirect: out.append(f"; jmp (${ptr:04X}) at ${addr:04X}") out.append(";") out.append('.setcpu "6502"') out.append("") out.append('.include "symbols.inc"') out.append('.include "locals.inc"') out.append("") out.append('.segment "PRG"') out.append("") emit_code(prg, base, insns, targets, out, args.project, semantic, refs, splits, notes) Path(args.out).write_text("\n".join(out) + "\n") code_bytes = sum(i.length for i in insns.values()) print(f"vectors : NMI=${nmi:04X} RESET=${rst:04X} IRQ=${irq:04X}") if extra: print(f"extra seed: {[hex(a) for a in extra]}") if tables: print(f"tables : {', '.join(f'${a:04X}x{c}' for a, c in tables)}") print(f"PRG : {len(prg)} bytes at ${base:04X}") print(f"code : {len(insns)} instructions / {code_bytes} bytes " f"({code_bytes * 100.0 / len(prg):.1f}%)") print(f"data : {len(prg) - code_bytes} bytes") print(f"labels : {len(targets | set(semantic))}") if invalid: print(f"INVALID : {[hex(a) for a in sorted(set(invalid))]}") if indirect: print(f"INDIRECT : {[(hex(a), hex(p)) for a, p in indirect]}") if overlap: print(f"OVERLAP : {[(hex(a), m, [hex(c) for c in clash]) for a, m, clash in overlap]}") if recovered: print(f"RECOVERED : {len(recovered)} table-referenced routine entries") print(f" {', '.join(hex(a) for a in sorted(set(recovered)))}") if rejected: print(f"REJECTED : {', '.join(hex(a) for a in sorted(set(rejected)))} " f"(mis-aligned stubs inside another instruction's operand)") if junk: print(f"JUNK SLOT : {', '.join(hex(a) for a in sorted(set(junk)))} " f"(unused dispatch-table slots, not decodable)") if forced: print(f"FORCED : {', '.join(f'${a:04X}+${n:02X}' for a, n in forced)} (branch-over-data)") if overridden: print(f" overrode {len(overridden)} fall-through bytes: " f"{', '.join(hex(a) for a in sorted(set(overridden)))}") if args.report_orphans: for off, length in scan_orphans(prg, base, insns, args.report_orphans): print(f"ORPHAN : ${base + off:04X}-${base + off + length - 1:04X} ({length} bytes)") # Collect zero-page operands actually referenced so locals.inc can list them. if args.locals: zp = {} for off in sorted(insns): insn = insns[off] if insn.mode not in (ZP, ZPX, ZPY, INDX, INDY): continue addr = int(insn.text.replace("(", " ").replace(")", " ").replace(",", " ").split()[0][2:], 16) reads, writes = classify_zp(insn) rec = zp.setdefault(addr, {"reads": 0, "writes": 0, "where": []}) rec["reads"] += reads rec["writes"] += writes if base + off not in rec["where"]: rec["where"].append(base + off) lines = [f"; {args.project} application-specific RAM aliases - Pass 1", "; Generated from every direct zero-page operand in reachable code:", "; ref counts are reads/writes, `where` lists the referencing addresses.", "; ZpXX names indicate storage with a role not yet unique enough for a behavioral name;", "; Pass 2 replaces them with names derived from observed usage.", ""] for addr in sorted(zp): rec = zp[addr] total = rec["reads"] + rec["writes"] name = f"Zp{addr:02X}" where = ", ".join(f"{w:04X}" for w in rec["where"][:4]) if len(rec["where"]) > 4: where += f", +{len(rec['where']) - 4} more" lines.append(f"{name:<24}= ${addr:02X} ; {total} refs; " f"reads={rec['reads']} writes={rec['writes']}; used at ${where}") Path(args.locals).write_text("\n".join(lines) + "\n") print(f"locals : {len(zp)} zero-page aliases written to {args.locals}") if __name__ == "__main__": main()