222 lines
11 KiB
Python
222 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
# scanIoAccess.py - find every instruction in the whole game that can touch the expansion port's I/O
|
|
# windows, $DE00-$DFFF.
|
|
#
|
|
# python3 scanIoAccess.py [image.bin@HEXLOAD ...]
|
|
#
|
|
# With a SwiftLink strapped to $DF00 this is a safety question, not a curiosity. A real cartridge
|
|
# decodes only A0 and A1 inside its page, so every four-byte window of $DF00-$DFFF is the same four
|
|
# registers: reading anywhere in the page with (address AND 3) = 0 consumes a received character and
|
|
# clears RDRF, and (address AND 3) = 1 clears the interrupt flag. A 6502 absolute-indexed STORE also
|
|
# performs a dummy READ at the un-carried address before the write, so an indexed store whose base is
|
|
# in the page reads a register even when the write itself lands elsewhere. That is what the stock
|
|
# driver's 'sta $DF59,y' at $E358 did, and why it had to go.
|
|
#
|
|
# Two passes, because neither alone is enough:
|
|
#
|
|
# pass 1, alignment aware - linear disassembly of every code region of every image the C64 loads.
|
|
# Code regions are the complement of disassembly/build/dataMap.json, which the verified
|
|
# disassembly generates, so data tables are not decoded as instructions. This is the pass whose
|
|
# answer is quoted.
|
|
# pass 2, superset - every three-byte window of every image, decoded as if it were an instruction.
|
|
# This catches anything pass 1 could have missed: a misaligned entry, a self-modified operand, or
|
|
# code hidden inside a region marked as data. It reports false positives by construction (a data
|
|
# byte pair that happens to look like an operand), so every hit has to be checked by hand against
|
|
# the .s file - but a clean pass 2 means there is nothing left to check.
|
|
#
|
|
# pass 3, indirect - every zero-page pointer dereferenced with (zp),y or (zp,x), with the immediate
|
|
# values the code stores into its high byte. An indirect access hides its address until run time,
|
|
# so this only narrows the field to the pointers that could name the $D000-$DFFF block at all.
|
|
#
|
|
# No pass can see an operand that only exists at run time. copyPageUnderIo $58B2 is exactly
|
|
# that case: its source and destination operands are patched by its callers and walk $D000..$DF00
|
|
# over the film buffer. It is safe because the routine banks the I/O area out ($01 = $34) around the
|
|
# whole copy, which is checked in the emulator rather than here - see strapReport.md.
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "disassembly")
|
|
|
|
# opcode -> (mnemonic, addressing mode)
|
|
OPCODES = {}
|
|
TABLE = """
|
|
00 brk imp;01 ora izx;05 ora zp;06 asl zp;08 php imp;09 ora imm;0a asl acc;0d ora abs;0e asl abs
|
|
10 bpl rel;11 ora izy;15 ora zpx;16 asl zpx;18 clc imp;19 ora aby;1d ora abx;1e asl abx
|
|
20 jsr abs;21 and izx;24 bit zp;25 and zp;26 rol zp;28 plp imp;29 and imm;2a rol acc;2c bit abs
|
|
2d and abs;2e rol abs;30 bmi rel;31 and izy;35 and zpx;36 rol zpx;38 sec imp;39 and aby;3d and abx
|
|
3e rol abx;40 rti imp;41 eor izx;45 eor zp;46 lsr zp;48 pha imp;49 eor imm;4a lsr acc;4c jmp abs
|
|
4d eor abs;4e lsr abs;50 bvc rel;51 eor izy;55 eor zpx;56 lsr zpx;58 cli imp;59 eor aby;5d eor abx
|
|
5e lsr abx;60 rts imp;61 adc izx;65 adc zp;66 ror zp;68 pla imp;69 adc imm;6a ror acc;6c jmp ind
|
|
6d adc abs;6e ror abs;70 bvs rel;71 adc izy;75 adc zpx;76 ror zpx;78 sei imp;79 adc aby;7d adc abx
|
|
7e ror abx;81 sta izx;84 sty zp;85 sta zp;86 stx zp;88 dey imp;8a txa imp;8c sty abs;8d sta abs
|
|
8e stx abs;90 bcc rel;91 sta izy;94 sty zpx;95 sta zpx;96 stx zpy;98 tya imp;99 sta aby;9a txs imp
|
|
9d sta abx;a0 ldy imm;a1 lda izx;a2 ldx imm;a4 ldy zp;a5 lda zp;a6 ldx zp;a8 tay imp;a9 lda imm
|
|
aa tax imp;ac ldy abs;ad lda abs;ae ldx abs;b0 bcs rel;b1 lda izy;b4 ldy zpx;b5 lda zpx;b6 ldx zpy
|
|
b8 clv imp;b9 lda aby;ba tsx imp;bc ldy abx;bd lda abx;be ldx aby;c0 cpy imm;c1 cmp izx;c4 cpy zp
|
|
c5 cmp zp;c6 dec zp;c8 iny imp;c9 cmp imm;ca dex imp;cc cpy abs;cd cmp abs;ce dec abs;d0 bne rel
|
|
d1 cmp izy;d5 cmp zpx;d6 dec zpx;d8 cld imp;d9 cmp aby;dd cmp abx;de dec abx;e0 cpx imm;e1 sbc izx
|
|
e4 cpx zp;e5 sbc zp;e6 inc zp;e8 inx imp;e9 sbc imm;ea nop imp;ec cpx abs;ed sbc abs;ee inc abs
|
|
f0 beq rel;f1 sbc izy;f5 sbc zpx;f6 inc zpx;f8 sed imp;f9 sbc aby;fd sbc abx;fe inc abx
|
|
"""
|
|
for entry in TABLE.replace("\n", ";").split(";"):
|
|
entry = entry.strip()
|
|
if entry:
|
|
code, mnemonic, mode = entry.split()
|
|
OPCODES[int(code, 16)] = (mnemonic, mode)
|
|
|
|
SIZES = {"imp": 1, "acc": 1, "imm": 2, "zp": 2, "zpx": 2, "zpy": 2, "izx": 2, "izy": 2, "rel": 2,
|
|
"abs": 3, "abx": 3, "aby": 3, "ind": 3}
|
|
|
|
# Superset decoding: the opcodes whose operand is a 16-bit address.
|
|
SUPERSET = {0x1D: "ora,x", 0x3D: "and,x", 0x5D: "eor,x", 0x7D: "adc,x", 0x9D: "sta,x",
|
|
0xBD: "lda,x", 0xDD: "cmp,x", 0xFD: "sbc,x", 0x1E: "asl,x", 0x3E: "rol,x",
|
|
0x5E: "lsr,x", 0x7E: "ror,x", 0xDE: "dec,x", 0xFE: "inc,x", 0xBC: "ldy,x",
|
|
0x19: "ora,y", 0x39: "and,y", 0x59: "eor,y", 0x79: "adc,y", 0x99: "sta,y",
|
|
0xB9: "lda,y", 0xD9: "cmp,y", 0xF9: "sbc,y", 0xBE: "ldx,y",
|
|
0x0D: "ora", 0x2D: "and", 0x4D: "eor", 0x6D: "adc", 0x8D: "sta", 0xAD: "lda",
|
|
0xCD: "cmp", 0xED: "sbc", 0x0E: "asl", 0x2E: "rol", 0x4E: "lsr", 0x6E: "ror",
|
|
0xCE: "dec", 0xEE: "inc", 0x8C: "sty", 0xAC: "ldy", 0x8E: "stx", 0xAE: "ldx",
|
|
0x2C: "bit", 0x4C: "jmp", 0x20: "jsr", 0x6C: "jmp()", 0xCC: "cpy", 0xEC: "cpx"}
|
|
|
|
IO_LOW = 0xDE00
|
|
IO_HIGH = 0xDFFF
|
|
|
|
|
|
def loadIndex():
|
|
modules = {}
|
|
for line in open(os.path.join(ROOT, "INDEX.txt")):
|
|
match = re.match(r"(\S+)\s+\$([0-9A-F]{4})-\$([0-9A-F]{4})", line)
|
|
if match:
|
|
modules[match.group(1)] = int(match.group(2), 16)
|
|
return modules
|
|
|
|
|
|
def codeRegions(name, start, length, dataMap):
|
|
ranges = sorted((int(a, 16), int(b, 16)) for a, b in dataMap.get(name, []))
|
|
regions = []
|
|
cursor = start
|
|
top = start + length
|
|
for low, high in ranges:
|
|
if low > cursor:
|
|
regions.append((cursor, min(low - 1, top - 1)))
|
|
cursor = max(cursor, high + 1)
|
|
if cursor < top:
|
|
regions.append((cursor, top - 1))
|
|
return regions
|
|
|
|
|
|
def images():
|
|
modules = loadIndex()
|
|
out = []
|
|
for name, start in sorted(modules.items()):
|
|
if name.startswith("drive/"):
|
|
continue # runs in the 1541's own 6502, not on the expansion port
|
|
path = os.path.join(ROOT, "build", name.replace("/", "_") + ".orig.bin")
|
|
if os.path.exists(path):
|
|
out.append((name, start, open(path, "rb").read()))
|
|
for spec in sys.argv[1:]:
|
|
path, base = spec.split("@")
|
|
out.append((os.path.basename(path), int(base, 16), open(path, "rb").read()))
|
|
return out
|
|
|
|
|
|
def passOne(loaded, dataMap):
|
|
print("=== pass 1: instructions decoded in the code regions of the verified disassembly ===")
|
|
found = 0
|
|
for name, start, data in loaded:
|
|
for low, high in codeRegions(name, start, len(data), dataMap):
|
|
pc = low
|
|
while pc <= high:
|
|
opcode = data[pc - start]
|
|
mnemonic, mode = OPCODES.get(opcode, ("???", "imp"))
|
|
size = SIZES[mode]
|
|
if pc + size - 1 > high:
|
|
break
|
|
if mode in ("abs", "abx", "aby", "ind"):
|
|
base = data[pc - start + 1] | (data[pc - start + 2] << 8)
|
|
indexed = mode in ("abx", "aby")
|
|
top = base + (0xFF if indexed else 0)
|
|
dummy = base & 0xFF00 if indexed else None
|
|
if (top >= IO_LOW and base <= IO_HIGH) or \
|
|
(dummy is not None and IO_LOW <= dummy <= IO_HIGH):
|
|
found += 1
|
|
suffix = {"abx": ",x", "aby": ",y", "abs": "", "ind": " (indirect)"}[mode]
|
|
reach = f"${base:04X}" if not indexed else f"${base:04X}-${top:04X}"
|
|
note = f", dummy read in ${dummy:04X}-${dummy + 0xFF:04X}" if indexed else ""
|
|
print(f" {name} ${pc:04X}: {mnemonic} ${base:04X}{suffix}"
|
|
f" reaches {reach}{note}")
|
|
pc += size
|
|
if not found:
|
|
print(" none")
|
|
return found
|
|
|
|
|
|
def passTwo(loaded):
|
|
print()
|
|
print("=== pass 2: every three-byte window decoded as an absolute instruction (superset) ===")
|
|
found = 0
|
|
for name, start, data in loaded:
|
|
for offset in range(len(data) - 2):
|
|
if data[offset + 2] not in (0xDE, 0xDF):
|
|
continue
|
|
kind = SUPERSET.get(data[offset])
|
|
if kind:
|
|
found += 1
|
|
print(f" {name} ${start + offset:04X}: {data[offset]:02X} {data[offset + 1]:02X} "
|
|
f"{data[offset + 2]:02X} = {kind} ${data[offset + 2]:02X}"
|
|
f"{data[offset + 1]:02X}")
|
|
if not found:
|
|
print(" none")
|
|
return found
|
|
|
|
|
|
def passThree(loaded, dataMap):
|
|
# (zp),y and (zp,x) hide their address until run time, so the question there is which zero-page
|
|
# pointers can hold one. This lists every pointer that is dereferenced indirectly, together with
|
|
# every IMMEDIATE value the code stores into its high byte. It is not proof - a pointer can also
|
|
# be given a computed high byte, and filmPtr is - but it is what says which pointers are even
|
|
# candidates for naming the $D000-$DFFF block, and it is short enough to read.
|
|
print()
|
|
print("=== pass 3: zero-page pointers used by (zp),y / (zp,x), and the immediate high bytes ===")
|
|
pointers = {}
|
|
immediates = {}
|
|
for name, start, data in loaded:
|
|
for low, high in codeRegions(name, start, len(data), dataMap):
|
|
pc = low
|
|
pending = None
|
|
while pc <= high:
|
|
opcode = data[pc - start]
|
|
mnemonic, mode = OPCODES.get(opcode, ("???", "imp"))
|
|
size = SIZES[mode]
|
|
if pc + size - 1 > high:
|
|
break
|
|
if mode in ("izy", "izx"):
|
|
pointers.setdefault(data[pc - start + 1], []).append(f"{name} ${pc:04X}")
|
|
if opcode in (0xA9, 0xA2, 0xA0): # lda/ldx/ldy #imm
|
|
pending = data[pc - start + 1]
|
|
elif opcode in (0x85, 0x86, 0x84) and pending is not None: # sta/stx/sty zp
|
|
immediates.setdefault(data[pc - start + 1], set()).add(pending)
|
|
pc += size
|
|
for zp in sorted(pointers):
|
|
highs = sorted(immediates.get(zp + 1, set()))
|
|
text = " ".join(f"${value:02X}" for value in highs) if highs else "(no immediate store seen)"
|
|
flag = ""
|
|
if any(0xD0 <= value <= 0xDF for value in highs):
|
|
flag = " <-- can name the $D000-$DFFF block"
|
|
print(f" ${zp:02X}/${zp + 1:02X} {len(pointers[zp]):3d} indirect uses; immediate high "
|
|
f"bytes stored at ${zp + 1:02X}: {text}{flag}")
|
|
|
|
|
|
def main():
|
|
dataMap = json.load(open(os.path.join(ROOT, "build", "dataMap.json")))
|
|
loaded = images()
|
|
print(f"scanned {len(loaded)} images: " + ", ".join(name for name, _, _ in loaded))
|
|
print()
|
|
passOne(loaded, dataMap)
|
|
passTwo(loaded)
|
|
passThree(loaded, dataMap)
|
|
|
|
|
|
main()
|