modemwars/swiftlink/testRealtime.py
2026-08-23 02:09:40 -05:00

557 lines
28 KiB
Python

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