414 lines
18 KiB
Python
414 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
# render.py - render a region of a memory image as reassemblable, annotated ca65 source.
|
|
#
|
|
# Inputs (all optional except image/region): a code-address set, a label map (addr->name), an
|
|
# annotation map (addr->{"name","line","block","routine"}) and a data-type map (addr->type).
|
|
import json, os, textwrap
|
|
from m6502 import decode, formatOperand, IMM, IMP, ACC, ZP, ZPX, ZPY, ABS, ABX, ABY, IND, IZX, IZY, REL, BRANCHES
|
|
from hwSymbols import c64Symbols, DRIVE
|
|
|
|
COMMENT_COL = 40
|
|
|
|
|
|
MARKERS = ("In:", "Out:", "Called", "*", "-", "$", "|")
|
|
|
|
|
|
def isStructured(line):
|
|
"""a line that carries its own layout (a field, a bullet, a hand made table row)"""
|
|
stripped = line.lstrip()
|
|
if line[:1].isspace(): # indented: the author laid it out on purpose
|
|
return True
|
|
# three or more spaces in a row mean a hand made table; two are just sentence spacing
|
|
return line.startswith(MARKERS) or stripped.startswith(("* ", "- ", "$")) or " " in stripped
|
|
|
|
|
|
def wrapLines(lines, width=100):
|
|
"""Wrap comment text to width. Consecutive prose lines are joined into one paragraph first, so a
|
|
long line written by hand does not leave a two word orphan before the next line."""
|
|
out = []
|
|
paragraph = []
|
|
|
|
def flush():
|
|
if not paragraph:
|
|
return
|
|
text = " ".join(p.strip() for p in paragraph)
|
|
out.extend(textwrap.wrap(text, width=width, break_long_words=False, break_on_hyphens=False) or [""])
|
|
paragraph.clear()
|
|
|
|
for line in lines:
|
|
if isStructured(line) or not line.strip():
|
|
flush()
|
|
if len(line) <= width:
|
|
out.append(line)
|
|
else:
|
|
indent = " " if line.startswith(("In:", "Out:", "Called")) else ""
|
|
out.extend(textwrap.wrap(line, width=width, subsequent_indent=indent,
|
|
break_long_words=False, break_on_hyphens=False) or [""])
|
|
else:
|
|
paragraph.append(line)
|
|
flush()
|
|
return out
|
|
|
|
|
|
def isPrintable(b):
|
|
return 0x20 <= b <= 0x7E
|
|
|
|
|
|
def asciiOf(bs):
|
|
return "".join(chr(b) if isPrintable(b) else "." for b in bs)
|
|
|
|
|
|
def escapeStr(s):
|
|
return s.replace("\\", "\\\\").replace('"', '\\"')
|
|
|
|
|
|
class RendererT:
|
|
def __init__(self, mem, codeSet, labels, notes, hwSyms, dataTypes=None, zpNames=None, useKernal=False):
|
|
self.useKernal = useKernal
|
|
self.mem = mem
|
|
self.code = codeSet
|
|
self.labels = dict(labels) # addr -> name (global, across files)
|
|
self.notes = notes # addr(int) -> dict
|
|
self.hw = hwSyms
|
|
self.dataTypes = dataTypes or {} # addr -> ("text"|"addr"|"byte"|"word", length)
|
|
self.zpNames = zpNames or {}
|
|
self.insnStarts = {} # addr -> InsnT for code in rendered region
|
|
self.altComments = {} # addr -> extra comment when a referenced address has other variant names
|
|
|
|
# ----- naming -----
|
|
def nameFor(self, addr):
|
|
if addr in self.labels:
|
|
return self.labels[addr]
|
|
if addr in self.hw:
|
|
return self.hw[addr]
|
|
return None
|
|
|
|
def midInsn(self, addr):
|
|
# (base, offset) if addr lies inside an instruction of the region being rendered
|
|
for back in (1, 2):
|
|
base = addr - back
|
|
if base in self.insnStarts and self.insnStarts[base].length > back:
|
|
return base, back
|
|
return None
|
|
|
|
def operandLabel(self, addr):
|
|
# label for an operand address; in-file mid-instruction references become label+offset
|
|
mid = self.midInsn(addr)
|
|
if mid:
|
|
bn = self.labels.get(mid[0])
|
|
if bn:
|
|
return f"{bn}+{mid[1]}"
|
|
n = self.nameFor(addr)
|
|
if n:
|
|
return n
|
|
if addr < 0x100:
|
|
return self.zpNames.get(addr)
|
|
return None
|
|
|
|
# ----- rendering -----
|
|
def renderRegion(self, start, end, title, header, externsOut):
|
|
out = []
|
|
mem = self.mem
|
|
# pass 1: decode instructions in region
|
|
a = start
|
|
while a < end:
|
|
if a in self.code:
|
|
insn = decode(mem, a)
|
|
if insn.isLegal() and a + insn.length <= end:
|
|
self.insnStarts[a] = insn
|
|
a += insn.length
|
|
continue
|
|
a += 1
|
|
# collect labels defined here and referenced externals
|
|
used = set()
|
|
defined = set()
|
|
for da, dtSpec in self.dataTypes.items():
|
|
kind, length = dtSpec[0], dtSpec[1]
|
|
if kind == "addr" and start <= da < end:
|
|
for i in range(0, length - 1, 2):
|
|
used.add(mem[da + i] | (mem[da + i + 1] << 8))
|
|
for a, insn in self.insnStarts.items():
|
|
if insn.mode in (ZP, ZPX, ZPY, ABS, ABX, ABY, IND, IZX, IZY, REL):
|
|
t = insn.target
|
|
if start <= t < end:
|
|
mid = self.midInsn(t)
|
|
if mid and mid[0] not in self.labels:
|
|
self.labels[mid[0]] = f"L_{mid[0]:04X}"
|
|
used.add(t)
|
|
out.append(f"; {'=' * 76}")
|
|
for line in title.split("\n"):
|
|
out.append(f"; {line}")
|
|
out.append(f"; {'=' * 76}")
|
|
for line in header:
|
|
out.append(f"; {line}")
|
|
out.append("")
|
|
out.append(' .setcpu "6502"')
|
|
out.append(' .include "c64.inc"')
|
|
if self.useKernal:
|
|
out.append(' .include "kernal.inc"')
|
|
out.append(' .include "zeropage.inc"')
|
|
out.append("")
|
|
# externs: every referenced labelled address outside [start,end)
|
|
externs = []
|
|
for t in sorted(used):
|
|
if start <= t < end or t < 0x100 or t in self.hw:
|
|
continue
|
|
n = self.labels.get(t)
|
|
if not n:
|
|
n = f"D_{t:04X}"
|
|
self.labels[t] = n
|
|
externs.append(f"{n:<24} := ${t:04X}")
|
|
# also mid-instruction / mid-data references that resolve to label+off outside region
|
|
if externs:
|
|
out.append("; ---- references to code/data outside this file ----")
|
|
out.extend(externs)
|
|
out.append("")
|
|
externsOut.extend(externs)
|
|
# ---- table of contents: every labelled routine that carries a header comment
|
|
toc = []
|
|
for a in sorted(self.insnStarts):
|
|
if not (start <= a < end):
|
|
continue
|
|
note = self.notes.get(a, {})
|
|
head = note.get("routine")
|
|
if not head or a not in self.labels:
|
|
continue
|
|
summary = head[0]
|
|
if summary.startswith(self.labels[a]):
|
|
summary = summary[len(self.labels[a]):].lstrip(" -")
|
|
cut = summary.find(". ") # the table of contents shows one sentence
|
|
if cut > 0:
|
|
summary = summary[:cut + 1]
|
|
toc.append((a, self.labels[a], summary))
|
|
if toc:
|
|
out.append("; Contents")
|
|
out.append("; --------")
|
|
for a, name, summary in toc:
|
|
prefix = f" ${a:04X} {name:<28} "
|
|
wrapped = wrapLines([prefix + summary], width=118)
|
|
out.append("; " + wrapped[0])
|
|
for cont in wrapped[1:]:
|
|
out.append("; " + " " * len(prefix) + cont.strip())
|
|
out.append("")
|
|
out.append(f" .org ${start:04X}")
|
|
out.append("")
|
|
# pass 2: emit
|
|
a = start
|
|
pendingBytes = []
|
|
def flushBytes(atAddr):
|
|
nonlocal pendingBytes
|
|
if not pendingBytes:
|
|
return
|
|
bs = pendingBytes
|
|
base = atAddr - len(bs)
|
|
i = 0
|
|
while i < len(bs):
|
|
chunk = bs[i:i+8]
|
|
hexs = ",".join(f"${b:02X}" for b in chunk)
|
|
line = f" .byte {hexs}"
|
|
out.append(f"{line:<{COMMENT_COL}}; {base+i:04X} {asciiOf(chunk)}")
|
|
i += 8
|
|
pendingBytes = []
|
|
labelsHere = set()
|
|
while a < end:
|
|
note = self.notes.get(a, {})
|
|
name = self.labels.get(a)
|
|
if a in self.insnStarts:
|
|
flushBytes(a)
|
|
insn = self.insnStarts[a]
|
|
if note.get("routine") or note.get("block"):
|
|
out.append("")
|
|
out.append(f"; {'-' * 70}")
|
|
for line in wrapLines((note.get("routine") or []) + (note.get("block") or [])):
|
|
out.append(f"; {line}")
|
|
out.append(f"; {'-' * 70}")
|
|
if name:
|
|
out.append(f"{name}:")
|
|
labelsHere.add(a)
|
|
opText = self.formatInsn(insn)
|
|
lineText = f" {insn.mnemonic:<7} {opText}".rstrip()
|
|
comment = note.get("line", "")
|
|
if insn.target is not None and insn.target in self.altComments and not (start <= insn.target < end):
|
|
comment = (comment + " " if comment else "") + "(" + self.altComments[insn.target] + ")"
|
|
hexBytes = " ".join(f"{mem[a+i]:02X}" for i in range(insn.length))
|
|
out.append(f"{lineText:<{COMMENT_COL}}; {a:04X} {comment}".rstrip())
|
|
a += insn.length
|
|
# blank line after unconditional flow end for readability
|
|
if insn.mnemonic in ("rts", "rti", "jmp") and a < end:
|
|
flushBytes(a)
|
|
out.append("")
|
|
continue
|
|
# data
|
|
dt = self.dataTypes.get(a)
|
|
if name or note.get("block") or dt or note.get("routine"):
|
|
flushBytes(a)
|
|
if note.get("block") or note.get("routine"):
|
|
out.append("")
|
|
for line in wrapLines((note.get("routine") or []) + (note.get("block") or [])):
|
|
out.append(f"; {line}")
|
|
if name:
|
|
out.append(f"{name}:")
|
|
labelsHere.add(a)
|
|
if dt:
|
|
kind, length = dt[0], dt[1]
|
|
length = min(length, end - a)
|
|
# never run a typed block across another label or annotated address - except that an
|
|
# address table may contain a label at offset 1 (the high-byte reference), which becomes an equate
|
|
for k in range(1, length):
|
|
if (a + k) in self.insnStarts or (a + k) in self.dataTypes or ((a + k) in self.notes and (a + k) not in self.labels):
|
|
length = k
|
|
break
|
|
if (a + k) in self.labels:
|
|
if kind == "addr" and k % 2 == 1 and name:
|
|
out.append(f"{self.labels[a + k]:<24} = {name}+{k}")
|
|
continue
|
|
length = k
|
|
break
|
|
if kind == "text":
|
|
a = self.emitText(out, a, length, note.get("line", ""))
|
|
continue
|
|
if kind in ("addr", "word") and length < 2:
|
|
kind = "byte"
|
|
if kind == "addr":
|
|
for i in range(0, length - 1, 2):
|
|
t = mem[a+i] | (mem[a+i+1] << 8)
|
|
n = self.operandLabel(t) or f"${t:04X}"
|
|
line = f" .addr {n}"
|
|
out.append(f"{line:<{COMMENT_COL}}; {a+i:04X} {note.get('line','') if i == 0 else ''}".rstrip())
|
|
a += length - (length % 2)
|
|
continue
|
|
if kind == "word":
|
|
for i in range(0, length - 1, 2):
|
|
t = mem[a+i] | (mem[a+i+1] << 8)
|
|
line = f" .word ${t:04X}"
|
|
out.append(f"{line:<{COMMENT_COL}}; {a+i:04X} {note.get('line','') if i == 0 else ''}".rstrip())
|
|
a += length - (length % 2)
|
|
continue
|
|
if kind == "grid":
|
|
width = dt[2] if len(dt) > 2 else 8
|
|
for i in range(0, length, width):
|
|
chunk = mem[a+i:a+min(i+width, length)]
|
|
hexs = ",".join(f"${b:02X}" for b in chunk)
|
|
c = note.get("line", "") if i == 0 else ""
|
|
row = f" .byte {hexs}"
|
|
out.append(f"{row:<{COMMENT_COL}}; {a+i:04X} {asciiOf(chunk)} {c}".rstrip())
|
|
a += length
|
|
continue
|
|
if kind in ("bitmap", "sprite"):
|
|
perRow = 3 if kind == "sprite" else 1
|
|
rows = 21 if kind == "sprite" else 8
|
|
unit = perRow * rows
|
|
for base in range(0, length, unit):
|
|
if note.get("line") and base == 0:
|
|
out.append(f"; {note['line']}")
|
|
for i in range(0, min(unit, length - base), perRow):
|
|
# the block may have been clamped short by a label, so never emit past it
|
|
chunk = mem[a+base+i:a+base+min(i+perRow, length-base)]
|
|
hexs = ",".join(f"${b:02X}" for b in chunk)
|
|
art = "".join("#" if b & (0x80 >> k) else "." for b in chunk for k in range(8))
|
|
row = f" .byte {hexs}"
|
|
out.append(f"{row:<{COMMENT_COL}}; {a+base+i:04X} {art}")
|
|
out.append("")
|
|
a += length
|
|
continue
|
|
if kind == "byte":
|
|
# fixed-length row(s)
|
|
for i in range(0, length, 8):
|
|
chunk = mem[a+i:a+min(i+8, length)]
|
|
hexs = ",".join(f"${b:02X}" for b in chunk)
|
|
line = f" .byte {hexs}"
|
|
c = note.get("line", "") if i == 0 else ""
|
|
out.append(f"{line:<{COMMENT_COL}}; {a+i:04X} {asciiOf(chunk)} {c}".rstrip())
|
|
a += length
|
|
continue
|
|
if note.get("line") and not dt:
|
|
flushBytes(a)
|
|
b = mem[a]
|
|
line = f" .byte ${b:02X}"
|
|
out.append(f"{line:<{COMMENT_COL}}; {a:04X} {note['line']}")
|
|
a += 1
|
|
continue
|
|
pendingBytes.append(mem[a])
|
|
a += 1
|
|
# break byte rows at labels / code boundaries
|
|
if a in self.insnStarts or self.nameFor(a) or a in self.notes or a in self.dataTypes:
|
|
flushBytes(a)
|
|
flushBytes(a)
|
|
return out, labelsHere
|
|
|
|
def emitText(self, out, a, length, comment):
|
|
mem = self.mem
|
|
bs = mem[a:a+length]
|
|
# split into printable runs and high-bit/ctrl bytes
|
|
parts = []
|
|
cur = ""
|
|
for b in bs:
|
|
if isPrintable(b) and b not in (0x22,):
|
|
cur += chr(b)
|
|
else:
|
|
if cur:
|
|
parts.append(f'"{cur}"'); cur = ""
|
|
if isPrintable(b & 0x7F) and (b & 0x80) and b != 0xA0 and b != 0xFF:
|
|
parts.append(f"'{chr(b & 0x7F)}'|$80")
|
|
else:
|
|
parts.append(f"${b:02X}")
|
|
if cur:
|
|
parts.append(f'"{cur}"')
|
|
# wrap to a few parts per line
|
|
lineParts = []
|
|
lines = []
|
|
curLen = 0
|
|
for p in parts:
|
|
if curLen + len(p) > 60 and lineParts:
|
|
lines.append(",".join(lineParts)); lineParts = []; curLen = 0
|
|
lineParts.append(p); curLen += len(p) + 1
|
|
if lineParts:
|
|
lines.append(",".join(lineParts))
|
|
for i, l in enumerate(lines):
|
|
text = f" .byte {l}"
|
|
out.append(f"{text:<{COMMENT_COL}}; {a:04X} {comment if i == 0 else ''}".rstrip())
|
|
return a + length
|
|
|
|
def formatInsn(self, insn):
|
|
m = insn.mode
|
|
if m == IMP:
|
|
return ""
|
|
if m == ACC:
|
|
return "a"
|
|
if m == IMM:
|
|
return f"#${insn.operand:02X}"
|
|
t = insn.target
|
|
lab = self.operandLabel(t)
|
|
if m == REL:
|
|
return lab or f"${t:04X}"
|
|
if m in (ZP, ZPX, ZPY, IZX, IZY):
|
|
s = lab or f"${t:02X}"
|
|
else:
|
|
s = lab or f"${t:04X}"
|
|
if t < 0x100 and m in (ABS, ABX, ABY):
|
|
s = "a:" + s
|
|
if m == ZPX or m == ABX: return s + ",x"
|
|
if m == ZPY or m == ABY: return s + ",y"
|
|
if m == IND: return f"({s})"
|
|
if m == IZX: return f"({s},x)"
|
|
if m == IZY: return f"({s}),y"
|
|
return s
|
|
|
|
|
|
def writeIncludes(outDir, zpNames, zpComments):
|
|
from hwSymbols import KERNAL
|
|
hw = c64Symbols()
|
|
lines = ["; c64.inc - Commodore 64 hardware registers (auto-generated)", ""]
|
|
for a in sorted(hw):
|
|
if a in KERNAL: continue
|
|
lines.append(f"{hw[a]:<24} := ${a:04X}")
|
|
open(os.path.join(outDir, "c64.inc"), "w").write("\n".join(lines) + "\n")
|
|
lines = ["; kernal.inc - C64 KERNAL jump table (only meaningful while the KERNAL ROM is banked in)", ""]
|
|
for a in sorted(KERNAL):
|
|
lines.append(f"{KERNAL[a]:<24} := ${a:04X}")
|
|
open(os.path.join(outDir, "kernal.inc"), "w").write("\n".join(lines) + "\n")
|
|
lines = ["; zeropage.inc - zero-page variables used by Modem Wars (auto-generated from annotations)", ""]
|
|
for a in sorted(zpNames):
|
|
c = zpComments.get(a, "")
|
|
lines.append(f"{zpNames[a]:<24} := ${a:02X}" + (f" ; {c}" if c else ""))
|
|
open(os.path.join(outDir, "zeropage.inc"), "w").write("\n".join(lines) + "\n")
|