#!/usr/bin/env python3
# testStack.py - catch the 38400-baud wedge while it is happening, rather than after.
#
#   python3 testStack.py <disk.d64> <rate> [tag] [watchSeconds]
#
# testWedge.py established what a wedged machine looks like afterwards: the game running with the I
# flag set so its raster IRQ never runs again, and the whole of page 1 covered in commNmiHandler
# frames.  That is wreckage, not a mechanism.  This script watches for the mechanism.
#
# The 6502 does not mask NMI, so a fresh /NMI edge re-enters commNmiHandler at the next instruction
# boundary, and every re-entry costs 6 bytes of stack: the CPU's own PCH/PCL/P plus the handler's A,
# X and saved $01.  So the stack pointer AT THE HANDLER'S FIRST INSTRUCTION is a direct read-out of
# how many NMIs are nested at that moment - and VICE prints SP and the free-running cycle counter on
# every tracepoint hit.
#
#   trace exec e685 if sp < $a8    - prints nothing while nesting is shallow (the main line runs with
#                                    SP around $BD-$E5, so an entry below $A8 is at least three deep)
#                                    and turns into a staircase the moment the handler stops keeping
#                                    up.  Cheap: no output at all until it matters.
#   break exec e685 if sp < $20    - stops the emulator once the stack has nearly wrapped, so the
#                                    stack page, the registers and the ACIA can be read with the
#                                    runaway still in progress instead of after it has destroyed
#                                    itself.
#
# Both machines carry both checkpoints, and the raw monitor output of each is saved, so the staircase
# is timestamped in the emulator's own cycles.
import os
import re
import sys
import time

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from viceHarness import ViceSession, aciaArgs, ACIA_BASE, SCRATCH
from testTwoMachines import NullModemRelay, pickModemOpponent, answerModemPrompts
from testRealtime import (BAUD_INDEX, LOGS, NTSC_HZ, SHOTS, command, decodeControl, flush,
                          pauseAll, readRange, resumeAll, snapshot, showState, stopwatch,
                          waitForPhase)
from testHighRates import moduleResident
from testWedge import TRACE_LINE, registers

DEEP_TRACE_SP = 0xB4                    # one NMI frame below the deepest main line ever sampled
BREAK_SP = 0x20                         # nearly a whole page of stack consumed
# A healthy machine never pushes below about $01A0: the deepest stack byte either emulator touched in
# a 200-second 38400-baud run that did not wedge was $01AE.  So a STORE anywhere in $0100-$0180 means
# the stack has descended thirteen NMI frames further than anything healthy does, and it is the
# cheapest possible trigger - it costs nothing at all until the runaway starts.
DEEP_TRACE_STORE = (0x0100, 0x0180)
BREAK_STORE = (0x0100, 0x0140)


def collect(sessions, seconds, maxBytes, runTag):
    # Read both monitor sockets raw, at the same time, and stop early if either machine's breakpoint
    # fires.  A resumed emulator prints nothing, so any prompt in the stream is the break.
    buffers = {tag: [] for _, tag in sessions}
    totals = {tag: 0 for _, tag in sessions}
    broke = None
    for session, _ in sessions:
        session.sock.settimeout(0.2)
    deadline = time.time() + seconds
    while time.time() < deadline and broke is None:
        for session, tag in sessions:
            if totals[tag] >= maxBytes:
                continue
            try:
                chunk = session.sock.recv(1 << 16)
            except OSError:
                continue
            if not chunk:
                continue
            text = chunk.decode("latin-1")
            buffers[tag].append(text)
            totals[tag] += len(chunk)
            if "Stop on" in text:
                broke = tag
                print(f"\n*** [{tag}] BREAKPOINT: the handler was entered with SP below "
                      f"${BREAK_SP:02X} ***", flush=True)
    out = {}
    for _, tag in sessions:
        body = "".join(buffers[tag])
        path = f"{LOGS}/stack.{runTag}.{tag}.deep.txt"
        open(path, "w").write(body)
        out[tag] = body
        print(f"[{tag}] {totals[tag]} bytes of deep-nesting trace -> {path}", flush=True)
    return out, broke


def staircase(body, tag):
    # Every line is one entry into commNmiHandler with the stack already at least three frames deep.
    # Consecutive lines whose SP falls by exactly 6 are consecutive nesting levels; the cycle gap
    # between them is how long one pass of the handler managed before the next character arrived.
    events = [(int(pc, 16), int(sp, 16), int(cycle)) for pc, sp, cycle in TRACE_LINE.findall(body)]
    entries = [event for event in events if event[0] == 0xE685]
    if not entries:
        print(f"  [{tag}] no entry to commNmiHandler was ever seen with SP < ${DEEP_TRACE_SP:02X}",
              flush=True)
        return {"tag": tag, "deepEntries": 0}
    sps = [sp for _, sp, _ in entries]
    gaps = [entries[i][2] - entries[i - 1][2] for i in range(1, len(entries))]
    descending = sum(1 for i in range(1, len(entries))
                     if (entries[i - 1][1] - entries[i][1]) % 256 == 6)
    print(f"\n  ---- deep-nesting staircase [{tag}] ----", flush=True)
    print(f"    entries below SP ${DEEP_TRACE_SP:02X}: {len(entries)}", flush=True)
    print(f"    SP first ${sps[0]:02X}, last ${sps[-1]:02X}, lowest ${min(sps):02X}", flush=True)
    print(f"    steps where SP fell by exactly 6 (one more NMI frame): {descending} of "
          f"{len(entries) - 1}", flush=True)
    if gaps:
        print(f"    cycles between consecutive entries: min {min(gaps)}, median "
              f"{sorted(gaps)[len(gaps) // 2]}, max {max(gaps)}", flush=True)
    span = entries[-1][2] - entries[0][2]
    print(f"    the whole staircase took {span} cycles = {span / NTSC_HZ * 1000:.2f} ms", flush=True)
    print(f"    first 24: {[(f'${sp:02X}', cycle) for _, sp, cycle in entries[:24]]}", flush=True)
    print(f"    last 8:   {[(f'${sp:02X}', cycle) for _, sp, cycle in entries[-8:]]}", flush=True)
    return {"tag": tag, "deepEntries": len(entries), "spFirst": sps[0], "spLast": sps[-1],
            "spLowest": min(sps), "stepsOfSix": descending, "spanCycles": span,
            "gapMedian": sorted(gaps)[len(gaps) // 2] if gaps else None,
            "gapMin": min(gaps) if gaps else None}


def dumpAtBreak(session, tag):
    print(f"\n======== the machine, stopped inside the runaway [{tag}] ========", flush=True)
    flush(session, 0.3, 3)
    row = registers(session, tag)
    print(f"  [{tag}] registers: {row}", flush=True)
    command(session, "d e685 e6f1", timeout=20)
    command(session, "m 0100 017f", timeout=20)
    command(session, "m 0180 01ff", timeout=20)
    command(session, "m 0000 0001")
    command(session, f"m {ACIA_BASE:04x} {ACIA_BASE + 3:04x}")
    command(session, "m e5bb e5bf")
    command(session, "m e42d e42f")
    command(session, "m e414 e416")
    command(session, "m e0a4 e0a6")
    command(session, "m 0038 0038")              # irqInProgress, the game's raster-IRQ guard
    command(session, "m fffa fffb")
    command(session, "stopwatch")
    return row


def main():
    disk = os.path.abspath(sys.argv[1])
    rate = int(sys.argv[2])
    tag = sys.argv[3] if len(sys.argv) > 3 else "s"
    watchSeconds = float(sys.argv[4]) if len(sys.argv) > 4 else 200.0
    # traceOnly: arm the tracepoints only, never a breakpoint, and start reading the moment the link
    # is open.  st384b caught a machine with SP already at $12, but it caught it with a BREAKPOINT,
    # which stops the emulator - and once stopped it stays stopped, so the descent itself was never
    # printed.  Tracepoints print and let the machine run, so this mode records the staircase as it
    # happens and never interferes.
    traceOnly = len(sys.argv) > 5 and sys.argv[5] == "traceonly"
    os.makedirs(SHOTS, exist_ok=True)
    os.makedirs(LOGS, exist_ok=True)
    relay = NullModemRelay()
    print(f"relay on 127.0.0.1:{relay.port}; both machines want {rate} baud", flush=True)
    args = aciaArgs(rsDevAddress=f"127.0.0.1:{relay.port}", baud=38400)
    a = ViceSession(disk, f"{SCRATCH}/stack.{tag}.a.vice.log", args, label="A", warp=False)
    b = ViceSession(disk, f"{SCRATCH}/stack.{tag}.b.vice.log", args, label="B", warp=False)
    sessions = [(a, "A"), (b, "B")]
    try:
        a.connect()
        b.connect()
        for session, _ in sessions:
            command(session, "warp on")
        a.bootPastLoader(waitSecs=900)
        b.bootPastLoader(waitSecs=900)
        pauseAll(sessions)
        for session, sessionTag in sessions:
            command(session, "warp off")
            print(f"[{sessionTag}] {command(session, 'warp').strip()}", flush=True)
        resumeAll(sessions)
        a.findWindow()
        b.findWindow()
        a.focus()
        b.focus()
        time.sleep(25)
        pickModemOpponent(a, "A", "a")
        pickModemOpponent(b, "B", "o")
        for session, sessionTag in sessions:
            if not moduleResident(session, sessionTag):
                raise SystemExit(f"{sessionTag}: the opponent module never loaded")
            pauseAll([(session, sessionTag)])
            command(session, f"> e055 {BAUD_INDEX[rate]:02x}")
            # Arm both checkpoints before the link is ever opened, so nothing that happens on the
            # way up can be missed.
            command(session, "del")
            command(session, f"trace exec e685 if sp < ${DEEP_TRACE_SP:02x}")
            if not traceOnly:
                command(session, f"trace store {DEEP_TRACE_STORE[0]:04x} "
                                 f"{DEEP_TRACE_STORE[1]:04x}")
                command(session, f"break exec e685 if sp < ${BREAK_SP:02x}")
                command(session, f"break store {BREAK_STORE[0]:04x} {BREAK_STORE[1]:04x}")
            resumeAll([(session, sessionTag)])
        answerModemPrompts(a, "A", "a")
        answerModemPrompts(b, "B", "o")
        if not traceOnly:
            ok, phases = waitForPhase(sessions, 3, 180)
            print(f"both in the packet phase: {ok} ({phases})", flush=True)
            for state in [snapshot(session, sessionTag) for session, sessionTag in sessions]:
                print(f"  [{state['tag']}] control {decodeControl(state['aciaControl'])}",
                      flush=True)
                showState(state)
            # waitForPhase and snapshot both stop and restart the emulators, so re-arm and hand the
            # sockets over to the raw collector with nothing else talking on them.
            for session, sessionTag in sessions:
                pauseAll([(session, sessionTag)])
                command(session, "del")
                command(session, f"trace exec e685 if sp < ${DEEP_TRACE_SP:02x}")
                command(session, f"trace store {DEEP_TRACE_STORE[0]:04x} "
                                 f"{DEEP_TRACE_STORE[1]:04x}")
                command(session, f"break exec e685 if sp < ${BREAK_SP:02x}")
                command(session, f"break store {BREAK_STORE[0]:04x} {BREAK_STORE[1]:04x}")
                resumeAll([(session, sessionTag)])
        # Bracket the watch with both machines' own cycle counters, so the run says for itself
        # whether it was at true C64 speed.
        pauseAll(sessions)
        startCycles = {sessionTag: stopwatch(session) for session, sessionTag in sessions}
        resumeAll(sessions)
        t0 = time.time()
        bodies, broke = collect(sessions, watchSeconds, 8 << 20, tag)
        wall = time.time() - t0
        if broke is None:
            pauseAll(sessions)
            endCycles = {sessionTag: stopwatch(session) for session, sessionTag in sessions}
            resumeAll(sessions)
            for sessionTag in startCycles:
                span = endCycles[sessionTag] - startCycles[sessionTag]
                print(f"[{sessionTag}] watch {wall:.2f} s wall, {span} emulated cycles = "
                      f"{span / NTSC_HZ:.2f} emulated s = "
                      f"{100.0 * span / NTSC_HZ / wall:.1f}% of real time", flush=True)
        summary = {}
        for _, sessionTag in sessions:
            summary[sessionTag] = staircase(bodies[sessionTag], sessionTag)
        if broke:
            session = dict((sessionTag, s) for s, sessionTag in sessions)[broke]
            dumpAtBreak(session, broke)
            print(f"\n  letting [{broke}] run on from the break", flush=True)
            command(session, "del")
            resumeAll([(session, broke)])
            time.sleep(10)
            pauseAll([(session, broke)])
            print(f"  [{broke}] ten seconds later: {registers(session, broke)}", flush=True)
            command(session, "m 0038 0038")
            command(session, "m e42d e42f")
            command(session, "m e0a5 e0a5")
            resumeAll([(session, broke)])
        for session, sessionTag in sessions:
            pauseAll([(session, sessionTag)])
            command(session, "del")
            row = registers(session, sessionTag)
            print(f"  [{sessionTag}] final registers: {row}", flush=True)
            command(session, "m 0038 0038")
            command(session, "m 0100 017f", timeout=20)
            command(session, "m 0180 01ff", timeout=20)
            resumeAll([(session, sessionTag)])
        a.shot(f"{SHOTS}/st{tag}end.png")
        b.shot(f"{SHOTS}/st{tag}endB.png")
        print("\n================ SUMMARY ================", flush=True)
        print(f"  rate {rate}, tag {tag}, broke on {broke}", flush=True)
        for sessionTag, row in summary.items():
            print(f"  {sessionTag}: {row}", flush=True)
    finally:
        a.close()
        b.close()
        time.sleep(1)
        relay.stop()
        for index in (0, 1):
            print(f"[relay] conn{index} total {len(relay.log[index])} bytes", flush=True)


if __name__ == "__main__":
    main()
