374 lines
15 KiB
Python
374 lines
15 KiB
Python
#!/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 file.
|
|
for line in sub:
|
|
# The audit comment's PRG field is four digits in every project,
|
|
# and the Pass 2 tools match exactly that. A banked image is
|
|
# larger than 64 KB, so the offset within the bank stays in that
|
|
# field and the bank's file base is recorded beside it.
|
|
line = re.sub(
|
|
r"PRG:([0-9A-F]{4})",
|
|
lambda m: f"PRG:{m.group(1)} BANK:{off: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()
|