#!/usr/bin/env python3
# discover.py - find additional code entry points that plain recursive descent cannot see:
#   * interrupt / indirect-jump vectors written with LDA #lo / STA vec, LDA #hi / STA vec+1
#   * address tables copied into the operand of a JSR/JMP (self-modifying dispatch)
#   * address tables inside data runs (3+ consecutive words that point at plausible code)
# Every candidate is only accepted by the caller after a clean transactional trace.
from m6502 import decode, IMM, ABS, ABX, ABY, IND


def instructionStarts(mem, codeSet):
    starts = {}
    nextStart = 0
    for a in sorted(codeSet):
        if a < nextStart:
            continue
        insn = decode(mem, a)
        if insn.isLegal():
            starts[a] = insn
            nextStart = a + insn.length
    return starts


def vectorEntries(mem, starts, vectorAddrs):
    """values stored to vector lo/hi bytes via immediate loads -> set of candidate addresses"""
    lo = {v: set() for v in vectorAddrs}
    hi = {v: set() for v in vectorAddrs}
    order = sorted(starts)
    for i, a in enumerate(order):
        insn = starts[a]
        if insn.mnemonic not in ("sta", "stx", "sty") or insn.mode != ABS:
            continue
        t = insn.target
        reg = {"sta": "lda", "stx": "ldx", "sty": "ldy"}[insn.mnemonic]
        # look back up to 4 instructions for the immediate load of the same register
        val = None
        for j in range(i - 1, max(-1, i - 5), -1):
            p = starts[order[j]]
            if p.mnemonic == reg and p.mode == IMM:
                val = p.operand
                break
            if p.mnemonic in ("jsr", "jmp", "rts", "rti") or (p.mnemonic == reg):
                break
        if val is None:
            continue
        if t in lo:
            lo[t].add(val)
        if (t - 1) in hi:
            hi[t - 1].add(val)
    out = set()
    for v in vectorAddrs:
        for l in lo[v]:
            for h in hi[v]:
                out.add(l | (h << 8))
    return out


def patchedCallTables(mem, starts):
    """Find tables whose entries are copied into the operand of a JSR/JMP.

    Two shapes exist:
      word table   lda tbl,x / sta jsr+1 ; lda tbl+1,x / sta jsr+2   (x = index*2)
      split table  lda lo,x  / sta jsr+1 ; lda hi,x   / sta jsr+2    (x = index)
    Returns a list of (kind, baseLo, baseHi) with kind in {"word", "split"}.
    """
    order = sorted(starts)
    pos = {a: i for i, a in enumerate(order)}
    found = []
    for a, insn in starts.items():
        if insn.mnemonic != "sta" or insn.mode != ABS:
            continue
        target = insn.target                      # operand byte being patched
        for back in (1, 2):
            base = target - back
            if base not in starts:
                continue
            callInsn = starts[base]
            if callInsn.mnemonic not in ("jsr", "jmp") or callInsn.mode != ABS:
                continue
            if back != 1:
                continue                          # anchor the search on the low-byte store
            loSrc = _sourceTable(order, pos, starts, a)
            hiStore = _findStoreTo(order, pos, starts, a, target + 1)
            if loSrc is None or hiStore is None:
                continue
            hiSrc = _sourceTable(order, pos, starts, hiStore)
            if hiSrc is None:
                continue
            found.append(("word" if hiSrc == loSrc + 1 else "split", loSrc, hiSrc))
    return found


def _sourceTable(order, pos, starts, storeAddr, lookBack=4):
    """base address of the lda tbl,x / tbl,y that feeds the store at storeAddr"""
    i = pos[storeAddr]
    for j in range(i - 1, max(-1, i - 1 - lookBack), -1):
        p = starts[order[j]]
        if p.mnemonic == "lda" and p.mode in (ABX, ABY):
            return p.target
        if p.mnemonic in ("jsr", "jmp", "rts", "rti", "lda"):
            return None
    return None


def _findStoreTo(order, pos, starts, fromAddr, wantTarget, lookAhead=6):
    """address of the next sta wantTarget after fromAddr"""
    i = pos[fromAddr]
    for j in range(i + 1, min(len(order), i + 1 + lookAhead)):
        p = starts[order[j]]
        if p.mnemonic == "sta" and p.mode == ABS and p.target == wantTarget:
            return p.addr
    return None


def splitTableEntries(mem, baseLo, baseHi, isPlausible, maxEntries=64):
    """entries of a split low/high byte table, stopping at the first implausible pair"""
    out = []
    for i in range(maxEntries):
        w = mem[baseLo + i] | (mem[baseHi + i] << 8)
        if not isPlausible(w):
            break
        out.append(w)
    return out


def wordsFrom(mem, base, isPlausible, maxEntries=64):
    """read consecutive words from base while they look like code addresses"""
    out = []
    a = base
    gaps = 0
    for _ in range(maxEntries):
        w = mem[a] | (mem[a + 1] << 8)
        if isPlausible(w):
            out.append(w)
            gaps = 0
        elif w in (0xFFFF, 0x0000) and gaps == 0 and out:
            gaps += 1                      # tolerate a single placeholder entry
        else:
            break
        a += 2
    return out


def addressTablesInData(mem, codeSet, ranges, isPlausible, minRun=3):
    """scan data bytes inside the given ranges for runs of plausible code addresses"""
    tables = []
    for lo, hi in ranges:
        a = lo
        while a + 1 < hi:
            if a in codeSet:
                a += 1
                continue
            run = []
            b = a
            while b + 1 < hi and b not in codeSet and (b + 1) not in codeSet:
                w = mem[b] | (mem[b + 1] << 8)
                if isPlausible(w):
                    run.append(w)
                    b += 2
                else:
                    break
            if len(run) >= minRun:
                tables.append((a, run))
                a = b
            else:
                a += 1
    return tables
