557 lines
28 KiB
Python
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()
|