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