#!/usr/bin/env python3 """Pass 2 helper: group a generated Pass 1 prg.s into routines and apply renames. Subcommands outline [FILE] list every routine: entry, size, callees, callers show FILE START-END compact listing of one address range apply FILE MAP rewrite labels / zero-page names in place The MAP file is `kindoldnew` per line, where kind is `label` or `zp`. Pass 1's `; CPU:$xxxx PRG:xxxx AA BB` audit comments and every emitted byte are left untouched, so the build stays byte-identical. """ import bisect import re import sys from collections import OrderedDict from pathlib import Path LABEL_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*):\s*$") INSN_RE = re.compile( r"^ (?P[a-z]{3})\s*(?P.*?)\s*" r"; CPU:\$(?P[0-9A-F]{4}) PRG:(?P[0-9A-F]{4})\s+" r"(?P(?:[0-9A-F]{2} )*[0-9A-F]{2})\s*$" ) ZP_RE = re.compile(r"^(Zp[0-9A-F]{2})$") TARGET_RE = re.compile(r"^(?:Label_([0-9A-F]{4})|\$([0-9A-F]{4}))$") TERMINATORS = {"jmp", "rts", "rti", "brk"} def parse(path): """Return (order, labels, insns, data_spans) from a generated prg.s.""" order = [] # source line index -> ('label', name) | ('insn', Insn) | ('data', ...) labels = OrderedDict() # name -> line index insns = OrderedDict() # cpu -> dict(text, op, raw, line) by_line = {} for idx, line in enumerate(Path(path).read_text().splitlines()): m = LABEL_RE.match(line) if m: labels[m.group(1)] = idx order.append(("label", m.group(1))) continue m = INSN_RE.match(line) if m: cpu = int(m.group("cpu"), 16) rec = {"cpu": cpu, "mn": m.group("text"), "op": m.group("op"), "raw": m.group("raw"), "line": idx} insns[cpu] = rec by_line[idx] = ("insn", rec) order.append(("insn", rec)) continue if line.startswith("; ---- data"): order.append(("data", line)) continue return order, labels, insns def label_index(order): """line index -> label name preceding it (for branch naming).""" cur = None out = {} for kind, val in order: if kind == "label": cur = val elif kind == "insn": out[val["line"]] = cur return out def routines(insns, order, labels): """Split instructions into routines at JSR/JMP/vector entry points.""" entries = set() callers = {} for rec in insns.values(): if rec["mn"] in ("jsr", "jmp"): m = re.match(r"^\$([0-9A-F]{4})$", rec["op"]) if m: t = int(m.group(1), 16) entries.add(t) callers.setdefault(t, []).append((rec["cpu"], rec["mn"])) # labels declared in the file that sit at a routine entry for name, line in labels.items(): for kind, val in order: if kind == "insn" and val["line"] == line: entries.add(val["cpu"]) break return sorted(entries), callers def outline(path): order, labels, insns = parse(path) entries, callers = routines(insns, order, labels) # walk each routine from its entry until a terminator for start in entries: rec = insns.get(start) if rec is None: print(f"{start:04X} (no instruction: entry with no body)") continue callees = [] size = 0 head = [] addr = start while True: r = insns.get(addr) if r is None: break if len(head) < 3: head.append(f"{r['mn']} {r['op']}".strip()) size += len(r["raw"].split()) if r["mn"] == "jsr": m = re.match(r"^\$([0-9A-F]{4})$", r["op"]) if m: callees.append(int(m.group(1), 16)) if r["mn"] in TERMINATORS: break addr += len(r["raw"].split()) callers_here = ",".join(f"{c:04X}{'j' if k == 'jmp' else 's'}" for c, k in sorted(set(callers.get(start, [])))) cal = ",".join(f"{c:04X}" for c in sorted(set(callees))) sig = " ; ".join(head) print(f"{start:04X} sz={size:<4} in=[{callers_here}] out=[{cal}] {sig}") BRANCH_COND = { "beq": "OnZero", "bne": "OnNonzero", "bcc": "OnCarryClear", "bcs": "OnCarrySet", "bpl": "OnNonnegative", "bmi": "OnNegative", "bvs": "OnOverflowSet", "bvc": "OnOverflowClear", } # Words describing what the instruction just before a fall-through label did. ROLE_BY_MNEMONIC = { "jsr": "CallNext", "jmp": "JumpNext", "lda": "LoadAndContinue", "ldx": "LoadIndexAndContinue", "ldy": "LoadIndexAndContinue", "sta": "StoreAndContinue", "stx": "StoreIndexAndContinue", "sty": "StoreIndexAndContinue", "inc": "IncrementAndContinue", "inx": "IncrementAndContinue", "iny": "IncrementAndContinue", "dec": "DecrementAndContinue", "dex": "DecrementAndContinue", "dey": "DecrementAndContinue", "asl": "ShiftLeftAndContinue", "lsr": "ShiftRightAndContinue", "rol": "RotateLeftAndContinue", "ror": "RotateRightAndContinue", "cmp": "OnCompare", "cpx": "OnCompare", "cpy": "OnCompare", "and": "OnMask", "ora": "OnMask", "eor": "OnMask", "adc": "OnAdd", "sbc": "OnSubtract", "rts": "Return", "rti": "Return", "brk": "Halt", "tax": "TransferIndexAndContinue", "tay": "TransferIndexAndContinue", "txa": "TransferAccumulatorAndContinue", "tya": "TransferAccumulatorAndContinue", "tsx": "TransferIndexAndContinue", "txs": "TransferIndexAndContinue", "pha": "PushAndContinue", "php": "PushAndContinue", "pla": "PullAndContinue", "plp": "PullAndContinue", "clc": "Continue", "sec": "Continue", "cli": "Continue", "sei": "Continue", "clv": "Continue", "sed": "Continue", "cld": "Continue", "nop": "Continue", "bit": "OnMask", "dey": "DecrementAndContinue", } def routine_entries(insns, labels, vectors=()): """Addresses that begin a routine: JSR/JMP targets, vectors, and declared labels.""" entries = set(vectors) for rec in insns.values(): if rec["mn"] in ("jsr", "jmp"): m = re.match(r"^\$([0-9A-F]{4})$", rec["op"]) if m: entries.add(int(m.group(1), 16)) for name, line in labels.items(): for rec in insns.values(): if rec["line"] == line: entries.add(rec["cpu"]) break return entries def derive(insns, labels, name_map, vectors=()): """Produce label -> name for every Label_XXXX, deriving internal names.""" entries = routine_entries(insns, labels, vectors) # incoming references per address incoming = {} fallthrough = {} for rec in insns.values(): m = re.match(r"^Label_([0-9A-F]{4})$", rec["op"]) if m and rec["mn"] in BRANCH_COND: incoming.setdefault(int(m.group(1), 16), set()).add(rec["mn"]) addrs = sorted(insns) by_line = sorted((rec["line"], rec["cpu"]) for rec in insns.values()) line_keys = [k for k, _ in by_line] def cpu_after(line): """Address of the first instruction that starts after the given source line.""" i = bisect.bisect_right(line_keys, line) return by_line[i][1] if i < len(by_line) else None prev_of = {} for i, a in enumerate(addrs): if i: prev_of[a] = addrs[i - 1] def enclosing(addr): best = None for e in entries: if e <= addr and (best is None or e > best): best = e return best out = {} for name, line in labels.items(): if not name.startswith("Label_"): continue addr = cpu_after(line) if addr is None: continue if addr in name_map: # routine names come from the hand map out[name] = name_map[addr] continue parent = enclosing(addr) base = name_map.get(parent, f"Sub_{parent:04X}") conds = incoming.get(addr, set()) if len(conds) > 1: suffix = "JoinBranchPaths" elif len(conds) == 1: suffix = BRANCH_COND[next(iter(conds))] else: prev = prev_of.get(addr) prev_rec = insns.get(prev) if prev_rec is None or prev + len(prev_rec["raw"].split()) != addr: suffix = "Entry" else: suffix = ROLE_BY_MNEMONIC.get(prev_rec["mn"], "Continue") out[name] = f"{base}_{suffix}_{addr:04X}" return out def show(path, rng): start, end = (int(x, 16) for x in rng.split("-")) order, labels, insns = parse(path) idx = label_index(order) cur_shown = None for addr in sorted(insns): if not (start <= addr <= end): continue rec = insns[addr] lab = idx.get(rec["line"]) if lab and lab != cur_shown: print(f"{lab}:") cur_shown = lab text = f"{rec['mn']} {rec['op']}".strip() print(f" {addr:04X} {text:<28} | {rec['raw']}") def apply_map(path, mapfile): lines = Path(path).read_text().splitlines(keepends=True) label_map = {} zp_map = {} scoped = [] # (start, end, old, new) for zero-page bytes reused across roles addr_re = re.compile(r"; CPU:\$([0-9A-F]{4}) PRG:[0-9A-F]{4}") for line in Path(mapfile).read_text().splitlines(): if not line.strip() or line.startswith("#"): continue parts = line.split("\t") kind = parts[0] if kind == "label": label_map[parts[1]] = parts[2] elif kind == "zp": zp_map[parts[1]] = parts[2] elif kind == "zprange": start, end, old, new = (int(parts[1], 16), int(parts[2], 16), parts[3], parts[4]) scoped.append((start, end, old, new)) changed = 0 scoped_changed = 0 for i, line in enumerate(lines): m = LABEL_RE.match(line.rstrip("\n")) if m and m.group(1) in label_map: lines[i] = label_map[m.group(1)] + ":\n" changed += 1 continue # operand-side label references def repl(mo): name = mo.group(0) if name in label_map: changed_local[0] += 1 return label_map[name] return name changed_local = [0] new_line = re.sub(r"\b(?:Label_[0-9A-F]{4})\b", repl, line) for old, new in zp_map.items(): new_line = re.sub(rf"\b{old}\b", new, new_line) am = addr_re.search(new_line) if am and scoped: cpu = int(am.group(1), 16) for start, end, old, new in scoped: if start <= cpu <= end: new_line, n = re.subn(rf"\b{old}\b", new, new_line) scoped_changed += n lines[i] = new_line changed += changed_local[0] Path(path).write_text("".join(lines)) print(f"renamed {changed} label references, {len(label_map)} label defs, " f"{len(zp_map)} zero-page names, {scoped_changed} scoped zero-page uses in {path}") 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 read_write(mn): if mn in ZP_READ_ONLY: return 1, 0 if mn in ZP_WRITE_ONLY: return 0, 1 if mn in ZP_READ_WRITE: return 1, 1 return 1, 1 def write_locals(prg_path, notefile, out, *extra): """Rebuild locals.inc from the renamed source, merging the hand-written notes.""" order, labels, insns = parse(prg_path) notes = {} addr_of = {} for src in (notefile,) + extra: if not Path(src).exists(): continue for line in Path(src).read_text().splitlines(): if not line.strip() or line.startswith("#"): continue parts = line.split("\t") if parts[0] == "zprange": # reuse of one address under a second role m = re.match(r"Zp([0-9A-F]{2})$", parts[3]) if m: addr_of[parts[4]] = int(m.group(1), 16) continue if parts[0] != "zp": continue notes[parts[2]] = parts[3].strip() if len(parts) > 3 else "" m = re.match(r"Zp([0-9A-F]{2})$", parts[1]) if m: addr_of[parts[2]] = int(m.group(1), 16) zp = {} ident = re.compile(r"(? 4: where += f", +{len(e['where']) - 4} more" note = notes.get(name, "") tail = f" ; {note}" if note else "" lines.append(f"{decl:<52}; {total:>4} refs; reads={e['r']:<4} " f"writes={e['w']:<4} used at ${where}{tail}") Path(out).write_text("\n".join(lines) + "\n") print(f"wrote {len(zp)} zero-page aliases to {out}") def reflow(path, column=31): """Re-pad instruction lines so the trailing byte comment keeps its column. `column` is the zero-based index the `;` lands on. The repo's canonical audit-comment column is 35 (see _tools/align_audit_comments.py). """ lines = Path(path).read_text().splitlines(keepends=True) moved = 0 for i, line in enumerate(lines): body, sep, tail = line.partition("; CPU:") if not sep: continue text = body.rstrip() new = text.ljust(column) if len(new) < len(text) + 1: new = text + " " lines[i] = f"{new}; CPU:{tail}" moved += 1 Path(path).write_text("".join(lines)) print(f"reflowed {moved} instruction lines in {path}") BOILERPLATE = { "; Parameters: Undetermined in Pass 1 unless implied by the caller.": "; Parameters: Not yet resolved; the callers visible in the static trace show what is" " loaded before the call.", "; Returns: Undetermined in Pass 1; status flags and registers may carry results.": "; Returns: Not yet resolved; status flags and registers may carry results.", "; Clobbers: Undetermined in Pass 1.": "; Clobbers: Not yet resolved.", } def describe(path, purposefile): """Replace the Pass 1 boilerplate `; Global:` line with a real description. Routines with a hand-written purpose in `purposefile` get it verbatim; the rest get a generated line naming the routine and the call sites the static trace can see, which is factual rather than boilerplate. """ purposes = {} for line in Path(purposefile).read_text().splitlines(): if not line.strip() or line.startswith("#"): continue addr, text = line.split("\t", 1) purposes[int(addr, 16)] = text order, labels, insns = parse(path) by_line = sorted((rec["line"], rec["cpu"]) for rec in insns.values()) keys = [k for k, _ in by_line] name_at = {} current = None for kind, val in order: if kind == "label": current = val elif kind == "insn": name_at[val["line"]] = current callers = {} for rec in insns.values(): if rec["mn"] in ("jsr", "jmp"): m = re.match(r"^\$([0-9A-F]{4})$", rec["op"]) if m: callers.setdefault(int(m.group(1), 16), []).append((rec["cpu"], rec["mn"])) described = generated = 0 lines = Path(path).read_text().splitlines(keepends=True) for i, line in enumerate(lines): stripped = line.rstrip("\n") if stripped in BOILERPLATE: lines[i] = BOILERPLATE[stripped] + "\n" described += 1 continue if not line.startswith("; Routine: "): continue j = bisect.bisect_right(keys, i) if j >= len(by_line): continue cpu = by_line[j][1] text = purposes.get(cpu) if text is None: name = stripped[len("; Routine: "):] sites = sorted({c for c, _ in callers.get(cpu, [])}) where = ("; no call site is visible in the static trace" if not sites else "; called from " + ", ".join(f"${c:04X}" for c in sites[:8]) + (f" and {len(sites) - 8} more" if len(sites) > 8 else "")) text = f"{name}.{where}" generated += 1 for k in range(i, min(i + 10, len(lines))): if lines[k].startswith("; Global: "): lines[k] = f"; Global: {text}\n" break Path(path).write_text("".join(lines)) print(f"described {described} boilerplate headers, wrote {generated} generated " f"descriptions in {path}") DATA_HEADER_RE = re.compile(r"^; ---- data \$([0-9A-F]{4})-\$([0-9A-F]{4}) \((\d+) bytes\) ----$") BYTE_LINE_RE = re.compile( r"^ \.byte ((?:\$[0-9A-F]{2}(?:, )?)+)\s*; CPU:\$([0-9A-F]{4}) PRG:([0-9A-F]{4})\s+" r"((?:[0-9A-F]{2} ?)*[0-9A-F]{2})\s*$" ) def read_spec(path): """Parse a Pass 3 region spec: addr, end, label, format, comment.""" rows = [] for line in Path(path).read_text().splitlines(): if not line.strip() or line.startswith("#"): continue parts = line.split("\t") rows.append({ "start": int(parts[0], 16), "end": int(parts[1], 16), "label": parts[2], "fmt": parts[3], "comment": parts[4] if len(parts) > 4 else "", }) rows.sort(key=lambda r: r["start"]) return rows def structure(path, specfile): """Split Pass 1 data blocks into named, optionally per-record sub-blocks.""" spec = read_spec(specfile) lines = Path(path).read_text().splitlines(keepends=True) out = [] i = 0 while i < len(lines): m = DATA_HEADER_RE.match(lines[i].rstrip("\n")) if not m: out.append(lines[i]) i += 1 continue start, end = int(m.group(1), 16), int(m.group(2), 16) # collect the .byte body, skipping Pass 1 annotation comments body = [] i += 1 while i < len(lines): stripped = lines[i].rstrip("\n") if stripped.startswith("; ---- data"): break if stripped.startswith(";"): i += 1 continue if not BYTE_LINE_RE.match(stripped): break body.append(lines[i]) i += 1 blob = bytearray() for ln in body: for tok in BYTE_LINE_RE.match(ln.rstrip("\n")).group(1).split(", "): blob.append(int(tok.lstrip("$"), 16)) if len(blob) != end - start + 1: raise SystemExit(f"block ${start:04X}: body {len(blob)} != header " f"{end - start + 1}") out.extend(render_block(blob, start, spec)) Path(path).write_text("".join(out)) print(f"structured {len(spec)} data regions in {path}") def emit_bytes(blob, base, per_line=16): lines = [] for k in range(0, len(blob), per_line): chunk = blob[k:k + per_line] vals = ", ".join(f"${b:02X}" for b in chunk) raw = " ".join(f"{b:02X}" for b in chunk) lines.append(f" .byte {vals} ; CPU:${base + k:04X} " f"PRG:{base + k - 0x8000:04X} {raw}\n") return lines def render_block(blob, base, spec): """Render one data block as a sequence of named sub-blocks.""" lines = [] end = base + len(blob) - 1 entries = [s for s in spec if s["start"] < base + len(blob) and s["end"] >= base] entries.sort(key=lambda s: s["start"]) cursor = base for s in entries: if s["start"] > cursor: chunk = blob[cursor - base:s["start"] - base] lines.append(f"; ---- data ${cursor:04X}-${s['start'] - 1:04X} " f"({len(chunk)} bytes) ----\n") lines.extend(emit_bytes(chunk, cursor)) s0 = max(s["start"], base) s1 = min(s["end"], end) + 1 chunk = blob[s0 - base:s1 - base] if s["comment"]: lines.append(f"; {s['comment']}\n") lines.append(f"; ---- data ${s0:04X}-${s1 - 1:04X} ({len(chunk)} bytes) ----\n") lines.append(f"{s['label']}:\n") if s["fmt"] == "word": for k in range(0, len(chunk) - 1, 2): lo, hi = chunk[k], chunk[k + 1] tgt = lo | (hi << 8) raw = f"{lo:02X} {hi:02X}" lines.append(f" .word ${tgt:04X} ; entry {k // 2:2d} -> ${tgt:04X}" f" ; CPU:${s0 + k:04X} PRG:{s0 + k - 0x8000:04X} {raw}\n") elif s["fmt"] == "wordlo": for k in range(0, len(chunk), 2): lo, hi = chunk[k], chunk[k + 1] lines.append(f" .word ${lo | (hi << 8):04X} ; entry {k // 2:2d}" f" ; CPU:${s0 + k:04X} PRG:{s0 + k - 0x8000:04X} {lo:02X} {hi:02X}\n") else: lines.extend(emit_bytes(chunk, s0)) cursor = s1 if cursor <= end: chunk = blob[cursor - base:] lines.append(f"; ---- data ${cursor:04X}-${end:04X} ({len(chunk)} bytes) ----\n") lines.extend(emit_bytes(chunk, cursor)) return lines def main(): if len(sys.argv) < 3: sys.exit(__doc__) cmd = sys.argv[1] if cmd == "outline": outline(sys.argv[2] if len(sys.argv) > 2 else "prg.s") elif cmd == "show": show(sys.argv[2], sys.argv[3]) elif cmd == "apply": apply_map(sys.argv[2], sys.argv[3]) elif cmd == "reflow": # reflow [column] - the canonical audit comment column is 35 reflow(sys.argv[2], int(sys.argv[3]) if len(sys.argv) > 3 else 31) elif cmd == "describe": describe(sys.argv[2], sys.argv[3]) elif cmd == "structure": structure(sys.argv[2], sys.argv[3]) elif cmd == "locals": # locals write_locals(sys.argv[2], sys.argv[3], sys.argv[4], *sys.argv[5:]) elif cmd == "derive": # derive path, mapfile, outfile = sys.argv[2], sys.argv[3], sys.argv[4] order, labels, insns = parse(path) name_map = {} for line in Path(mapfile).read_text().splitlines(): if not line.strip() or line.startswith("#"): continue addr, name = line.split("\t") name_map[int(addr, 16)] = name derived = derive(insns, labels, name_map, vectors=(0x8000, 0x86CB, 0x8764)) rows = [f"label\t{old}\t{new}" for old, new in sorted(derived.items(), key=lambda kv: int(kv[0][6:], 16))] missing = [r for r in rows if "_Sub_" in r] Path(outfile).write_text("\n".join(rows) + "\n") print(f"derived {len(rows)} label names -> {outfile}") print(f" {len(missing)} still unnamed (no routine in the map)") else: sys.exit(__doc__) if __name__ == "__main__": main()