#!/usr/bin/env python3 """Apply a Pass 2 name map (address -> name) to a project's prg.s. The map is a TSV of `` 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_` 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. Usage: python3 _tools/apply_pass2_map.py [--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:") def read_map(path): 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) addr = int(parts[0].strip().lstrip("$"), 16) name = parts[1].strip() 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 addr in out and out[addr] != name: raise SystemExit(f"error: ${addr:04X} mapped twice " f"({out[addr]} and {name})") out[addr] = name return out 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("--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) 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)) missing = [a for a in names if f"Label_{a:04X}" not in defined and names[a] not in defined and a not in insn_addrs] if missing: raise SystemExit("error: map names an address with no label " "definition: " + ", ".join(f"${a:04X}" for a in missing)) # Address -> label. A label definition carries no audit comment of its # own, so its address is the address of the next audited line. 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: addr_of.setdefault(int(am.group(1), 16), cur) cur = None # The new names must not collide with a label that is not being renamed. # A label that keeps the name it already has (the vectors, say) is fine. being_renamed = {addr_of.get(a) for a in names} existing = {n for n in defined if n not in being_renamed} clash = [names[a] for a in names if names[a] 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 a, old_name in addr_of.items(): if a in names and names[a] != old_name: renamed_labels[old_name] = names[a] 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 for line in lines: m = LABEL_RE.match(line) if m: old = m.group(1) a = next((addr for addr, lab in addr_of.items() if lab == old), None) if a is not None and a in names and names[a] != old: out.append(names[a] + ":") renamed_defs += 1 defined.discard(old) defined.add(names[a]) cur = names[a] continue out.append(line) cur = old continue # A call target that carries no label at all - the reclass can # leave a freshly decoded entry unlabelled - gets one from the map. am = ADDR_RE.search(line) 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*)(Label_[0-9A-F]{4}|" r"[A-Za-z_][A-Za-z0-9_]*)(\s*)$", comment) if hm: old = hm.group(2) m2 = ADDR_LABEL_RE.match(old) if m2: a = int(m2.group(1), 16) if a in names: comment = hm.group(1) + names[a] + hm.group(3) renamed_headers += 1 def repl(mo): nonlocal renamed_refs a = int(mo.group(1), 16) if a in names: renamed_refs += 1 return names[a] 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` 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) if a in names: code = f"{cm.group(1)}{names[a]}{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())