Urban Champion (JU): Pass 1 complete (CDL verified), Pass 2 complete - 519 labels renamed to semantic names via pass2.py apply - 10 zero-page semantic aliases added to locals.inc - Branch table at CB70-CBAC restructured per CDL (CB70-CB90=data, CB91-CBAC=code) - E07F-E09D region fixed (misaligned decode at E08D) - BYTE-IDENTICAL at PRG level verified Tools: - cdl_reclass.py: overrides now win over CDL conflicts - nesdis.py: fixed a: prefix parsing for JSR/JMP operands; IND mode emits no a: prefix - verify_pass1.py: all 17 top-level projects pass, no crashes
846 lines
35 KiB
Python
846 lines
35 KiB
Python
#!/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 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
|
|
|
|
|
|
def hex16(v):
|
|
return f"${v:04X}"
|
|
|
|
|
|
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:
|
|
insn.text = f"{'a:' if b2 == 0 else ''}{hex16(b1 | (b2 << 8))}"
|
|
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.
|
|
insn.text = f"{'a:' if b2 == 0 else ''}{hex16(b1 | (b2 << 8))},x"
|
|
elif mode == ABSY:
|
|
insn.text = f"{'a:' if b2 == 0 else ''}{hex16(b1 | (b2 << 8))},y"
|
|
elif mode == IND:
|
|
# IND (jmp ($xxxx)) has no zero-page variant, so no 'a:' prefix needed
|
|
insn.text = f"({hex16(b1 | (b2 << 8))})"
|
|
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):
|
|
"""Recursive descent from seeds. Returns (insns, invalid, indirect, targets, overlap)."""
|
|
insns = {}
|
|
targets = set(seeds)
|
|
invalid = []
|
|
indirect = []
|
|
overlap = []
|
|
queue = list(seeds)
|
|
|
|
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
|
|
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
|
|
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):
|
|
"""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.
|
|
"""
|
|
refs = {base + off: [] for off, _ in regions}
|
|
if not regions:
|
|
return refs
|
|
for off in sorted(insns):
|
|
insn = insns[off]
|
|
value = operand_value(insn)
|
|
if value is None:
|
|
continue
|
|
addr = base + off
|
|
for start, length in regions:
|
|
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):
|
|
"""Render the full PRG as ca65 text: code where traced, .byte elsewhere."""
|
|
n = len(prg)
|
|
refs = refs or {}
|
|
name_of = {}
|
|
|
|
# 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])
|
|
# `nop` is only a problem in its multi-byte padding forms; the
|
|
# official 0xEA implied NOP encodes fine.
|
|
unencodable = (insn.mnemonic in CA65_UNSUPPORTED
|
|
or (insn.mnemonic in CA65_UNSUPPORTED_MODE
|
|
and insn.mode != IMP))
|
|
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
|
|
cpu_end = base + j - 1
|
|
out.append(f"; ---- data ${cpu:04X}-${cpu_end:04X} ({j - i} bytes) ----")
|
|
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 < j:
|
|
chunk = prg[k:min(k + 16, j)]
|
|
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 += 16
|
|
i = j
|
|
|
|
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("--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("--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.")
|
|
args = ap.parse_args()
|
|
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)
|
|
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)]
|
|
|
|
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)
|
|
|
|
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}")
|
|
out.append(f"; PRG mapped at ${base:04X}-${base + len(prg) - 1:04X} (NROM, mirrored to the upper half)")
|
|
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")
|
|
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)
|
|
|
|
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() |