#!/usr/bin/env python3
# testRealtime.py - two SwiftLink machines linked through the socket null modem, running at TRUE C64
# SPEED, with the traffic between them counted against the wall clock.
#
#   python3 testRealtime.py <disk.d64> [windowSeconds] [firstRate] [tag] [firstWindows] [secondWindows]
#
# firstWindows / secondWindows are how many consecutive measurement windows each rate gets (default
# 1).  A series is measured back to back with a full state readback at every edge and nothing thrown
# away: a window that carries no bytes is a result, not a failure.
#
# firstRate is the line rate the first window is measured at.  Anything other than 300 is selected
# with the driver's own hot key at the "PRESS A OR O" prompt, before the link is opened, which is the
# order README.md tells the player to use.  The second window is then measured at the other rate,
# switched on a live link, which is the case the README says also works.  tag names this run's
# screenshots and logs so two runs do not overwrite each other.
#
# Every earlier test in this directory ran in warp, so none of them says anything about real-time
# behaviour at a given line rate.  This one closes that gap:
#
#   * both emulators are started with warp=False and only the loader is warped, through the monitor's
#     own "warp on" / "warp off" commands.  VICE 3.7.1 has no WarpMode resource - which is what
#     earlier sessions concluded - but it does have a "warp" command, and that is what makes a
#     real-time measurement possible at all;
#   * the relay counts every byte in both directions, so bytes per second falls straight out of two
#     timestamps and two byte counts, and the counts are also sampled once a second during the
#     window, which costs nothing and shows whether the flow was steady or bursty;
#   * the monitor's stopwatch (the emulated CPU cycle counter, free running since the emulator
#     started) is read at both ends of the window, so the emulated time the window covers is known
#     independently of the host's load.  A machine that could not keep up shows fewer cycles than
#     wall clock, and its byte rate would then be low for a reason that has nothing to do with the
#     driver.  Every window records whether both machines held at least 90% of real time, and says so
#     next to its byte counts;
#   * a tracepoint on countLinkError ($E73F) counts every framing / parity / overrun character the
#     NMI receiver rejects.  A tracepoint prints and carries on - it never stops the emulator;
#   * the measurement is repeated at a second, higher line rate.
#
# Nothing is sent to either monitor between the two ends of a measurement window: any input stops
# that emulator, which would corrupt exactly the number being measured.  Reading the monitor socket
# is safe - it only collects what the tracepoint printed - so that is all the window does.
#
# Monitor plumbing: VICE's remote monitor does not delimit its replies, and viceHarness.mon() returns
# as soon as it sees a prompt, which is often the previous command's.  Everything below therefore
# talks to the socket through command()/pause()/resume(), which read to quiescence instead, and every
# resume is verified by watching the cycle counter actually advance.
import os
import re
import subprocess
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

SHOTS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shots")
LOGS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "testLogs")
NTSC_HZ = 1022727.0                     # VICE's C64 NTSC system clock
NTSC_FPS = 59.826                       # ... and its frame rate, which paces the link state machine
BITS_PER_BYTE = 10                      # 8N1: one start bit, eight data bits, one stop bit
MEM_LINE = re.compile(r">C:([0-9a-f]{4})((?: {1,3}[0-9a-f]{2})+)")
PROMPT = re.compile(r"\((?:C|8):\$[0-9a-f]{4}\)")

# The module's own table at $E6FD: control byte -> line rate, with the SwiftLink crystal doubling.
CONTROL_RATES = {0x15: 300, 0x17: 1200, 0x18: 2400, 0x1A: 4800, 0x1C: 9600, 0x1E: 19200,
                 0x1F: 38400}
BAUD_INDEX = {300: 0, 1200: 3, 2400: 6, 4800: 9, 9600: 12, 19200: 15, 38400: 18}
BAUD_HOTKEY = {300: "3", 1200: "1", 2400: "2", 4800: "4", 9600: "9", 19200: "0", 38400: "8"}


def analyseWire(buf):
    # What the bytes themselves say about the link's health, at no cost to the emulator.  A resync is
    # beginByteSyncPhase pouring out $00 until the peer answers, so a long run of $00 is the signature
    # of a link that has fallen out of the packet phase; $55 is the frame layer's lead byte.
    runs = 0
    longest = 0
    current = 0
    for byte in buf:
        if byte == 0x00:
            current += 1
            longest = max(longest, current)
        else:
            if current >= 8:
                runs += 1
            current = 0
    if current >= 8:
        runs += 1
    return {"bytes": len(buf), "zeroRuns8": runs, "longestZeroRun": longest,
            "leadBytes55": buf.count(b"\x55")}


def command(session, text, idle=0.4, timeout=10.0):
    flush(session, 0.05, 0.5)
    session.sock.sendall((text + "\n").encode())
    out = flush(session, idle, timeout)
    print(f"[{session.label}] >>> {text}\n{out}", flush=True)
    return out


def decodeControl(value):
    if value is None:
        return "unread"
    rate = CONTROL_RATES.get(value & 0x1F)
    bits = 8 - 2 * ((value >> 5) & 0x03)
    stop = 2 if value & 0x80 else 1
    clock = "internal baud generator" if value & 0x10 else "external clock"
    return (f"${value:02X} = {bits}N{stop}, {clock}, rate bits ${value & 0x0F:X} = "
            f"{rate if rate else 'unknown'} baud on a SwiftLink")


def flush(session, idle=0.4, timeout=10.0):
    # Read until the monitor has been quiet for `idle` seconds.  Replies are not delimited, so this
    # is the only way to be sure a whole reply has arrived.
    session.sock.settimeout(idle)
    text = ""
    deadline = time.time() + timeout
    while time.time() < deadline:
        try:
            chunk = session.sock.recv(65536)
            if not chunk:
                break
            text += chunk.decode("latin-1")
        except OSError:
            break
    return text


def hotKey(session, keysym, holdMs=600):
    # The game scans the raw keyboard matrix itself ($0DB7), so a modem hot key needs the Commodore
    # key and the digit physically down together.  VICE's default symbolic keymap puts the Commodore
    # key on Tab (gtk3_sym.vkm: "Tab 7 5"), not on Control_L - which is why the earlier session's
    # ctrl+c attempt at C=+C did nothing.  Control_L is CTRL, and scanKeyboard deliberately reports
    # CTRL as "no key".
    session.focus()
    subprocess.run(["xdotool", "keydown", "Tab"], env=session.env, check=False)
    time.sleep(0.2)
    subprocess.run(["xdotool", "keydown", keysym], env=session.env, check=False)
    time.sleep(holdMs / 1000.0)
    subprocess.run(["xdotool", "keyup", keysym], env=session.env, check=False)
    time.sleep(0.15)
    subprocess.run(["xdotool", "keyup", "Tab"], env=session.env, check=False)
    time.sleep(0.4)


def isTxDeadlocked(state):
    # startNextTxChar ($E607) refuses to touch the ACIA while txCharActive is set, and the NMI's
    # transmit half ($E6B6) ignores TDRE unless command bit 2 says the transmit interrupt was armed.
    # A machine holding both at once can never transmit again on its own.
    return bool(state["txCharActive"]) and not (state["aciaCommandShadow"] or 0) & 0x04


def measureSeries(relay, sessions, seconds, label, count, key="series", runTag="x"):
    # `count` consecutive windows, with every machine's state read at every edge.  This retries
    # nothing and discards nothing - an earlier version measured one window and threw it away when a
    # direction carried no bytes, which is the wrong rule for a deadlock test: whether the link is
    # still alive at the end of the series is the question, and a window that carried nothing is part
    # of the answer rather than a failed measurement.  The window still records whether both machines
    # ran at full speed ("healthy"), because a machine that could not keep up would show up as a slow
    # link for a reason that has nothing to do with the driver.  The pause a snapshot costs sits
    # between windows, never inside one.
    edge = [snapshot(session, tag) for session, tag in sessions]
    for state in edge:
        showState(state)
    windows = []
    for index in range(count):
        result = measureWindowOnce(relay, sessions, seconds, f"{label} [{index + 1}/{count}]")
        result["statesBefore"] = edge
        edge = [snapshot(session, tag) for session, tag in sessions]
        for state in edge:
            showState(state)
        result["states"] = edge
        result["healthy"] = all(value >= 0.9 for value in result["speed"].values())
        result["deadlocked"] = {state["tag"]: isTxDeadlocked(state) for state in edge}
        # Two tracepoints now: $E73F counts every character the NMI receiver threw away, and the
        # conditional one on $E505 counts only the subset that was thrown away because the 20-byte
        # receive ring was already full - so a bad character and a ring overflow can be told apart.
        # The monitor announces a hit as "#1 (Trace  exec e73f)" and then disassembles the line as
        # ".C:e73f ..." - no dollar sign on either - so the header is what to count.  countLinkError
        # moved from $E403 to $E73F when selectFramedLinkVectors grew the bytes that clear the ACIA
        # page latch; a tracepoint left at $E403 lands on the operand of an LDA and can never fire.
        result["traceHits"] = len(re.findall(r"exec e73f", result["traced"]))
        result["traceByTag"] = {tag: {"linkError$E73F": len(re.findall(r"exec e73f", text)),
                                      "rxRingFull$E505": len(re.findall(r"exec e505", text))}
                                for tag, text in result["tracedBy"].items()}
        print(f"  transmitter deadlock at this edge: {result['deadlocked']}", flush=True)
        print(f"  countLinkError ($E73F) tracepoint hits in this window: {result['traceHits']}",
              flush=True)
        print(f"  per machine: {result['traceByTag']}", flush=True)
        # Persisted as each window closes rather than at the end of the run: a run that falls over
        # later must not take the windows that already succeeded with it.
        open(f"{LOGS}/realtime.{runTag}.{key}.w{index + 1}.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}/realtime.{runTag}.{key}.w{index + 1}.trace.txt", "w").write(
                result["traced"])
        windows.append(result)
    return windows


def measureWindowOnce(relay, sessions, seconds, label):
    # The measurement proper.  Both emulators are stopped before either cycle counter is read, so the
    # two counts cover the same window, and nothing is sent to either monitor in between.
    pauseAll(sessions)
    startCycles = {tag: stopwatch(session) for session, tag in sessions}
    resumeAll(sessions)
    t0 = time.time()
    marks0 = [len(relay.log[0]), len(relay.log[1])]
    traced = ""
    tracedBy = {tag: "" for _, tag in sessions}
    samples = []
    while time.time() - t0 < seconds:
        # Reading the monitor socket does not pause the emulator; it only collects tracepoint hits.
        # Keeping each machine's hits separate is what makes an error attributable to one end.
        for session, tag in sessions:
            chunk = flush(session, 0.5, 0.6)
            traced += chunk
            tracedBy[tag] += chunk
        samples.append((round(time.time() - t0, 2), len(relay.log[0]), len(relay.log[1])))
    t1 = time.time()
    marks1 = [len(relay.log[0]), len(relay.log[1])]
    pauseAll(sessions)
    endCycles = {tag: stopwatch(session) for session, tag in sessions}
    resumeAll(sessions)
    wall = t1 - t0
    cycles = {tag: (endCycles[tag] - startCycles[tag]) if None not in (endCycles[tag],
                                                                      startCycles[tag]) else None
              for tag in endCycles}
    speed = {tag: (value / NTSC_HZ / wall if value else 0.0) for tag, value in cycles.items()}
    result = {"label": label, "wall": wall, "cycles": cycles, "speed": speed, "traced": traced,
              "tracedBy": tracedBy, "samples": samples,
              "counts": [marks1[0] - marks0[0], marks1[1] - marks0[1]],
              "wire": [analyseWire(bytes(relay.log[i][marks0[i]:marks1[i]])) for i in (0, 1)]}
    print(f"\n=== window '{label}': {wall:.2f} s wall ===", flush=True)
    for tag in cycles:
        print(f"  [{tag}] {cycles[tag]} emulated cycles = {cycles[tag] / NTSC_HZ:.2f} emulated "
              f"seconds = {100.0 * speed[tag]:.1f}% of real time", flush=True)
    for i in (0, 1):
        count = result["counts"][i]
        print(f"  conn{i} -> peer: {count} bytes in {wall:.2f} s = {count / wall:.2f} bytes/s = "
              f"{count / wall * BITS_PER_BYTE:.0f} bit/s of 8N1 line time; wire: {result['wire'][i]}",
              flush=True)
    return result


def pauseAll(sessions):
    for session, _ in sessions:
        flush(session, 0.05, 0.5)
        session.sock.sendall(b"\n")
    for session, _ in sessions:
        flush(session, 0.4, 6)


def probeTxDeadlock(relay, sessions, states, seconds=15):
    # A direct test of the diagnosis rather than an inference from it: on a machine that has gone
    # silent with txCharActive set and the transmit interrupt disarmed, clear txCharActive ($E5BB)
    # through the monitor - nothing else - and see whether its bytes come back.
    stalled = [(session, tag) for (session, tag), state in zip(sessions, states)
               if isTxDeadlocked(state)]
    if not stalled:
        return None
    before = [len(relay.log[0]), len(relay.log[1])]
    for session, tag in stalled:
        print(f"[{tag}] transmitter deadlock: clearing txCharActive $E5BB from the monitor",
              flush=True)
        pauseAll([(session, tag)])
        command(session, "> e5bb 00")
        resumeAll([(session, tag)])
    time.sleep(seconds)
    after = [len(relay.log[0]), len(relay.log[1])]
    moved = [after[i] - before[i] for i in (0, 1)]
    print(f"  {seconds} s after clearing txCharActive: conn0 {moved[0]} bytes, "
          f"conn1 {moved[1]} bytes", flush=True)
    return {"tags": [tag for _, tag in stalled], "seconds": seconds, "bytes": moved}


def readRange(session, start, count, tries=4):
    # One monitor round trip for a whole range, so a state read pauses the emulator once instead of
    # once per byte.  Reads have no side effects: the monitor's sidefx default is off, so peeking at
    # $DE00 does not eat a received character.
    for _ in range(tries):
        out = command(session, f"m {start:04x} {start + count - 1:04x}")
        got = {}
        for match in MEM_LINE.finditer(out):
            base = int(match.group(1), 16)
            values = [int(token, 16) for token in match.group(2).split()]
            for offset, value in enumerate(values[:16]):
                got[base + offset] = value
        if all(start + i in got for i in range(count)):
            return [got[start + i] for i in range(count)]
    return [None] * count


def resumeAll(sessions, tries=4):
    # The one thing this script cannot tolerate is an emulator that quietly stays at the monitor
    # prompt: it would show up as a dead link rather than as a broken test.  A resumed emulator
    # prints nothing, so a prompt coming back means the "x" did not take, and it is sent again.
    for session, tag in sessions:
        for attempt in range(tries):
            flush(session, 0.05, 0.5)
            session.sock.sendall(b"x\n")
            out = flush(session, 0.3, 2)
            if not PROMPT.search(out):
                break
            print(f"[{tag}] still at the monitor prompt after 'x' - retrying", flush=True)


def selectRate(session, tag, rate, tries=6):
    # Press the speed hot key at the A/O prompt and check that the driver took it.  baudIndex ($E055)
    # is written by the hot key itself and by nothing else, so it is the honest proof.
    for attempt in range(tries):
        pauseAll([(session, tag)])
        hook = readRange(session, 0xE013, 2)
        resumeAll([(session, tag)])
        if hook == [0xB7, 0x0D]:
            hotKey(session, BAUD_HOTKEY[rate])
            state = snapshot(session, tag)
            showState(state)
            if state["baudIndex"] == BAUD_INDEX[rate]:
                print(f"[{tag}] {rate} baud selected before the link was opened", flush=True)
                return True
        else:
            print(f"[{tag}] the module's keyboard vector is still {hook} - waiting", flush=True)
        time.sleep(3)
    print(f"[{tag}] could not select {rate} baud with the hot key", flush=True)
    return False


def setBaud(sessions, rate, useHotKey=True):
    # Try the driver's own hot key first, and fall back to programming the 6551 from the monitor.
    # Both machines have to change together or each is listening at the wrong rate, so the fallback
    # stops both emulators, writes both, and only then lets either of them run again.
    if useHotKey:
        for session, tag in sessions:
            hotKey(session, BAUD_HOTKEY[rate])
        time.sleep(1)
        states = [snapshot(session, tag) for session, tag in sessions]
        for state in states:
            showState(state)
        if all(state["aciaControl"] is not None and
               CONTROL_RATES.get(state["aciaControl"] & 0x1F) == rate for state in states):
            return f"hot key C= + {BAUD_HOTKEY[rate]} (Commodore key = Tab)", states
        print("  the hot key did not take on both machines - falling back to the monitor", flush=True)
    control = [key for key, value in CONTROL_RATES.items() if value == rate][0]
    pauseAll(sessions)
    for session, tag in sessions:
        # sidefx has to be on for the write to reach the chip, and back off afterwards so that later
        # reads of the data register do not consume a received character.
        command(session, "sidefx on")
        command(session, f"> {ACIA_BASE + 3:04x} {control:02x}")
        command(session, "sidefx off")
        command(session, f"> e055 {BAUD_INDEX[rate]:02x}")  # so a re-open picks the same rate
        command(session, f"> e056 {control:02x}")           # bitPeriodLo mirrors the control byte
    resumeAll(sessions)
    states = [snapshot(session, tag) for session, tag in sessions]
    for state in states:
        showState(state)
    return f"direct monitor write of ${control:02X} to ${ACIA_BASE + 3:04X}, $E055 and $E056", states


def showState(state):
    def hexOf(name):
        value = state[name]
        return "??" if value is None else f"${value:02X}"

    print(f"  [{state['tag']}] phase={state['connectionPhase']} "
          f"isLinkActive={state['isLinkActive']} linkErrorCount={state['linkErrorCount']} "
          f"linkStatus={hexOf('linkStatus')} baudIndex={state['baudIndex']} "
          f"${ACIA_BASE + 3:04X}={hexOf('aciaControl')} ${ACIA_BASE + 1:04X}={hexOf('aciaStatus')} "
          f"${ACIA_BASE + 2:04X}={hexOf('aciaCommand')} "
          f"bitPeriodLo={hexOf('bitPeriodLo')} statusSave={hexOf('aciaStatusSave')} "
          f"cmdShadow={hexOf('aciaCommandShadow')} txCharActive={hexOf('txCharActive')} "
          f"uartPending={hexOf('uartPendingCount')} syncRound={hexOf('packetProtocolState')} "
          f"lastHotkey={hexOf('lastHotkeyCode')} lockout={state['inputLockoutTimer']}", flush=True)


def snapshot(session, tag):
    # Everything worth knowing about one machine, in six monitor round trips.
    pauseAll([(session, tag)])
    link = readRange(session, 0xE03B, 13)       # isLinkActive .. linkErrorCount
    pending = readRange(session, 0xE0A5, 1)     # uartPendingCount
    baud = readRange(session, 0xE055, 2)        # baudIndex, bitPeriodLo (the live control byte)
    acia = readRange(session, 0xE5BB, 4)        # txCharActive, statusSave, rxByte, command shadow
    regs = readRange(session, ACIA_BASE, 4)     # the 6551 itself, wherever it is strapped
    sync = readRange(session, 0xEAB7, 1)        # packetProtocolState / syncRoundCounter
    lockout = readRange(session, 0x0B7D, 1)[0]  # inputLockoutTimer: non-zero blocks the key poll
    state = {"tag": tag,
             "isLinkActive": link[0], "linkStatus": link[1], "connectionPhase": link[5],
             "lastHotkeyCode": link[11], "linkErrorCount": link[12],
             "uartPendingCount": pending[0], "packetProtocolState": sync[0],
             "baudIndex": baud[0], "bitPeriodLo": baud[1],
             "txCharActive": acia[0], "aciaStatusSave": acia[1], "aciaCommandShadow": acia[3],
             "aciaStatus": regs[1], "aciaCommand": regs[2], "aciaControl": regs[3],
             "inputLockoutTimer": lockout}
    resumeAll([(session, tag)])
    return state


def stopwatch(session):
    # The cycle counter is free running from the moment the emulator started, so the window's
    # emulated length is the difference between two readings - no reset, one command each.
    for _ in range(4):
        match = re.search(r"Stopwatch:\s+(\d+)", command(session, "stopwatch"))
        if match:
            return int(match.group(1))
    return None


def waitForPhase(sessions, want, timeout):
    deadline = time.time() + timeout
    phases = {}
    while True:
        for session, tag in sessions:
            pauseAll([(session, tag)])
            phases[tag] = readRange(session, 0xE040, 1)[0]
            resumeAll([(session, tag)])
        print(f"  phases: {phases}", flush=True)
        if all(value == want for value in phases.values()) or time.time() > deadline:
            return all(value == want for value in phases.values()), phases
        time.sleep(3)


def main():
    disk = os.path.abspath(sys.argv[1])
    seconds = float(sys.argv[2]) if len(sys.argv) > 2 else 75.0
    firstRate = int(sys.argv[3]) if len(sys.argv) > 3 else 300
    runTag = sys.argv[4] if len(sys.argv) > 4 else "a"
    firstWindows = int(sys.argv[5]) if len(sys.argv) > 5 else 1
    secondWindows = int(sys.argv[6]) if len(sys.argv) > 6 else 1
    secondRate = 2400 if firstRate != 2400 else 300
    os.makedirs(SHOTS, exist_ok=True)
    os.makedirs(LOGS, exist_ok=True)
    relay = NullModemRelay()
    print(f"relay on 127.0.0.1:{relay.port}", flush=True)
    args = aciaArgs(rsDevAddress=f"127.0.0.1:{relay.port}", baud=2400)
    a = ViceSession(disk, f"{SCRATCH}/rt.a.vice.log", args, label="A", warp=False)
    b = ViceSession(disk, f"{SCRATCH}/rt.b.vice.log", args, label="B", warp=False)
    sessions = [(a, "A"), (b, "B")]
    results = {}
    try:
        a.connect()
        b.connect()
        # Warp the loader by hand.  x64sc was started with +warp, so this is the only warping there
        # is, and it is switched off again the moment the game is up.
        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()

        # True speed from here on, so the menu needs real seconds rather than warped ones.
        time.sleep(25)
        a.shot(f"{SHOTS}/rt{runTag}01aMenu.png")
        b.shot(f"{SHOTS}/rt{runTag}01bMenu.png")
        pickModemOpponent(a, "A", "a")
        pickModemOpponent(b, "B", "o")
        if firstRate != 300:
            # README.md's order: fire, wait for "PRESS A OR O", then the speed hot key, then A or O,
            # then space.  baudIndex comes off the disk as 0 every time the module is loaded, so this
            # is the only way a link ever opens at anything but 300 baud.  The hot key cannot land
            # until initCommModule has patched the module's keyboard vector to the game's own
            # scanner, so wait for that first: before it runs, $E015 goes to returnNoKey and every
            # key is answered with $FF.
            for session, tag in sessions:
                selectRate(session, tag, firstRate)
        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}/rt{runTag}02aLinked.png")
        b.shot(f"{SHOTS}/rt{runTag}02bLinked.png")

        # Count every rejected character for the rest of the run.
        pauseAll(sessions)
        for session, tag in sessions:
            command(session, "trace exec $e73f")
            # X holds uartRxCount at $E505, and the monitor reads condition numbers as hex, so
            # "x > $13" is "the ring already holds all 20 characters" - the overflow half of $E73F.
            command(session, "trace exec $e505 if x > $13")
            command(session, "break")
        resumeAll(sessions)

        for state in [snapshot(session, tag) for session, tag in sessions]:
            showState(state)
        firstKey = f"run{firstRate}"
        secondKey = f"run{secondRate}"
        results[firstKey] = measureSeries(relay, sessions, seconds, f"{firstRate} baud",
                                          firstWindows, firstKey, runTag)
        results[firstKey + "Probe"] = probeTxDeadlock(relay, sessions,
                                                      results[firstKey][-1]["states"])
        a.shot(f"{SHOTS}/rt{runTag}03aAfterFirst.png")
        b.shot(f"{SHOTS}/rt{runTag}03bAfterFirst.png")

        if secondWindows:
            method, states = setBaud(sessions, secondRate)
            print(f"switched to {secondRate} baud by {method}", flush=True)
            results["switchMethod"] = method
            ok, phases = waitForPhase(sessions, 3, 120)
            print(f"back in the packet phase after the switch: {ok} ({phases})", flush=True)
            results[secondKey] = measureSeries(relay, sessions, seconds,
                                               f"{secondRate} baud, switched on a live link",
                                               secondWindows, secondKey, runTag)
            results[secondKey + "Probe"] = probeTxDeadlock(relay, sessions,
                                                           results[secondKey][-1]["states"])
        pauseAll(sessions)
        for session, tag in sessions:
            command(session, "break")
            command(session, "del")
        resumeAll(sessions)
        a.shot(f"{SHOTS}/rt{runTag}04aAfterSecond.png")
        b.shot(f"{SHOTS}/rt{runTag}04bAfterSecond.png")

        print("\n================ SUMMARY ================", flush=True)
        for key in (firstKey, secondKey):
            for index, run in enumerate(results.get(key) or []):
                print(f"{run['label']}: {run['wall']:.2f} s wall, atFullSpeed={run['healthy']}",
                      flush=True)
                for name in run["cycles"]:
                    print(f"   [{name}] {run['cycles'][name]} cycles = "
                          f"{run['cycles'][name] / NTSC_HZ:.2f} emulated s = "
                          f"{100.0 * run['speed'][name]:.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, "
                          f"{count / run['wall'] / NTSC_FPS:.3f} bytes per frame; "
                          f"wire {run['wire'][i]}", flush=True)
                for state in run["states"]:
                    print(f"   [{state['tag']}] control {decodeControl(state['aciaControl'])}",
                          flush=True)
                    showState(state)
                print(f"   countLinkError ($E73F) tracepoint hits during the window: "
                      f"{run['traceHits']}", flush=True)
                print(f"   by machine, and how many were receive-ring overflows: "
                      f"{run['traceByTag']}", flush=True)
                print(f"   transmitter deadlock at the closing edge: {run['deadlocked']}",
                      flush=True)
            print(f"deadlock probe after the {key} series: {results.get(key + 'Probe')}", flush=True)
        print(f"baud switch method: {results.get('switchMethod')}", 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()
