Files
disasm/_tools/banked_dis.py
T

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()