modemwars/swiftlink/scanIoAccess.py
2026-08-23 02:09:40 -05:00

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()