Pass 3 for NES PowerPad Test Card (U): name all data tables and VramWrite streams
- Decoded the VramWrite ($E1CC) stream format: <addr hi> <addr lo> <count> <bytes>, $00 terminator - Named all 9 payload streams ($E4CC-$E7C2, no gaps/overlaps) plus VramWriteSourcePointerTable at $F000 - Split PadBitTableLives into Player1TileTable ($E4B4) and Player2TileTable ($E4C0), 12 entries each (Y=0..11) - Decoded CHR font (digits $30-$39, letters $3A-$52, space $5F; 'W' missing from the A-Z run) and recovered on-screen text including 'PRESS A TO TEST SIDE-A' / 'SIDE-B' - Documented that the 16 KiB PRG is two byte-identical 8 KiB halves; $C000 copy is dead - Documented 7 unreached subroutines inside the live code region (kept as .byte per Pass-1 policy) - Fixed a stray duplicate sentence and stale _wip paths in root OVERVIEW.md Byte-identical ROM verified.
This commit is contained in:
1 parent
3ef16a6f99
commit
4c644d2a20
23 files changed
+17290
-507
No files matched your search
@@ -0,0 +1,700 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Recursive-trace NES PRG disassembler that emits ca65 source (Pass 1).
|
||||
|
||||
Classifies every PRG byte as reachable code or data by recursive descent from
|
||||
the RESET/NMI/IRQ vectors, then writes a byte-identical ca65 file where each
|
||||
instruction carries a trailing `; CPU:$xxxx PRG:xxxx AA BB` audit comment and
|
||||
every unreached byte stays as an explicit `.byte` line (never deleted).
|
||||
|
||||
Usage:
|
||||
nesdis.py --prg prg.bin --base 0x8000 --project "MUSCLE (U)" \
|
||||
--out prg.s --locals locals.inc [--pass2]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Official NMOS 6502 opcode table: opcode -> (mnemonic, addressing mode)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
IMP, ACC, IMM, ZP, ZPX, ZPY, ABS, ABSX, ABSY, IND, INDX, INDY, REL = range(13)
|
||||
|
||||
OPCODES = {
|
||||
0x00: ("brk", IMP), 0x01: ("ora", INDX), 0x05: ("ora", ZP), 0x06: ("asl", ZP),
|
||||
0x08: ("php", IMP), 0x09: ("ora", IMM), 0x0A: ("asl", ACC), 0x0D: ("ora", ABS),
|
||||
0x0E: ("asl", ABS), 0x10: ("bpl", REL), 0x11: ("ora", INDY), 0x15: ("ora", ZPX),
|
||||
0x16: ("asl", ZPX), 0x18: ("clc", IMP), 0x19: ("ora", ABSY), 0x1D: ("ora", ABSX),
|
||||
0x1E: ("asl", ABSX), 0x20: ("jsr", ABS), 0x21: ("and", INDX), 0x24: ("bit", ZP),
|
||||
0x25: ("and", ZP), 0x26: ("rol", ZP), 0x28: ("plp", IMP), 0x29: ("and", IMM),
|
||||
0x2A: ("rol", ACC), 0x2C: ("bit", ABS), 0x2D: ("and", ABS), 0x2E: ("rol", ABS),
|
||||
0x30: ("bmi", REL), 0x31: ("and", INDY), 0x35: ("and", ZPX), 0x36: ("rol", ZPX),
|
||||
0x38: ("sec", IMP), 0x39: ("and", ABSY), 0x3D: ("and", ABSX), 0x3E: ("rol", ABSX),
|
||||
0x40: ("rti", IMP), 0x41: ("eor", INDX), 0x45: ("eor", ZP), 0x46: ("lsr", ZP),
|
||||
0x48: ("pha", IMP), 0x49: ("eor", IMM), 0x4A: ("lsr", ACC), 0x4C: ("jmp", ABS),
|
||||
0x4D: ("eor", ABS), 0x4E: ("lsr", ABS), 0x50: ("bvc", REL), 0x51: ("eor", INDY),
|
||||
0x55: ("eor", ZPX), 0x56: ("lsr", ZPX), 0x58: ("cli", IMP), 0x59: ("eor", ABSY),
|
||||
0x5D: ("eor", ABSX), 0x5E: ("lsr", ABSX), 0x60: ("rts", IMP), 0x61: ("adc", INDX),
|
||||
0x65: ("adc", ZP), 0x66: ("ror", ZP), 0x68: ("pla", IMP), 0x69: ("adc", IMM),
|
||||
0x6A: ("ror", ACC), 0x6C: ("jmp", IND), 0x6D: ("adc", ABS), 0x6E: ("ror", ABS),
|
||||
0x70: ("bvs", REL), 0x71: ("adc", INDY), 0x75: ("adc", ZPX), 0x76: ("ror", ZPX),
|
||||
0x78: ("sei", IMP), 0x79: ("adc", ABSY), 0x7D: ("adc", ABSX), 0x7E: ("ror", ABSX),
|
||||
0x81: ("sta", INDX), 0x84: ("sty", ZP), 0x85: ("sta", ZP), 0x86: ("stx", ZP),
|
||||
0x88: ("dey", IMP), 0x8A: ("txa", IMP), 0x8C: ("sty", ABS), 0x8D: ("sta", ABS),
|
||||
0x8E: ("stx", ABS), 0x90: ("bcc", REL), 0x91: ("sta", INDY), 0x94: ("sty", ZPX),
|
||||
0x95: ("sta", ZPX), 0x96: ("stx", ZPY), 0x98: ("tya", IMP), 0x99: ("sta", ABSY),
|
||||
0x9A: ("txs", IMP), 0x9D: ("sta", ABSX), 0xA0: ("ldy", IMM), 0xA1: ("lda", INDX),
|
||||
0xA2: ("ldx", IMM), 0xA4: ("ldy", ZP), 0xA5: ("lda", ZP), 0xA6: ("ldx", ZP),
|
||||
0xA8: ("tay", IMP), 0xA9: ("lda", IMM), 0xAA: ("tax", IMP), 0xAC: ("ldy", ABS),
|
||||
0xAD: ("lda", ABS), 0xAE: ("ldx", ABS), 0xB0: ("bcs", REL), 0xB1: ("lda", INDY),
|
||||
0xB4: ("ldy", ZPX), 0xB5: ("lda", ZPX), 0xB6: ("ldx", ZPY), 0xB8: ("clv", IMP),
|
||||
0xB9: ("lda", ABSY), 0xBA: ("tsx", IMP), 0xBC: ("ldy", ABSX), 0xBD: ("lda", ABSX),
|
||||
0xBE: ("ldx", ABSY), 0xC0: ("cpy", IMM), 0xC1: ("cmp", INDX), 0xC4: ("cpy", ZP),
|
||||
0xC5: ("cmp", ZP), 0xC6: ("dec", ZP), 0xC8: ("iny", IMP), 0xC9: ("cmp", IMM),
|
||||
0xCA: ("dex", IMP), 0xCC: ("cpy", ABS), 0xCD: ("cmp", ABS), 0xCE: ("dec", ABS),
|
||||
0xD0: ("bne", REL), 0xD1: ("cmp", INDY), 0xD5: ("cmp", ZPX), 0xD6: ("dec", ZPX),
|
||||
0xD8: ("cld", IMP), 0xD9: ("cmp", ABSY), 0xDD: ("cmp", ABSX), 0xDE: ("dec", ABSX),
|
||||
0xE0: ("cpx", IMM), 0xE1: ("sbc", INDX), 0xE4: ("cpx", ZP), 0xE5: ("sbc", ZP),
|
||||
0xE6: ("inc", ZP), 0xE8: ("inx", IMP), 0xE9: ("sbc", IMM), 0xEA: ("nop", IMP),
|
||||
0xEC: ("cpx", ABS), 0xED: ("sbc", ABS), 0xEE: ("inc", ABS), 0xF0: ("beq", REL),
|
||||
0xF1: ("sbc", INDY), 0xF5: ("sbc", ZPX), 0xF6: ("inc", ZPX), 0xF8: ("sed", IMP),
|
||||
0xF9: ("sbc", ABSY), 0xFD: ("sbc", ABSX), 0xFE: ("inc", ABSX),
|
||||
}
|
||||
|
||||
MODE_LEN = {IMP: 1, ACC: 1, IMM: 2, ZP: 2, ZPX: 2, ZPY: 2, REL: 2,
|
||||
ABS: 3, ABSX: 3, ABSY: 3, IND: 3, INDX: 2, INDY: 2}
|
||||
|
||||
# Instructions after which control never falls through.
|
||||
TERMINATORS = {"jmp", "rts", "rti", "brk"}
|
||||
|
||||
BRANCHES = {"bpl", "bmi", "bvc", "bvs", "bcc", "bcs", "bne", "beq"}
|
||||
|
||||
|
||||
class Insn:
|
||||
"""One decoded 6502 instruction."""
|
||||
|
||||
def __init__(self, addr, length, text):
|
||||
self.addr = addr
|
||||
self.length = length
|
||||
self.text = text # rendered operand text (without mnemonic)
|
||||
self.opcode = None
|
||||
self.mnemonic = None
|
||||
self.mode = None
|
||||
self.target = None # branch/jump destination (CPU address)
|
||||
self.jsr_target = None # for jsr/jmp abs: subroutine/entry address
|
||||
|
||||
|
||||
def hex16(v):
|
||||
return f"${v:04X}"
|
||||
|
||||
|
||||
def render(prg, base, opcode, pc):
|
||||
"""Decode one instruction at pc. Returns an Insn or None when illegal."""
|
||||
if pc >= len(prg):
|
||||
return None
|
||||
entry = OPCODES.get(prg[pc])
|
||||
if entry is None:
|
||||
return None
|
||||
mnemonic, mode = entry
|
||||
length = MODE_LEN[mode]
|
||||
if pc + length > len(prg):
|
||||
return None
|
||||
|
||||
b1 = prg[pc + 1] if length > 1 else 0
|
||||
b2 = prg[pc + 2] if length > 2 else 0
|
||||
insn = Insn(pc + base, length, "")
|
||||
insn.opcode = prg[pc]
|
||||
insn.mnemonic = mnemonic
|
||||
insn.mode = mode
|
||||
|
||||
if mode == IMP:
|
||||
insn.text = ""
|
||||
elif mode == ACC:
|
||||
insn.text = "a"
|
||||
elif mode == IMM:
|
||||
insn.text = f"#${b1:02X}"
|
||||
elif mode == ZP:
|
||||
insn.text = f"Zp{b1:02X}"
|
||||
elif mode == ZPX:
|
||||
insn.text = f"Zp{b1:02X},x"
|
||||
elif mode == ZPY:
|
||||
insn.text = f"Zp{b1:02X},y"
|
||||
elif mode == ABS:
|
||||
insn.text = hex16(b1 | (b2 << 8))
|
||||
elif mode == ABSX:
|
||||
insn.text = f"{hex16(b1 | (b2 << 8))},x"
|
||||
elif mode == ABSY:
|
||||
insn.text = f"{hex16(b1 | (b2 << 8))},y"
|
||||
elif mode == IND:
|
||||
insn.text = f"({hex16(b1 | (b2 << 8))})"
|
||||
elif mode == INDX:
|
||||
insn.text = f"(Zp{b1:02X},x)"
|
||||
elif mode == INDY:
|
||||
insn.text = f"(Zp{b1:02X}),y"
|
||||
elif mode == REL:
|
||||
offset = b1 - 256 if b1 > 127 else b1
|
||||
insn.target = base + pc + 2 + offset
|
||||
insn.text = f"Label_{insn.target:04X}"
|
||||
return insn
|
||||
|
||||
|
||||
def trace(prg, base, seeds):
|
||||
"""Recursive descent from seeds. Returns (insns, invalid, indirect, targets, overlap)."""
|
||||
insns = {}
|
||||
targets = set(seeds)
|
||||
invalid = []
|
||||
indirect = []
|
||||
overlap = []
|
||||
queue = list(seeds)
|
||||
|
||||
while queue:
|
||||
addr = queue.pop(0)
|
||||
if addr in insns:
|
||||
continue
|
||||
off = addr - base
|
||||
if off < 0 or off >= len(prg):
|
||||
continue
|
||||
cur = off
|
||||
while True:
|
||||
if cur in insns:
|
||||
break
|
||||
if cur < 0 or cur >= len(prg):
|
||||
break
|
||||
insn = render(prg, base, prg[cur], cur)
|
||||
if insn is None:
|
||||
invalid.append(base + cur)
|
||||
break
|
||||
clash = [c for c in range(cur + 1, cur + insn.length) if c in insns]
|
||||
if clash:
|
||||
overlap.append((base + cur, insn.mnemonic, [base + c for c in clash]))
|
||||
insns[cur] = insn
|
||||
|
||||
# Follow control transfers.
|
||||
if insn.mnemonic in BRANCHES:
|
||||
t = insn.target
|
||||
if base <= t < base + len(prg):
|
||||
targets.add(t)
|
||||
queue.append(t)
|
||||
elif insn.mnemonic == "jsr":
|
||||
t = int(insn.text[1:], 16)
|
||||
insn.jsr_target = t
|
||||
if base <= t < base + len(prg):
|
||||
targets.add(t)
|
||||
queue.append(t)
|
||||
elif insn.mnemonic == "jmp":
|
||||
if insn.mode == ABS:
|
||||
t = int(insn.text[1:], 16)
|
||||
insn.jsr_target = t
|
||||
if base <= t < base + len(prg):
|
||||
targets.add(t)
|
||||
queue.append(t)
|
||||
break # unconditional: no fall-through
|
||||
else:
|
||||
ptr = int(insn.text.strip("()")[1:], 16)
|
||||
indirect.append((base + cur, ptr))
|
||||
if base <= ptr < base + len(prg) - 1:
|
||||
t = prg[ptr - base] | (prg[ptr - base + 1] << 8)
|
||||
if base <= t < base + len(prg):
|
||||
targets.add(t)
|
||||
queue.append(t)
|
||||
break
|
||||
elif insn.mnemonic in TERMINATORS:
|
||||
break
|
||||
|
||||
cur += insn.length
|
||||
|
||||
return insns, invalid, indirect, targets, overlap
|
||||
|
||||
|
||||
def scan_orphans(prg, base, insns, min_len=8):
|
||||
"""Contiguous untraced runs, used to hunt for code missed by static trace.
|
||||
|
||||
Coverage includes operand bytes, so only genuinely untouched ROM is reported.
|
||||
"""
|
||||
covered = set()
|
||||
for off, insn in insns.items():
|
||||
for k in range(off, off + insn.length):
|
||||
covered.add(k)
|
||||
out = []
|
||||
n = len(prg)
|
||||
i = 0
|
||||
while i < n:
|
||||
if i in covered:
|
||||
i += 1
|
||||
continue
|
||||
j = i
|
||||
while j < n and j not in covered:
|
||||
j += 1
|
||||
if j - i >= min_len:
|
||||
out.append((i, j - i))
|
||||
i = j
|
||||
return out
|
||||
|
||||
|
||||
def probe(prg, base, addr, max_insns=64):
|
||||
"""Decode linearly from addr without recording; used to judge candidate seeds."""
|
||||
cur = addr - base
|
||||
n = len(prg)
|
||||
seq = []
|
||||
term = None
|
||||
for _ in range(max_insns):
|
||||
if cur >= n:
|
||||
break
|
||||
insn = render(prg, base, prg[cur], cur)
|
||||
if insn is None:
|
||||
return seq, f"illegal ${base + cur:04X}"
|
||||
seq.append(insn)
|
||||
cur += insn.length
|
||||
if insn.mnemonic in TERMINATORS:
|
||||
term = insn.mnemonic
|
||||
break
|
||||
return seq, term
|
||||
|
||||
|
||||
# Bytes that may legally precede a routine entry point.
|
||||
ENTRY_PREDECESSORS = {0x60, 0x4C, 0x6C, 0x40, 0x00, 0xEA, 0x6A, 0x4A, 0x2A, 0x0A}
|
||||
|
||||
|
||||
def operand_value(insn):
|
||||
"""Absolute address of an ABS/ABSX/ABSY/IND operand, or None."""
|
||||
if insn.mode not in (ABS, ABSX, ABSY, IND):
|
||||
return None
|
||||
token = insn.text.strip("()").split(",")[0].lstrip("$")
|
||||
return int(token, 16)
|
||||
|
||||
|
||||
def operand_in_prg(insn, base, n):
|
||||
value = operand_value(insn)
|
||||
return value is None or (base <= value < base + n)
|
||||
|
||||
|
||||
# Mnemonics that only read, only write, or do both, for zero-page reference counting.
|
||||
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 classify_zp(insn):
|
||||
"""Return (reads, writes) for a zero-page operand."""
|
||||
m = insn.mnemonic
|
||||
if m in ZP_READ_ONLY:
|
||||
return 1, 0
|
||||
if m in ZP_WRITE_ONLY:
|
||||
return 0, 1
|
||||
if m in ZP_READ_WRITE:
|
||||
return 1, 1
|
||||
if m in ("lda", "ldx", "ldy", "cmp", "cpx", "cpy", "bit", "sbc", "adc", "and",
|
||||
"ora", "eor"):
|
||||
return 1, 0
|
||||
return 1, 1
|
||||
|
||||
|
||||
def find_referenced_stubs(prg, base, insns, min_insns=4):
|
||||
"""Candidate routine entries that a data table elsewhere in the image points at.
|
||||
|
||||
This recovers code reached only through computed dispatch (jump tables feeding
|
||||
`jmp (zp)`, RTS tables), which a plain recursive trace from the vectors cannot see.
|
||||
A candidate must start right after a plausible terminator, decode linearly with no
|
||||
illegal opcode, run at least `min_insns` instructions, and end in rts/jmp.
|
||||
|
||||
Offsets that are operand bytes of already-decoded instructions are rejected first:
|
||||
that is what separates a real entry point from a decode that merely starts inside
|
||||
a neighbouring instruction's operand field.
|
||||
"""
|
||||
n = len(prg)
|
||||
# Every 16-bit little-endian window in the image, mapped to its host offsets.
|
||||
refs = {}
|
||||
for off in range(n - 1):
|
||||
refs.setdefault(prg[off] | (prg[off + 1] << 8), []).append(off)
|
||||
|
||||
# Operand bytes belong to an instruction already known to be reached code.
|
||||
operand_bytes = set()
|
||||
for off, insn in insns.items():
|
||||
for k in range(off + 1, off + insn.length):
|
||||
operand_bytes.add(k)
|
||||
|
||||
stubs = []
|
||||
for off in range(n):
|
||||
if off in insns or off in operand_bytes:
|
||||
continue
|
||||
if off > 0 and prg[off - 1] not in ENTRY_PREDECESSORS:
|
||||
continue
|
||||
addr = base + off
|
||||
seq, term = probe(prg, base, addr)
|
||||
if term not in ("rts", "jmp"):
|
||||
continue
|
||||
if len(seq) < min_insns:
|
||||
continue
|
||||
# Reject anything that runs into already-decoded code mid-stream.
|
||||
if any((i.addr - base) in insns for i in seq):
|
||||
continue
|
||||
# Reject clean decodes that no table anywhere in the image refers to.
|
||||
if not refs.get(addr):
|
||||
continue
|
||||
# Reject stubs whose operands point outside the PRG window (data decode).
|
||||
if any(not operand_in_prg(i, base, n) for i in seq):
|
||||
continue
|
||||
stubs.append(addr)
|
||||
return stubs
|
||||
|
||||
|
||||
def recover(prg, base, seeds, tables=(), max_rounds=12):
|
||||
"""Trace, iteratively seed table-referenced stubs, then drop seeds that collide.
|
||||
|
||||
A recovered stub that turns out to sit inside another instruction's operand field is
|
||||
a mis-aligned decode, not a routine: it is removed and the trace is redone.
|
||||
`tables` are explicitly declared 16-bit dispatch tables (addr, entry count) whose
|
||||
entries are routine entry points; they are seeded on every round.
|
||||
"""
|
||||
vector_seeds = list(seeds)
|
||||
table_seeds = []
|
||||
junk = []
|
||||
for addr, count in tables:
|
||||
off = addr - base
|
||||
for k in range(count):
|
||||
lo = prg[off + 2 * k]
|
||||
hi = prg[off + 2 * k + 1]
|
||||
entry = lo | (hi << 8)
|
||||
# Dispatch tables leave unused slots filled with whatever followed; only
|
||||
# keep slots that actually begin a decodable instruction.
|
||||
if render(prg, base, prg[entry - base], entry - base) is None:
|
||||
junk.append(entry)
|
||||
continue
|
||||
table_seeds.append(entry)
|
||||
stub_seeds = []
|
||||
rejected = []
|
||||
result = None
|
||||
|
||||
for _ in range(max_rounds):
|
||||
result = trace(prg, base, vector_seeds + table_seeds + stub_seeds)
|
||||
insns, invalid, indirect, targets, overlap = result
|
||||
|
||||
operand_bytes = set()
|
||||
for off, insn in insns.items():
|
||||
for k in range(off + 1, off + insn.length):
|
||||
operand_bytes.add(k)
|
||||
|
||||
keep, drop = [], []
|
||||
for addr in stub_seeds:
|
||||
(drop if (addr - base) in operand_bytes else keep).append(addr)
|
||||
stub_seeds = keep
|
||||
|
||||
stubs = find_referenced_stubs(prg, base, insns)
|
||||
fresh = [s for s in stubs if s not in targets and s not in stub_seeds]
|
||||
if not fresh and not drop:
|
||||
break
|
||||
stub_seeds.extend(fresh)
|
||||
rejected.extend(drop)
|
||||
|
||||
return result[0], result[1], result[2], result[3], result[4], stub_seeds, rejected, junk
|
||||
|
||||
|
||||
def parse_ranges(spec):
|
||||
"""Parse `ADDR:LEN` pairs into (address, length) tuples."""
|
||||
out = []
|
||||
for tok in spec.split(","):
|
||||
tok = tok.strip()
|
||||
if not tok:
|
||||
continue
|
||||
addr, length = tok.split(":")
|
||||
out.append((int(addr, 16), int(length, 10)))
|
||||
return out
|
||||
|
||||
|
||||
def parse_tables(spec):
|
||||
"""Parse `ADDR:COUNT` pairs into (address, entry_count) tuples."""
|
||||
return parse_ranges(spec)
|
||||
|
||||
|
||||
def build_refs(prg, base, insns, regions):
|
||||
"""Attribute each data region to the decoded instructions that reference it.
|
||||
|
||||
`regions` are (offset, length) pairs from scan_orphans; results are keyed by the
|
||||
region's start CPU address so the emitter can look them up directly.
|
||||
"""
|
||||
refs = {base + off: [] for off, _ in regions}
|
||||
if not regions:
|
||||
return refs
|
||||
for off in sorted(insns):
|
||||
insn = insns[off]
|
||||
value = operand_value(insn)
|
||||
if value is None:
|
||||
continue
|
||||
addr = base + off
|
||||
for start, length in regions:
|
||||
if base + start <= value < base + start + length:
|
||||
refs[base + start].append((addr, insn, value))
|
||||
break
|
||||
return refs
|
||||
|
||||
|
||||
def byte_columns(values):
|
||||
return ", ".join(f"${v:02X}" for v in values)
|
||||
|
||||
|
||||
def emit_code(prg, base, insns, targets, out, project, semantic, refs=None):
|
||||
"""Render the full PRG as ca65 text: code where traced, .byte elsewhere."""
|
||||
n = len(prg)
|
||||
refs = refs or {}
|
||||
name_of = {}
|
||||
|
||||
# Seed semantic entry-point names.
|
||||
for addr, name in semantic.items():
|
||||
name_of[addr] = name
|
||||
|
||||
i = 0
|
||||
routine_header = [
|
||||
"; -----------------------------------------------------------------------------",
|
||||
"; Routine: {name}",
|
||||
"; Global: Contextual control-flow entry identified from its parent routine and incoming branch usage.",
|
||||
"; Local: Continues its parent routine along the condition or operation encoded in this label,",
|
||||
"; then returns, branches again, or rejoins shared code as documented by the instructions.",
|
||||
"; Parameters: Undetermined in Pass 1 unless implied by the caller.",
|
||||
"; Returns: Undetermined in Pass 1; status flags and registers may carry results.",
|
||||
"; Clobbers: Undetermined in Pass 1.",
|
||||
"; -----------------------------------------------------------------------------",
|
||||
]
|
||||
|
||||
while i < n:
|
||||
cpu = base + i
|
||||
|
||||
if i in insns:
|
||||
insn = insns[i]
|
||||
if cpu in targets or cpu in name_of:
|
||||
name = name_of.get(cpu) or f"Label_{cpu:04X}"
|
||||
for line in routine_header:
|
||||
out.append(line.format(name=name))
|
||||
out.append(f"{name}:")
|
||||
text = f"{insn.mnemonic} {insn.text}".strip()
|
||||
raw = " ".join(f"{b:02X}" for b in prg[i:i + insn.length])
|
||||
line = f" {text}"
|
||||
out.append(f"{line:<31}; CPU:${cpu:04X} PRG:{i:04X} {raw}")
|
||||
i += insn.length
|
||||
continue
|
||||
|
||||
# Data run.
|
||||
j = i
|
||||
while j < n and j not in insns:
|
||||
j += 1
|
||||
cpu_end = base + j - 1
|
||||
out.append(f"; ---- data ${cpu:04X}-${cpu_end:04X} ({j - i} bytes) ----")
|
||||
for addr, insn, value in refs.get(cpu, [])[:6]:
|
||||
text = f"{insn.mnemonic} {insn.text}".strip()
|
||||
out.append(f"; referenced by `{text}` at ${addr:04X} "
|
||||
f"(operand ${value:04X} falls inside this region)")
|
||||
if len(refs.get(cpu, [])) > 6:
|
||||
out.append(f"; ... and {len(refs[cpu]) - 6} further references from decoded code")
|
||||
if cpu in name_of:
|
||||
out.append(f"{name_of[cpu]}:")
|
||||
k = i
|
||||
while k < j:
|
||||
chunk = prg[k:min(k + 16, j)]
|
||||
raw = " ".join(f"{b:02X}" for b in chunk)
|
||||
out.append(f" .byte {byte_columns(chunk)} ; CPU:${base + k:04X} PRG:{k:04X} {raw}")
|
||||
k += 16
|
||||
i = j
|
||||
|
||||
return name_of
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--prg", required=True)
|
||||
ap.add_argument("--base", default="0x8000")
|
||||
ap.add_argument("--project", required=True)
|
||||
ap.add_argument("--out", required=True)
|
||||
ap.add_argument("--locals", default=None)
|
||||
ap.add_argument("--zp-aliases", default=None,
|
||||
help="comma list addr=name pairs for semantic zero-page names")
|
||||
ap.add_argument("--zp-strict", action="store_true",
|
||||
help="use ZpXX for every zero-page operand (default)")
|
||||
ap.add_argument("--extra-seed", default="",
|
||||
help="comma list of CPU addresses to seed in addition to the vectors")
|
||||
ap.add_argument("--report-orphans", type=int, default=0,
|
||||
help="print untraced runs of at least N bytes (missed-code hunting)")
|
||||
ap.add_argument("--auto-seed", action="store_true",
|
||||
help="iteratively seed table-referenced routine stubs to recover "
|
||||
"computed-dispatch code, then report every recovered entry")
|
||||
ap.add_argument("--table", default="",
|
||||
help="comma list of ADDR:COUNT declaring a 16-bit dispatch table whose "
|
||||
"entries are routine entry points (e.g. 0x9237:40)")
|
||||
ap.add_argument("--data", default="",
|
||||
help="comma list of ADDR:LEN forcing a region back to data even when "
|
||||
"static fall-through reached it (branch-over-data)")
|
||||
ap.add_argument("--probe", default="",
|
||||
help="comma list of CPU addresses to decode linearly for inspection")
|
||||
args = ap.parse_args()
|
||||
|
||||
base = int(args.base, 16)
|
||||
prg = Path(args.prg).read_bytes()
|
||||
if len(prg) < 6:
|
||||
sys.exit("PRG image too small for vectors")
|
||||
|
||||
nmi = prg[-6] | (prg[-5] << 8)
|
||||
rst = prg[-4] | (prg[-3] << 8)
|
||||
irq = prg[-2] | (prg[-1] << 8)
|
||||
|
||||
extra = [int(a, 16) for a in args.extra_seed.split(",") if a.strip()]
|
||||
|
||||
if args.probe:
|
||||
for tok in args.probe.split(","):
|
||||
tok = tok.strip()
|
||||
if not tok:
|
||||
continue
|
||||
addr = int(tok, 16)
|
||||
seq, term = probe(prg, base, addr)
|
||||
print(f"probe {tok}: {len(seq)} insns, terminator={term}")
|
||||
for insn in seq:
|
||||
raw = " ".join(f"{b:02X}" for b in prg[insn.addr - base:insn.addr - base + insn.length])
|
||||
text = f"{insn.mnemonic} {insn.text}".strip()
|
||||
print(f" ${insn.addr:04X} {raw:<9} {text}")
|
||||
return
|
||||
|
||||
seeds = [rst, nmi, irq] + extra
|
||||
tables = parse_tables(args.table)
|
||||
forced = parse_ranges(args.data)
|
||||
rejected = []
|
||||
junk = []
|
||||
if args.auto_seed or tables:
|
||||
insns, invalid, indirect, targets, overlap, recovered, rejected, junk = recover(
|
||||
prg, base, seeds, tables)
|
||||
else:
|
||||
insns, invalid, indirect, targets, overlap = trace(prg, base, seeds)
|
||||
recovered = []
|
||||
|
||||
# A branch that is always taken leaves its fall-through bytes as data; the trace
|
||||
# cannot tell, so the caller names those spans explicitly.
|
||||
overridden = []
|
||||
for addr, length in forced:
|
||||
for k in range(addr, addr + length):
|
||||
if k - base in insns:
|
||||
overridden.append(k)
|
||||
del insns[k - base]
|
||||
targets.discard(k)
|
||||
if forced:
|
||||
invalid = [a for a in invalid if not any(a0 <= a < a0 + n for a0, n in forced)]
|
||||
|
||||
semantic = OrderedDict()
|
||||
semantic[rst] = "Reset"
|
||||
semantic[nmi] = "NmiHandler"
|
||||
semantic[irq] = "IrqHandler"
|
||||
|
||||
regions = scan_orphans(prg, base, insns, 1)
|
||||
refs = build_refs(prg, base, insns, regions)
|
||||
|
||||
out = []
|
||||
out.append(f"; {args.project} NES disassembly - Pass 1")
|
||||
out.append("; Byte-oriented ca65 source generated from the supplied PRG image.")
|
||||
out.append("; Recursively reachable instructions are emitted explicitly with address/byte comments.")
|
||||
out.append("; Unreached bytes remain explicit .byte blocks; no ROM byte is deleted or reformatted.")
|
||||
out.append("; Pass 2 replaces address-derived labels, RAM aliases, and table names while")
|
||||
out.append("; preserving byte identity.")
|
||||
out.append(";")
|
||||
out.append(f"; Vectors: NMI=${nmi:04X} RESET=${rst:04X} IRQ=${irq:04X}")
|
||||
out.append(f"; PRG mapped at ${base:04X}-${base + len(prg) - 1:04X} (NROM, mirrored to the upper half)")
|
||||
out.append(";")
|
||||
out.append("; Pass 1 trace provenance (the audit trail behind every classification below):")
|
||||
if tables:
|
||||
for addr, count in tables:
|
||||
out.append(f"; dispatch table ${addr:04X}, {count} x 16-bit entries, seeded as routine entries")
|
||||
if recovered:
|
||||
out.append(f"; {len(set(recovered))} further routine entries recovered from table"
|
||||
" references elsewhere in the image:")
|
||||
out.append(f"; {', '.join(f'${a:04X}' for a in sorted(set(recovered)))}")
|
||||
if extra:
|
||||
out.append("; entries seeded by hand after inspecting code-shaped but unreferenced blocks:")
|
||||
out.append(f"; {', '.join(f'${a:04X}' for a in extra)}")
|
||||
if forced:
|
||||
for addr, length in forced:
|
||||
out.append(f"; ${addr:04X}-${addr + length - 1:04X} forced back to data:"
|
||||
" the only path into it is a branch that is always taken")
|
||||
if indirect:
|
||||
out.append("; indirect dispatch through zero page (not followed statically):")
|
||||
for addr, ptr in indirect:
|
||||
out.append(f"; jmp (${ptr:04X}) at ${addr:04X}")
|
||||
out.append(";")
|
||||
out.append('.setcpu "6502"')
|
||||
out.append("")
|
||||
out.append('.include "symbols.inc"')
|
||||
out.append('.include "locals.inc"')
|
||||
out.append("")
|
||||
out.append('.segment "PRG"')
|
||||
out.append("")
|
||||
|
||||
emit_code(prg, base, insns, targets, out, args.project, semantic, refs)
|
||||
|
||||
Path(args.out).write_text("\n".join(out) + "\n")
|
||||
|
||||
code_bytes = sum(i.length for i in insns.values())
|
||||
print(f"vectors : NMI=${nmi:04X} RESET=${rst:04X} IRQ=${irq:04X}")
|
||||
if extra:
|
||||
print(f"extra seed: {[hex(a) for a in extra]}")
|
||||
if tables:
|
||||
print(f"tables : {', '.join(f'${a:04X}x{c}' for a, c in tables)}")
|
||||
print(f"PRG : {len(prg)} bytes at ${base:04X}")
|
||||
print(f"code : {len(insns)} instructions / {code_bytes} bytes "
|
||||
f"({code_bytes * 100.0 / len(prg):.1f}%)")
|
||||
print(f"data : {len(prg) - code_bytes} bytes")
|
||||
print(f"labels : {len(targets | set(semantic))}")
|
||||
if invalid:
|
||||
print(f"INVALID : {[hex(a) for a in sorted(set(invalid))]}")
|
||||
if indirect:
|
||||
print(f"INDIRECT : {[(hex(a), hex(p)) for a, p in indirect]}")
|
||||
if overlap:
|
||||
print(f"OVERLAP : {[(hex(a), m, [hex(c) for c in clash]) for a, m, clash in overlap]}")
|
||||
if recovered:
|
||||
print(f"RECOVERED : {len(recovered)} table-referenced routine entries")
|
||||
print(f" {', '.join(hex(a) for a in sorted(set(recovered)))}")
|
||||
if rejected:
|
||||
print(f"REJECTED : {', '.join(hex(a) for a in sorted(set(rejected)))} "
|
||||
f"(mis-aligned stubs inside another instruction's operand)")
|
||||
if junk:
|
||||
print(f"JUNK SLOT : {', '.join(hex(a) for a in sorted(set(junk)))} "
|
||||
f"(unused dispatch-table slots, not decodable)")
|
||||
if forced:
|
||||
print(f"FORCED : {', '.join(f'${a:04X}+${n:02X}' for a, n in forced)} (branch-over-data)")
|
||||
if overridden:
|
||||
print(f" overrode {len(overridden)} fall-through bytes: "
|
||||
f"{', '.join(hex(a) for a in sorted(set(overridden)))}")
|
||||
if args.report_orphans:
|
||||
for off, length in scan_orphans(prg, base, insns, args.report_orphans):
|
||||
print(f"ORPHAN : ${base + off:04X}-${base + off + length - 1:04X} ({length} bytes)")
|
||||
|
||||
# Collect zero-page operands actually referenced so locals.inc can list them.
|
||||
if args.locals:
|
||||
zp = {}
|
||||
for off in sorted(insns):
|
||||
insn = insns[off]
|
||||
if insn.mode not in (ZP, ZPX, ZPY, INDX, INDY):
|
||||
continue
|
||||
addr = int(insn.text.replace("(", " ").replace(")", " ").replace(",", " ").split()[0][2:], 16)
|
||||
reads, writes = classify_zp(insn)
|
||||
rec = zp.setdefault(addr, {"reads": 0, "writes": 0, "where": []})
|
||||
rec["reads"] += reads
|
||||
rec["writes"] += writes
|
||||
if base + off not in rec["where"]:
|
||||
rec["where"].append(base + off)
|
||||
|
||||
lines = [f"; {args.project} application-specific RAM aliases - Pass 1",
|
||||
"; Generated from every direct zero-page operand in reachable code:",
|
||||
"; ref counts are reads/writes, `where` lists the referencing addresses.",
|
||||
"; ZpXX names indicate storage with a role not yet unique enough for a behavioral name;",
|
||||
"; Pass 2 replaces them with names derived from observed usage.",
|
||||
""]
|
||||
for addr in sorted(zp):
|
||||
rec = zp[addr]
|
||||
total = rec["reads"] + rec["writes"]
|
||||
name = f"Zp{addr:02X}"
|
||||
where = ", ".join(f"{w:04X}" for w in rec["where"][:4])
|
||||
if len(rec["where"]) > 4:
|
||||
where += f", +{len(rec['where']) - 4} more"
|
||||
lines.append(f"{name:<24}= ${addr:02X} ; {total} refs; "
|
||||
f"reads={rec['reads']} writes={rec['writes']}; used at ${where}")
|
||||
Path(args.locals).write_text("\n".join(lines) + "\n")
|
||||
print(f"locals : {len(zp)} zero-page aliases written to {args.locals}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+626
@@ -0,0 +1,626 @@
|
||||
#!/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 `kind<TAB>old<TAB>new` 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<text>[a-z]{3})\s*(?P<op>.*?)\s*"
|
||||
r"; CPU:\$(?P<cpu>[0-9A-F]{4}) PRG:(?P<prg>[0-9A-F]{4})\s+"
|
||||
r"(?P<raw>(?:[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"(?<![$\w])[A-Za-z_][A-Za-z0-9_]*")
|
||||
defined = set(labels) # label definitions in the source, not RAM
|
||||
for rec in insns.values():
|
||||
for token in ident.findall(rec["op"]):
|
||||
if token in ("x", "y", "a") or token in defined:
|
||||
continue
|
||||
rec_r, rec_w = read_write(rec["mn"])
|
||||
e = zp.setdefault(token, {"r": 0, "w": 0, "where": []})
|
||||
e["r"] += rec_r
|
||||
e["w"] += rec_w
|
||||
if rec["cpu"] not in e["where"]:
|
||||
e["where"].append(rec["cpu"])
|
||||
|
||||
lines = [f"; MUSCLE (U) application-specific RAM aliases - Pass 2",
|
||||
"; Generated from every direct zero-page operand in reachable code.",
|
||||
"; Names come from observed usage; `ZpXX` marks storage whose role is not yet",
|
||||
"; unique enough for a behavioural name. Ref counts are reads/writes and the",
|
||||
"; trailing list shows the first referencing addresses.",
|
||||
""]
|
||||
for name in sorted(zp, key=lambda n: (n.startswith("Zp"), n)):
|
||||
e = zp[name]
|
||||
total = e["r"] + e["w"]
|
||||
m = re.match(r"Zp([0-9A-F]{2})$", name)
|
||||
value = int(m.group(1), 16) if m else addr_of.get(name)
|
||||
decl = f"{name} = ${value:02X}" if value is not None else name
|
||||
where = ", ".join(f"{w:04X}" for w in e["where"][:4])
|
||||
if len(e["where"]) > 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=32):
|
||||
"""Re-pad instruction lines so the trailing byte comment keeps its column."""
|
||||
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 - 1)
|
||||
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(sys.argv[2])
|
||||
elif cmd == "describe":
|
||||
describe(sys.argv[2], sys.argv[3])
|
||||
elif cmd == "structure":
|
||||
structure(sys.argv[2], sys.argv[3])
|
||||
elif cmd == "locals":
|
||||
# locals <prg.s> <notes.tsv> <out.inc>
|
||||
write_locals(sys.argv[2], sys.argv[3], sys.argv[4], *sys.argv[5:])
|
||||
elif cmd == "derive":
|
||||
# derive <prg.s> <routinemap.tsv> <out.tsv>
|
||||
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()
|
||||
Reference in new issue
Block a user