#!/usr/bin/env python3
# testHighRates.py - what the SwiftLink driver does at the line rates a C64 Ultimate's SwiftLink
# emulation might hand it, and what it does when the two ends do not agree on the rate.
#
#   python3 testHighRates.py <disk.d64> <rateA> <rateB> [windowSeconds] [cleanWindows] [tag]
#
# It reuses testRealtime.py wholesale - the socket null modem, the "warp only the loader" boot, the
# hot-key rate selection, the stopwatch check that the emulators really ran at 1 MHz, and the
# transmitter-deadlock probe.  What it adds is aimed at one question the earlier reports could not
# answer: at a rate the 20-byte receive ring cannot keep up with, does the link make progress,
# degrade and RECOVER, or WEDGE?
#
#   * rateA and rateB are selected independently, so 38400 against 300 - a device that ignores the
#     baud bits talking to a peer that does not - is the same script with different arguments;
#   * a window is measured three ways rather than once, because the instruments are not free:
#       w1..wN  clean, no tracepoints at all, so the byte counts are the honest throughput;
#       wSync   with tracepoints on beginByteSyncPhase ($E805) and on the instruction that
#               completes a sync ($E855, "sta ackPending", reached only when the peer's $FF has
#               arrived).  Those two count degradations and recoveries directly, and they are low
#               volume - at most a few per second - where a tracepoint on the error counter is not;
#       wErr    a short window with the error tracepoints from rate1200Report.md: $E73F (every
#               character the receiver threw away) and the conditional $E505 if x > $13 (the subset
#               thrown away because the 20-byte ring was already full).  At 38400 that can print
#               thousands of lines a second, which is why it is short and separate;
#   * uartRxCount ($E42D) is then sampled repeatedly with the emulator stopped, to see how full the
#     raw receive ring actually gets, alongside connectionPhase, isLinkActive, txCharActive and the
#     command shadow.  Those reads DO stop the emulator, so they are outside every measured window.
#
# The rig's own limits, which the report has to repeat: VICE's ACIA is byte level, so two ends at
# different rates do not garble each other the way real hardware would - the fast sender simply
# fills the slow receiver's socket buffer.
import os
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 (BITS_PER_BYTE, CONTROL_RATES, LOGS, NTSC_HZ, SHOTS, command,
                          decodeControl, isTxDeadlocked, measureWindowOnce, pauseAll,
                          probeTxDeadlock, readRange, resumeAll, selectRate, showState, snapshot,
                          waitForPhase)

RING_SIZE = 20                          # pushUartRxRing $E505 refuses a 21st character
SYNC_ENTER = 0xE805                     # beginByteSyncPhase: the link threw the connection away
SYNC_DONE = 0xE855                      # sta ackPending: the peer answered $FF, phase 3 is next
# The module's own jump table.  Sixteen bytes that cannot be anything else, which is the only honest
# "the opponent module has landed" test - see test1200AutoBaud.waitForModule and rate1200Report.md
# section 6.  $E5BE and $E013/$E014 both read plausible values out of the previous overlay's wreckage
# while the 1541 is still fetching track 34, and a run that trusts them measures a machine that never
# opened its link at all.
MODULE_JUMP_TABLE = [0x4C, 0x11, 0xE1, 0x4C, 0x9A, 0xE2, 0x4C, 0xA9, 0xE0, 0x4C, 0xF6, 0xE0, 0x4C,
                     0xCB, 0xE3, 0x60]


def countHits(text, address):
    return text.count(f"exec {address:04x}")


def measure(relay, sessions, seconds, label, traces, runTag, key):
    # One window, with whichever tracepoints this window is meant to carry, and a full state read of
    # both machines at each edge.  Nothing is retried and nothing is discarded: a window that
    # carried no bytes is the answer to the question, not a failed measurement.
    setTraces(sessions, traces)
    before = [snapshot(session, tag) for session, tag in sessions]
    for state in before:
        showState(state)
    result = measureWindowOnce(relay, sessions, seconds, label)
    after = [snapshot(session, tag) for session, tag in sessions]
    for state in after:
        showState(state)
    setTraces(sessions, [])
    result["statesBefore"] = before
    result["states"] = after
    result["healthy"] = all(value >= 0.9 for value in result["speed"].values())
    result["deadlocked"] = {state["tag"]: isTxDeadlocked(state) for state in after}
    result["traceByTag"] = {tag: {"syncEnter$E805": countHits(text, SYNC_ENTER),
                                  "syncDone$E855": countHits(text, SYNC_DONE),
                                  "linkError$E73F": countHits(text, 0xE73F),
                                  "rxRingFull$E505": countHits(text, 0xE505)}
                            for tag, text in result["tracedBy"].items()}
    print(f"  transmitter deadlock at this edge: {result['deadlocked']}", flush=True)
    print(f"  tracepoint hits by machine: {result['traceByTag']}", flush=True)
    open(f"{LOGS}/highRate.{runTag}.{key}.samples.txt", "w").write(
        "\n".join(f"{t}\t{c0}\t{c1}" for t, c0, c1 in result["samples"]) + "\n")
    if result["traced"].strip():
        open(f"{LOGS}/highRate.{runTag}.{key}.trace.txt", "w").write(result["traced"])
    return result


def probePendingCount(relay, sessions, seconds=20):
    # The direct test of the diagnosis, the way realtimeReport.md tested the 2400-baud transmitter
    # deadlock by writing $00 over txCharActive: on a machine that has gone silent with an empty
    # transmit ring and a non-zero uartPendingCount, write $00 over $E0A5 from the monitor - nothing
    # else - and see whether its bytes come back.  If they do, the byte the layers above wait on is
    # what wedged.  If they do not, the wedge is above the driver and no byte in the driver fixes it,
    # which is just as much of an answer.
    results = []
    for stage, addresses in (("uartPendingCount $E0A5", [0xE0A5]),
                             ("txCharActive $E5BB too", [0xE0A5, 0xE5BB])):
        before = [len(relay.log[0]), len(relay.log[1])]
        for session, tag in sessions:
            pauseAll([(session, tag)])
            for address in addresses:
                command(session, f"> {address:04x} 00")
            resumeAll([(session, tag)])
        time.sleep(seconds)
        moved = [len(relay.log[index]) - before[index] for index in (0, 1)]
        print(f"  {seconds} s after clearing {stage}: conn0 {moved[0]} bytes, conn1 {moved[1]} bytes",
              flush=True)
        results.append({"cleared": stage, "seconds": seconds, "bytes": moved})
    return results


def wedgeDump(session, tag, times=6):
    # Everything needed to say WHERE a silent machine is stuck rather than only that it is.  The
    # first 38400-baud run left machine B reading $DE01/$DE02/$DE03 as $FF/$00/$00 while machine A
    # read them correctly, which is what the monitor shows when the CPU it is reading through has the
    # I/O area banked out ($01 bit 2 clear) - so $01 and the program counter are the two bytes that
    # decide whether a wedge is in the driver or in the game above it.
    print(f"\n---- wedge dump [{tag}] ----", flush=True)
    for index in range(times):
        pauseAll([(session, tag)])
        command(session, "registers")
        command(session, "m 0000 0001")          # the bank register
        command(session, "m e039 e03a")          # the disk-load suspend handshake
        command(session, "m e42d e42f")          # uartRxCount and both ring indices
        command(session, "m e414 e416")          # uartTxCount and both transmit ring indices
        command(session, f"m {ACIA_BASE:04x} {ACIA_BASE + 3:04x}")
        command(session, "m 0ba5 0ba5")          # the game's solo/modem flag
        resumeAll([(session, tag)])
        time.sleep(1.0)


def moduleResident(session, tag, tries=60):
    # Wait for the opponent module itself, not for a byte inside it.  At true C64 speed the 1541
    # takes the best part of a minute to fetch track 34, and the fire press that starts the load does
    # not always land, so it is offered again every sixth poll while the options menu is still up.
    for attempt in range(tries):
        pauseAll([(session, tag)])
        got = readRange(session, 0xE000, 16)
        resumeAll([(session, tag)])
        if got == MODULE_JUMP_TABLE:
            print(f"[{tag}] the module is resident: the $E000 jump table matches", flush=True)
            return True
        print(f"[{tag}] waiting for the module: $E000 = "
              f"{' '.join('??' if value is None else f'{value:02X}' for value in got)}", flush=True)
        if attempt % 6 == 5:
            session.focus()
            session.hold("KP_0", 300)
        time.sleep(3)
    print(f"[{tag}] the module never became resident", flush=True)
    return False


def perSecond(samples):
    # The window's samples are cumulative relay byte counts; what matters for "did it keep making
    # progress" is the per-second delta and, above all, whether any second carried nothing.
    out = []
    for index in range(1, len(samples)):
        span = samples[index][0] - samples[index - 1][0]
        if span <= 0:
            continue
        out.append((round(span, 2),
                    (samples[index][1] - samples[index - 1][1]) / span,
                    (samples[index][2] - samples[index - 1][2]) / span))
    return out


def ringSample(session, tag):
    # How full the raw receive ring is right now.  This stops the emulator, so it never runs inside
    # a measured window.
    pauseAll([(session, tag)])
    ring = readRange(session, 0xE42D, 3)        # uartRxCount, read index, write index
    link = readRange(session, 0xE03B, 13)       # isLinkActive .. linkErrorCount
    acia = readRange(session, 0xE5BB, 4)        # txCharActive, statusSave, rxByte, command shadow
    txq = readRange(session, 0xE414, 1)         # uartTxCount
    resumeAll([(session, tag)])
    return {"tag": tag, "uartRxCount": ring[0], "uartTxCount": txq[0],
            "isLinkActive": link[0], "connectionPhase": link[5], "linkErrorCount": link[12],
            "txCharActive": acia[0], "aciaStatusSave": acia[1], "aciaCommandShadow": acia[3]}


def sampleRings(sessions, count, gap, runTag):
    # Repeated sampling, both machines, alternating.  The point is the distribution: the maximum the
    # ring ever reached, how often it was at the 20-byte limit, and whether connectionPhase moved
    # between 2 and 3 (degrade and recover) or sat still (wedge).
    rows = []
    for index in range(count):
        for session, tag in sessions:
            row = ringSample(session, tag)
            row["n"] = index
            rows.append(row)
            print(f"  [ring {index:02d}] [{tag}] uartRxCount={row['uartRxCount']} "
                  f"uartTxCount={row['uartTxCount']} phase={row['connectionPhase']} "
                  f"active=${(row['isLinkActive'] or 0):02X} err={row['linkErrorCount']} "
                  f"tx=${(row['txCharActive'] or 0):02X} cmd=${(row['aciaCommandShadow'] or 0):02X}",
                  flush=True)
        time.sleep(gap)
    with open(f"{LOGS}/highRate.{runTag}.ring.txt", "w") as handle:
        for row in rows:
            handle.write(f"{row['n']}\t{row['tag']}\t{row['uartRxCount']}\t{row['uartTxCount']}\t"
                         f"{row['connectionPhase']}\t{row['linkErrorCount']}\t"
                         f"{row['txCharActive']}\t{row['aciaCommandShadow']}\n")
    for _, tag in sessions:
        mine = [row for row in rows if row["tag"] == tag]
        depths = [row["uartRxCount"] for row in mine if row["uartRxCount"] is not None]
        phases = sorted({row["connectionPhase"] for row in mine})
        full = sum(1 for value in depths if value >= RING_SIZE)
        print(f"  [{tag}] uartRxCount over {len(depths)} samples: min {min(depths)}, "
              f"max {max(depths)}, mean {sum(depths) / len(depths):.1f}, at the {RING_SIZE}-byte "
              f"limit {full} times; connectionPhase values seen {phases}", flush=True)
    return rows


def setTraces(sessions, addresses):
    pauseAll(sessions)
    for session, tag in sessions:
        command(session, "del")
        for text in addresses:
            command(session, f"trace exec {text}")
        command(session, "break")
    resumeAll(sessions)


def summarise(name, run):
    print(f"\n--- {name} ---", flush=True)
    print(f"  {run['wall']:.2f} s wall, atFullSpeed={run['healthy']}", flush=True)
    for tag in run["cycles"]:
        print(f"   [{tag}] {run['cycles'][tag]} cycles = {run['cycles'][tag] / NTSC_HZ:.2f} "
              f"emulated s = {100.0 * run['speed'][tag]:.1f}% of real time", flush=True)
    for i in (0, 1):
        count = run["counts"][i]
        print(f"   conn{i} -> peer {count} bytes = {count / run['wall']:.2f} B/s = "
              f"{count / run['wall'] * BITS_PER_BYTE:.0f} bit/s of 8N1 line time; "
              f"wire {run['wire'][i]}", flush=True)
    buckets = perSecond(run["samples"])
    for i, name2 in ((1, "conn0"), (2, "conn1")):
        values = [row[i] for row in buckets]
        zeros = sum(1 for value in values if value < 0.5)
        print(f"   {name2} per second: min {min(values):.1f}, max {max(values):.1f}, "
              f"seconds carrying nothing: {zeros} of {len(values)}", flush=True)
    print(f"   tracepoints: {run['traceByTag']}", flush=True)
    print(f"   deadlock at the closing edge: {run['deadlocked']}", flush=True)
    for state in run["states"]:
        print(f"   [{state['tag']}] control {decodeControl(state['aciaControl'])}", flush=True)
        showState(state)


def main():
    disk = os.path.abspath(sys.argv[1])
    rateA = int(sys.argv[2])
    rateB = int(sys.argv[3]) if len(sys.argv) > 3 else int(sys.argv[2])
    seconds = float(sys.argv[4]) if len(sys.argv) > 4 else 75.0
    cleanWindows = int(sys.argv[5]) if len(sys.argv) > 5 else 1
    runTag = sys.argv[6] if len(sys.argv) > 6 else "h"
    os.makedirs(SHOTS, exist_ok=True)
    os.makedirs(LOGS, exist_ok=True)
    relay = NullModemRelay()
    print(f"relay on 127.0.0.1:{relay.port}; A wants {rateA} baud, B wants {rateB} baud", flush=True)
    # The host serial device's own baud setting is raised above every rate under test so that it can
    # never be the thing doing the limiting; the ACIA control register is what this test is about.
    args = aciaArgs(rsDevAddress=f"127.0.0.1:{relay.port}", baud=38400)
    a = ViceSession(disk, f"{SCRATCH}/hr.{runTag}.a.vice.log", args, label="A", warp=False)
    b = ViceSession(disk, f"{SCRATCH}/hr.{runTag}.b.vice.log", args, label="B", warp=False)
    sessions = [(a, "A"), (b, "B")]
    runs = []
    try:
        a.connect()
        b.connect()
        for session, tag in sessions:
            command(session, "warp on")
        a.bootPastLoader(waitSecs=900)
        b.bootPastLoader(waitSecs=900)
        pauseAll(sessions)
        for session, tag in sessions:
            command(session, "warp off")
            print(f"[{tag}] {command(session, 'warp').strip()}", flush=True)
        resumeAll(sessions)
        a.findWindow()
        b.findWindow()
        a.focus()
        b.focus()
        time.sleep(25)
        a.shot(f"{SHOTS}/hr{runTag}01aMenu.png")
        b.shot(f"{SHOTS}/hr{runTag}01bMenu.png")
        pickModemOpponent(a, "A", "a")
        pickModemOpponent(b, "B", "o")
        # Each machine gets its own rate, chosen at the "PRESS A OR O" prompt before the link is
        # ever opened, which is the order README.md tells the player to use.  baudIndex comes off
        # the disk as 0, so 300 needs no key - but pressing C= + 3 anyway proves the module is
        # resident and programs the chip at the same point in the sequence as every other rate.
        for (session, tag), rate in zip(sessions, (rateA, rateB)):
            if not moduleResident(session, tag):
                raise SystemExit(f"{tag}: the opponent module never loaded")
            selectRate(session, tag, rate)
        answerModemPrompts(a, "A", "a")
        answerModemPrompts(b, "B", "o")
        ok, phases = waitForPhase(sessions, 3, 180)
        print(f"both in the packet phase: {ok} ({phases})", flush=True)
        a.shot(f"{SHOTS}/hr{runTag}02aLinked.png")
        b.shot(f"{SHOTS}/hr{runTag}02bLinked.png")
        for state in [snapshot(session, tag) for session, tag in sessions]:
            print(f"  [{state['tag']}] control {decodeControl(state['aciaControl'])}", flush=True)
            showState(state)

        for index in range(cleanWindows):
            runs.append((f"clean w{index + 1}",
                         measure(relay, sessions, seconds,
                                 f"{rateA}/{rateB} baud, clean [{index + 1}/{cleanWindows}]",
                                 [], runTag, f"clean{index + 1}")))
        a.shot(f"{SHOTS}/hr{runTag}03aAfterClean.png")
        b.shot(f"{SHOTS}/hr{runTag}03bAfterClean.png")

        runs.append(("sync/recover", measure(relay, sessions, seconds,
                                             f"{rateA}/{rateB} baud, sync tracepoints",
                                             [f"${SYNC_ENTER:04x}", f"${SYNC_DONE:04x}"],
                                             runTag, "sync")))
        runs.append(("errors", measure(relay, sessions, min(seconds, 20.0),
                                       f"{rateA}/{rateB} baud, error tracepoints",
                                       ["$e73f", "$e505 if x > $13"], runTag, "err")))
        a.shot(f"{SHOTS}/hr{runTag}04aAfterTraced.png")
        b.shot(f"{SHOTS}/hr{runTag}04bAfterTraced.png")

        print("\n---- uartRxCount $E42D sampled repeatedly ----", flush=True)
        rows = sampleRings(sessions, 24, 0.5, runTag)
        probe = probeTxDeadlock(relay, sessions, runs[-1][1]["states"])
        print(f"deadlock probe: {probe}", flush=True)
        # A direction that carried nothing for a whole window is the wedge case, and it is worth more
        # than the byte count: dump where that machine actually is.
        # Both ends are dumped rather than the silent one, because which relay connection belongs to
        # which emulator is an inference (conn0 is whichever opened its ACIA first) and the machine
        # that is still talking is half the evidence.
        pending = None
        if any(0 in run["counts"] for _, run in runs):
            for session, tag in sessions:
                wedgeDump(session, tag)
            pending = probePendingCount(relay, sessions)
            for session, tag in sessions:
                wedgeDump(session, tag, times=2)
        a.shot(f"{SHOTS}/hr{runTag}05aEnd.png")
        b.shot(f"{SHOTS}/hr{runTag}05bEnd.png")

        print("\n================ SUMMARY ================", flush=True)
        print(f"A asked for {rateA} baud, B asked for {rateB} baud", flush=True)
        for name, run in runs:
            summarise(name, run)
        finalPhases = sorted({row["connectionPhase"] for row in rows})
        print(f"\nconnectionPhase values seen across the ring sampling: {finalPhases}", flush=True)
        print(f"deadlock probe: {probe}", flush=True)
        print(f"uartPendingCount probe: {pending}", flush=True)
    finally:
        a.close()
        b.close()
        time.sleep(1)
        relay.stop()
        for i in (0, 1):
            print(f"[relay] conn{i} total {len(relay.log[i])} bytes, "
                  f"tail {bytes(relay.log[i][-60:])!r}", flush=True)


if __name__ == "__main__":
    main()
