#!/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()