310 lines
11 KiB
Python
310 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Apply a Pass 2 name map (address -> name) to a project's prg.s.
|
|
|
|
The map is a TSV of `<CPU address><TAB><name>` lines, one per routine or
|
|
branch target. This is the "fresh map derived from the committed split"
|
|
workflow the repo uses: the map is written by reading the disassembly, and
|
|
this tool only performs the mechanical rewrite.
|
|
|
|
Two things are rewritten:
|
|
|
|
1. `Label_<addr>` label definitions and their references become the
|
|
mapped name.
|
|
2. A raw `$XXXX` operand on a `jsr` or `jmp` becomes the mapped name of
|
|
the address it points at. Only `jsr`/`jmp` are touched: a raw
|
|
operand on a data reference (`lda $C253`) points at a table, not a
|
|
routine, and naming it would be a lie.
|
|
|
|
Everything else is preserved byte for byte: the mnemonic, the whitespace and
|
|
the trailing `; CPU:$.... PRG:.... XX XX` audit comment. Because every
|
|
replacement names a label that is defined in the same file, the assembled
|
|
bytes cannot change - `make` still has to print BYTE-IDENTICAL.
|
|
|
|
The tool refuses to write if any label in the map has no definition in the
|
|
file, or if a rename would collide with an existing label.
|
|
|
|
Banked cartridges
|
|
-----------------
|
|
A banked PRG (MMC3 and friends) has several labels at the same CPU address:
|
|
one per bank. Those banks are told apart by a label prefix, and the map may
|
|
name them with a `<prefix>:<addr>` key:
|
|
|
|
800F UpdateSprites
|
|
Bank4:800D Bank4_DrawTile
|
|
|
|
`--bank-prefix` says which prefix belongs to which part of the file:
|
|
|
|
--bank-prefix "0:,8000:Bank4_,A000:Bank5_"
|
|
|
|
i.e. the labels from file offset $8000 on are the `Bank4_` ones. A
|
|
reference inside a file region is resolved against that region's bank, so
|
|
`Label_800F` means bank 0's label in the lower half and bank 4's label in
|
|
the upper half.
|
|
|
|
Usage:
|
|
python3 _tools/apply_pass2_map.py <project dir> <map.tsv> [--write]
|
|
"""
|
|
|
|
import argparse
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
LABEL_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*):\s*$")
|
|
ADDR_LABEL_RE = re.compile(r"^Label_([0-9A-F]{4})$")
|
|
CALL_RE = re.compile(r"^(\s*(?:jsr|jmp)\s+)\$([0-9A-F]{4})(\s*)$")
|
|
ADDR_RE = re.compile(r"; CPU:\$([0-9A-F]{4}) PRG:([0-9A-F]{4})(?: BANK:([0-9A-F]+))?")
|
|
# a label that belongs to a bank: optional prefix plus the address
|
|
BANKED_LABEL_RE = re.compile(r"^(?:(?P<pre>[A-Za-z][A-Za-z0-9]*)_)?Label_(?P<addr>[0-9A-F]{4})$")
|
|
|
|
|
|
def read_map(path):
|
|
"""Return {(prefix, address): name}. A bare address means the default
|
|
(unprefixed) bank; `<prefix>:<addr>` names a label in a prefixed bank."""
|
|
out = {}
|
|
for raw in Path(path).read_text().splitlines():
|
|
line = raw.split("#", 1)[0].strip()
|
|
if not line:
|
|
continue
|
|
parts = line.split("\t")
|
|
if len(parts) < 2:
|
|
parts = line.split(None, 1)
|
|
key = parts[0].strip()
|
|
name = parts[1].strip()
|
|
prefix = ""
|
|
if ":" in key:
|
|
prefix, _, key = key.partition(":")
|
|
prefix = prefix.strip()
|
|
addr = int(key.strip().lstrip("$"), 16)
|
|
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
|
|
raise SystemExit(f"error: bad name {name!r} for ${addr:04X}")
|
|
if (prefix, addr) in out and out[(prefix, addr)] != name:
|
|
raise SystemExit(f"error: ${addr:04X} mapped twice "
|
|
f"({out[(prefix, addr)]} and {name})")
|
|
out[(prefix, addr)] = name
|
|
return out
|
|
|
|
|
|
def read_bank_prefixes(spec):
|
|
"""[(file offset, prefix)] sorted by offset."""
|
|
out = []
|
|
for tok in spec.split(","):
|
|
tok = tok.strip()
|
|
if not tok:
|
|
continue
|
|
off, _, pre = tok.partition(":")
|
|
out.append((int(off, 16), pre.strip()))
|
|
return sorted(out)
|
|
|
|
|
|
def prefix_at(bank_prefixes, file_offset):
|
|
pre = ""
|
|
for off, p in bank_prefixes:
|
|
if file_offset >= off:
|
|
pre = p
|
|
else:
|
|
break
|
|
return pre
|
|
|
|
|
|
def file_offset_of(match):
|
|
"""File offset of an audit comment. See derive_branch_names.file_offset_of."""
|
|
if match.lastindex and match.lastindex >= 3 and match.group(3):
|
|
return int(match.group(3), 16) + int(match.group(2), 16)
|
|
return int(match.group(2), 16)
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
ap.add_argument("dir", help="project directory")
|
|
ap.add_argument("map", help="address -> name TSV")
|
|
ap.add_argument("--bank-prefix", default="",
|
|
help="comma list of FILEOFF:PREFIX, telling which labels "
|
|
"belong to which bank")
|
|
ap.add_argument("--write", action="store_true", help="apply (default: dry run)")
|
|
args = ap.parse_args()
|
|
|
|
prg = Path(args.dir) / "prg.s"
|
|
if not prg.is_file():
|
|
raise SystemExit(f"error: {prg} not found")
|
|
|
|
names = read_map(args.map)
|
|
bank_prefixes = read_bank_prefixes(args.bank_prefix)
|
|
lines = prg.read_text().split("\n")
|
|
|
|
# Which addresses actually carry a label definition?
|
|
defined = set()
|
|
for line in lines:
|
|
m = LABEL_RE.match(line)
|
|
if m:
|
|
defined.add(m.group(1))
|
|
|
|
# Which addresses carry an instruction at all? A call target the
|
|
# tracer never labelled still has an instruction there, and the map
|
|
# gives it a name.
|
|
insn_addrs = set()
|
|
for line in lines:
|
|
am = ADDR_RE.search(line)
|
|
if am:
|
|
insn_addrs.add(int(am.group(1), 16))
|
|
|
|
# (prefix, address) -> label name, for every label in the file.
|
|
addr_of = {}
|
|
cur = None
|
|
for line in lines:
|
|
m = LABEL_RE.match(line)
|
|
if m:
|
|
cur = m.group(1)
|
|
continue
|
|
am = ADDR_RE.search(line)
|
|
if am and cur:
|
|
lm = BANKED_LABEL_RE.match(cur)
|
|
if lm:
|
|
key = (lm.group("pre") or "", int(lm.group("addr"), 16))
|
|
else:
|
|
key = ("", int(am.group(1), 16))
|
|
addr_of.setdefault(key, cur)
|
|
cur = None
|
|
|
|
missing = []
|
|
for (prefix, addr) in names:
|
|
if (prefix, addr) in addr_of:
|
|
continue
|
|
if names[(prefix, addr)] in defined:
|
|
continue
|
|
if prefix == "" and addr in insn_addrs:
|
|
continue
|
|
missing.append((prefix, addr))
|
|
if missing:
|
|
raise SystemExit("error: map names an address with no label "
|
|
"definition: " + ", ".join(
|
|
f"{p or ''}${a:04X}" for p, a in missing))
|
|
|
|
# The new names must not collide with a label that is not being renamed.
|
|
being_renamed = set(addr_of.get(k) for k in names)
|
|
existing = {n for n in defined if n not in being_renamed}
|
|
clash = [names[k] for k in names if names[k] in existing]
|
|
if clash:
|
|
raise SystemExit("error: renamed label would collide: "
|
|
+ ", ".join(sorted(set(clash))))
|
|
|
|
# the addresses that already carry a label definition
|
|
addr_with_label = set(addr_of)
|
|
|
|
# old label spelling -> new name, for every label the map renames.
|
|
# One combined pattern, so a 15,000-line file does not need 15,000
|
|
# separate substitutions per label.
|
|
renamed_labels = {}
|
|
for key, old_name in addr_of.items():
|
|
if key in names and names[key] != old_name:
|
|
renamed_labels[old_name] = names[key]
|
|
renamed_re = None
|
|
if renamed_labels:
|
|
pattern = "|".join(re.escape(n) for n in sorted(renamed_labels,
|
|
key=len, reverse=True))
|
|
renamed_re = re.compile(r"\b(" + pattern + r")\b")
|
|
|
|
out = []
|
|
renamed_defs = 0
|
|
renamed_refs = 0
|
|
renamed_calls = 0
|
|
renamed_headers = 0
|
|
created_defs = 0
|
|
cur = None
|
|
cur_prefix = ""
|
|
for line in lines:
|
|
m = LABEL_RE.match(line)
|
|
if m:
|
|
old = m.group(1)
|
|
lm = BANKED_LABEL_RE.match(old)
|
|
key = None
|
|
if lm:
|
|
cand = (lm.group("pre") or "", int(lm.group("addr"), 16))
|
|
if cand in addr_of and addr_of[cand] == old:
|
|
key = cand
|
|
if key is not None and key in names and names[key] != old:
|
|
out.append(names[key] + ":")
|
|
renamed_defs += 1
|
|
defined.discard(old)
|
|
defined.add(names[key])
|
|
cur = names[key]
|
|
cur_prefix = key[0]
|
|
continue
|
|
out.append(line)
|
|
cur = old
|
|
cur_prefix = lm.group("pre") or "" if lm else cur_prefix
|
|
continue
|
|
|
|
am = ADDR_RE.search(line)
|
|
line_prefix = cur_prefix
|
|
if am:
|
|
line_prefix = prefix_at(bank_prefixes, file_offset_of(am))
|
|
|
|
# A call target that carries no label at all - the reclass can
|
|
# leave a freshly decoded entry unlabelled - gets one from the map.
|
|
if am:
|
|
a = int(am.group(1), 16)
|
|
if ("", a) in names and ("", a) not in addr_with_label:
|
|
out.append(f"{names[('', a)]}:")
|
|
addr_with_label.add(("", a))
|
|
created_defs += 1
|
|
|
|
# The operand token lives in the code part; the trailing audit
|
|
# comment is preserved byte for byte.
|
|
code, sep, comment = line.partition(";")
|
|
|
|
# The routine header block names the routine it introduces; it has
|
|
# to follow the rename or the header would point at a label that
|
|
# no longer exists.
|
|
hm = re.match(r"^(\s*Routine:\s*)(\S+)(\s*)$", comment)
|
|
if hm:
|
|
old = hm.group(2)
|
|
lm = BANKED_LABEL_RE.match(old)
|
|
if lm:
|
|
cand = (lm.group("pre") or "", int(lm.group("addr"), 16))
|
|
if cand in names:
|
|
comment = hm.group(1) + names[cand] + hm.group(3)
|
|
renamed_headers += 1
|
|
|
|
def repl(mo):
|
|
nonlocal renamed_refs
|
|
a = int(mo.group(1), 16)
|
|
cand = (line_prefix, a)
|
|
if cand in names:
|
|
renamed_refs += 1
|
|
return names[cand]
|
|
return mo.group(0)
|
|
code = re.sub(r"\bLabel_([0-9A-F]{4})\b", repl, code)
|
|
|
|
# A label the map renames can carry any spelling - `SubC<addr>` is
|
|
# the honest residue form - so its references have to follow it.
|
|
if renamed_re is not None:
|
|
code = renamed_re.sub(lambda mo: renamed_labels[mo.group(1)], code)
|
|
|
|
# raw $XXXX on jsr/jmp
|
|
cm = CALL_RE.match(code)
|
|
if cm:
|
|
a = int(cm.group(2), 16)
|
|
# A call into $E000-$FFFF always lands in the fixed bank,
|
|
# whichever bank the caller runs in.
|
|
cand = ("", a) if 0xE000 <= a <= 0xFFFF else (line_prefix, a)
|
|
if cand not in names and ("", a) in names and line_prefix:
|
|
cand = ("", a)
|
|
if cand in names:
|
|
code = f"{cm.group(1)}{names[cand]}{cm.group(3)}"
|
|
renamed_calls += 1
|
|
out.append(code + sep + comment)
|
|
|
|
print(f"{renamed_defs} label definitions renamed, {created_defs} created, "
|
|
f"{renamed_refs} label references, {renamed_calls} raw call operands, "
|
|
f"{renamed_headers} routine headers renamed "
|
|
f"({len(names)} entries in the map)")
|
|
if args.write:
|
|
prg.write_text("\n".join(out))
|
|
print("written")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|