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