363 lines
19 KiB
Python
363 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
# testHighRates.py - what the SwiftLink driver does at the line rates a C64 Ultimate's SwiftLink
|
|
# emulation might hand it, and what it does when the two ends do not agree on the rate.
|
|
#
|
|
# python3 testHighRates.py <disk.d64> <rateA> <rateB> [windowSeconds] [cleanWindows] [tag]
|
|
#
|
|
# It reuses testRealtime.py wholesale - the socket null modem, the "warp only the loader" boot, the
|
|
# hot-key rate selection, the stopwatch check that the emulators really ran at 1 MHz, and the
|
|
# transmitter-deadlock probe. What it adds is aimed at one question the earlier reports could not
|
|
# answer: at a rate the 20-byte receive ring cannot keep up with, does the link make progress,
|
|
# degrade and RECOVER, or WEDGE?
|
|
#
|
|
# * rateA and rateB are selected independently, so 38400 against 300 - a device that ignores the
|
|
# baud bits talking to a peer that does not - is the same script with different arguments;
|
|
# * a window is measured three ways rather than once, because the instruments are not free:
|
|
# w1..wN clean, no tracepoints at all, so the byte counts are the honest throughput;
|
|
# wSync with tracepoints on beginByteSyncPhase ($E805) and on the instruction that
|
|
# completes a sync ($E855, "sta ackPending", reached only when the peer's $FF has
|
|
# arrived). Those two count degradations and recoveries directly, and they are low
|
|
# volume - at most a few per second - where a tracepoint on the error counter is not;
|
|
# wErr a short window with the error tracepoints from rate1200Report.md: $E73F (every
|
|
# character the receiver threw away) and the conditional $E505 if x > $13 (the subset
|
|
# thrown away because the 20-byte ring was already full). At 38400 that can print
|
|
# thousands of lines a second, which is why it is short and separate;
|
|
# * uartRxCount ($E42D) is then sampled repeatedly with the emulator stopped, to see how full the
|
|
# raw receive ring actually gets, alongside connectionPhase, isLinkActive, txCharActive and the
|
|
# command shadow. Those reads DO stop the emulator, so they are outside every measured window.
|
|
#
|
|
# The rig's own limits, which the report has to repeat: VICE's ACIA is byte level, so two ends at
|
|
# different rates do not garble each other the way real hardware would - the fast sender simply
|
|
# fills the slow receiver's socket buffer.
|
|
import os
|
|
import sys
|
|
import time
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from viceHarness import ViceSession, aciaArgs, ACIA_BASE, SCRATCH
|
|
from testTwoMachines import NullModemRelay, pickModemOpponent, answerModemPrompts
|
|
from testRealtime import (BITS_PER_BYTE, CONTROL_RATES, LOGS, NTSC_HZ, SHOTS, command,
|
|
decodeControl, isTxDeadlocked, measureWindowOnce, pauseAll,
|
|
probeTxDeadlock, readRange, resumeAll, selectRate, showState, snapshot,
|
|
waitForPhase)
|
|
|
|
RING_SIZE = 20 # pushUartRxRing $E505 refuses a 21st character
|
|
SYNC_ENTER = 0xE805 # beginByteSyncPhase: the link threw the connection away
|
|
SYNC_DONE = 0xE855 # sta ackPending: the peer answered $FF, phase 3 is next
|
|
# The module's own jump table. Sixteen bytes that cannot be anything else, which is the only honest
|
|
# "the opponent module has landed" test - see test1200AutoBaud.waitForModule and rate1200Report.md
|
|
# section 6. $E5BE and $E013/$E014 both read plausible values out of the previous overlay's wreckage
|
|
# while the 1541 is still fetching track 34, and a run that trusts them measures a machine that never
|
|
# opened its link at all.
|
|
MODULE_JUMP_TABLE = [0x4C, 0x11, 0xE1, 0x4C, 0x9A, 0xE2, 0x4C, 0xA9, 0xE0, 0x4C, 0xF6, 0xE0, 0x4C,
|
|
0xCB, 0xE3, 0x60]
|
|
|
|
|
|
def countHits(text, address):
|
|
return text.count(f"exec {address:04x}")
|
|
|
|
|
|
def measure(relay, sessions, seconds, label, traces, runTag, key):
|
|
# One window, with whichever tracepoints this window is meant to carry, and a full state read of
|
|
# both machines at each edge. Nothing is retried and nothing is discarded: a window that
|
|
# carried no bytes is the answer to the question, not a failed measurement.
|
|
setTraces(sessions, traces)
|
|
before = [snapshot(session, tag) for session, tag in sessions]
|
|
for state in before:
|
|
showState(state)
|
|
result = measureWindowOnce(relay, sessions, seconds, label)
|
|
after = [snapshot(session, tag) for session, tag in sessions]
|
|
for state in after:
|
|
showState(state)
|
|
setTraces(sessions, [])
|
|
result["statesBefore"] = before
|
|
result["states"] = after
|
|
result["healthy"] = all(value >= 0.9 for value in result["speed"].values())
|
|
result["deadlocked"] = {state["tag"]: isTxDeadlocked(state) for state in after}
|
|
result["traceByTag"] = {tag: {"syncEnter$E805": countHits(text, SYNC_ENTER),
|
|
"syncDone$E855": countHits(text, SYNC_DONE),
|
|
"linkError$E73F": countHits(text, 0xE73F),
|
|
"rxRingFull$E505": countHits(text, 0xE505)}
|
|
for tag, text in result["tracedBy"].items()}
|
|
print(f" transmitter deadlock at this edge: {result['deadlocked']}", flush=True)
|
|
print(f" tracepoint hits by machine: {result['traceByTag']}", flush=True)
|
|
open(f"{LOGS}/highRate.{runTag}.{key}.samples.txt", "w").write(
|
|
"\n".join(f"{t}\t{c0}\t{c1}" for t, c0, c1 in result["samples"]) + "\n")
|
|
if result["traced"].strip():
|
|
open(f"{LOGS}/highRate.{runTag}.{key}.trace.txt", "w").write(result["traced"])
|
|
return result
|
|
|
|
|
|
def probePendingCount(relay, sessions, seconds=20):
|
|
# The direct test of the diagnosis, the way realtimeReport.md tested the 2400-baud transmitter
|
|
# deadlock by writing $00 over txCharActive: on a machine that has gone silent with an empty
|
|
# transmit ring and a non-zero uartPendingCount, write $00 over $E0A5 from the monitor - nothing
|
|
# else - and see whether its bytes come back. If they do, the byte the layers above wait on is
|
|
# what wedged. If they do not, the wedge is above the driver and no byte in the driver fixes it,
|
|
# which is just as much of an answer.
|
|
results = []
|
|
for stage, addresses in (("uartPendingCount $E0A5", [0xE0A5]),
|
|
("txCharActive $E5BB too", [0xE0A5, 0xE5BB])):
|
|
before = [len(relay.log[0]), len(relay.log[1])]
|
|
for session, tag in sessions:
|
|
pauseAll([(session, tag)])
|
|
for address in addresses:
|
|
command(session, f"> {address:04x} 00")
|
|
resumeAll([(session, tag)])
|
|
time.sleep(seconds)
|
|
moved = [len(relay.log[index]) - before[index] for index in (0, 1)]
|
|
print(f" {seconds} s after clearing {stage}: conn0 {moved[0]} bytes, conn1 {moved[1]} bytes",
|
|
flush=True)
|
|
results.append({"cleared": stage, "seconds": seconds, "bytes": moved})
|
|
return results
|
|
|
|
|
|
def wedgeDump(session, tag, times=6):
|
|
# Everything needed to say WHERE a silent machine is stuck rather than only that it is. The
|
|
# first 38400-baud run left machine B reading $DE01/$DE02/$DE03 as $FF/$00/$00 while machine A
|
|
# read them correctly, which is what the monitor shows when the CPU it is reading through has the
|
|
# I/O area banked out ($01 bit 2 clear) - so $01 and the program counter are the two bytes that
|
|
# decide whether a wedge is in the driver or in the game above it.
|
|
print(f"\n---- wedge dump [{tag}] ----", flush=True)
|
|
for index in range(times):
|
|
pauseAll([(session, tag)])
|
|
command(session, "registers")
|
|
command(session, "m 0000 0001") # the bank register
|
|
command(session, "m e039 e03a") # the disk-load suspend handshake
|
|
command(session, "m e42d e42f") # uartRxCount and both ring indices
|
|
command(session, "m e414 e416") # uartTxCount and both transmit ring indices
|
|
command(session, f"m {ACIA_BASE:04x} {ACIA_BASE + 3:04x}")
|
|
command(session, "m 0ba5 0ba5") # the game's solo/modem flag
|
|
resumeAll([(session, tag)])
|
|
time.sleep(1.0)
|
|
|
|
|
|
def moduleResident(session, tag, tries=60):
|
|
# Wait for the opponent module itself, not for a byte inside it. At true C64 speed the 1541
|
|
# takes the best part of a minute to fetch track 34, and the fire press that starts the load does
|
|
# not always land, so it is offered again every sixth poll while the options menu is still up.
|
|
for attempt in range(tries):
|
|
pauseAll([(session, tag)])
|
|
got = readRange(session, 0xE000, 16)
|
|
resumeAll([(session, tag)])
|
|
if got == MODULE_JUMP_TABLE:
|
|
print(f"[{tag}] the module is resident: the $E000 jump table matches", flush=True)
|
|
return True
|
|
print(f"[{tag}] waiting for the module: $E000 = "
|
|
f"{' '.join('??' if value is None else f'{value:02X}' for value in got)}", flush=True)
|
|
if attempt % 6 == 5:
|
|
session.focus()
|
|
session.hold("KP_0", 300)
|
|
time.sleep(3)
|
|
print(f"[{tag}] the module never became resident", flush=True)
|
|
return False
|
|
|
|
|
|
def perSecond(samples):
|
|
# The window's samples are cumulative relay byte counts; what matters for "did it keep making
|
|
# progress" is the per-second delta and, above all, whether any second carried nothing.
|
|
out = []
|
|
for index in range(1, len(samples)):
|
|
span = samples[index][0] - samples[index - 1][0]
|
|
if span <= 0:
|
|
continue
|
|
out.append((round(span, 2),
|
|
(samples[index][1] - samples[index - 1][1]) / span,
|
|
(samples[index][2] - samples[index - 1][2]) / span))
|
|
return out
|
|
|
|
|
|
def ringSample(session, tag):
|
|
# How full the raw receive ring is right now. This stops the emulator, so it never runs inside
|
|
# a measured window.
|
|
pauseAll([(session, tag)])
|
|
ring = readRange(session, 0xE42D, 3) # uartRxCount, read index, write index
|
|
link = readRange(session, 0xE03B, 13) # isLinkActive .. linkErrorCount
|
|
acia = readRange(session, 0xE5BB, 4) # txCharActive, statusSave, rxByte, command shadow
|
|
txq = readRange(session, 0xE414, 1) # uartTxCount
|
|
resumeAll([(session, tag)])
|
|
return {"tag": tag, "uartRxCount": ring[0], "uartTxCount": txq[0],
|
|
"isLinkActive": link[0], "connectionPhase": link[5], "linkErrorCount": link[12],
|
|
"txCharActive": acia[0], "aciaStatusSave": acia[1], "aciaCommandShadow": acia[3]}
|
|
|
|
|
|
def sampleRings(sessions, count, gap, runTag):
|
|
# Repeated sampling, both machines, alternating. The point is the distribution: the maximum the
|
|
# ring ever reached, how often it was at the 20-byte limit, and whether connectionPhase moved
|
|
# between 2 and 3 (degrade and recover) or sat still (wedge).
|
|
rows = []
|
|
for index in range(count):
|
|
for session, tag in sessions:
|
|
row = ringSample(session, tag)
|
|
row["n"] = index
|
|
rows.append(row)
|
|
print(f" [ring {index:02d}] [{tag}] uartRxCount={row['uartRxCount']} "
|
|
f"uartTxCount={row['uartTxCount']} phase={row['connectionPhase']} "
|
|
f"active=${(row['isLinkActive'] or 0):02X} err={row['linkErrorCount']} "
|
|
f"tx=${(row['txCharActive'] or 0):02X} cmd=${(row['aciaCommandShadow'] or 0):02X}",
|
|
flush=True)
|
|
time.sleep(gap)
|
|
with open(f"{LOGS}/highRate.{runTag}.ring.txt", "w") as handle:
|
|
for row in rows:
|
|
handle.write(f"{row['n']}\t{row['tag']}\t{row['uartRxCount']}\t{row['uartTxCount']}\t"
|
|
f"{row['connectionPhase']}\t{row['linkErrorCount']}\t"
|
|
f"{row['txCharActive']}\t{row['aciaCommandShadow']}\n")
|
|
for _, tag in sessions:
|
|
mine = [row for row in rows if row["tag"] == tag]
|
|
depths = [row["uartRxCount"] for row in mine if row["uartRxCount"] is not None]
|
|
phases = sorted({row["connectionPhase"] for row in mine})
|
|
full = sum(1 for value in depths if value >= RING_SIZE)
|
|
print(f" [{tag}] uartRxCount over {len(depths)} samples: min {min(depths)}, "
|
|
f"max {max(depths)}, mean {sum(depths) / len(depths):.1f}, at the {RING_SIZE}-byte "
|
|
f"limit {full} times; connectionPhase values seen {phases}", flush=True)
|
|
return rows
|
|
|
|
|
|
def setTraces(sessions, addresses):
|
|
pauseAll(sessions)
|
|
for session, tag in sessions:
|
|
command(session, "del")
|
|
for text in addresses:
|
|
command(session, f"trace exec {text}")
|
|
command(session, "break")
|
|
resumeAll(sessions)
|
|
|
|
|
|
def summarise(name, run):
|
|
print(f"\n--- {name} ---", flush=True)
|
|
print(f" {run['wall']:.2f} s wall, atFullSpeed={run['healthy']}", flush=True)
|
|
for tag in run["cycles"]:
|
|
print(f" [{tag}] {run['cycles'][tag]} cycles = {run['cycles'][tag] / NTSC_HZ:.2f} "
|
|
f"emulated s = {100.0 * run['speed'][tag]:.1f}% of real time", flush=True)
|
|
for i in (0, 1):
|
|
count = run["counts"][i]
|
|
print(f" conn{i} -> peer {count} bytes = {count / run['wall']:.2f} B/s = "
|
|
f"{count / run['wall'] * BITS_PER_BYTE:.0f} bit/s of 8N1 line time; "
|
|
f"wire {run['wire'][i]}", flush=True)
|
|
buckets = perSecond(run["samples"])
|
|
for i, name2 in ((1, "conn0"), (2, "conn1")):
|
|
values = [row[i] for row in buckets]
|
|
zeros = sum(1 for value in values if value < 0.5)
|
|
print(f" {name2} per second: min {min(values):.1f}, max {max(values):.1f}, "
|
|
f"seconds carrying nothing: {zeros} of {len(values)}", flush=True)
|
|
print(f" tracepoints: {run['traceByTag']}", flush=True)
|
|
print(f" deadlock at the closing edge: {run['deadlocked']}", flush=True)
|
|
for state in run["states"]:
|
|
print(f" [{state['tag']}] control {decodeControl(state['aciaControl'])}", flush=True)
|
|
showState(state)
|
|
|
|
|
|
def main():
|
|
disk = os.path.abspath(sys.argv[1])
|
|
rateA = int(sys.argv[2])
|
|
rateB = int(sys.argv[3]) if len(sys.argv) > 3 else int(sys.argv[2])
|
|
seconds = float(sys.argv[4]) if len(sys.argv) > 4 else 75.0
|
|
cleanWindows = int(sys.argv[5]) if len(sys.argv) > 5 else 1
|
|
runTag = sys.argv[6] if len(sys.argv) > 6 else "h"
|
|
os.makedirs(SHOTS, exist_ok=True)
|
|
os.makedirs(LOGS, exist_ok=True)
|
|
relay = NullModemRelay()
|
|
print(f"relay on 127.0.0.1:{relay.port}; A wants {rateA} baud, B wants {rateB} baud", flush=True)
|
|
# The host serial device's own baud setting is raised above every rate under test so that it can
|
|
# never be the thing doing the limiting; the ACIA control register is what this test is about.
|
|
args = aciaArgs(rsDevAddress=f"127.0.0.1:{relay.port}", baud=38400)
|
|
a = ViceSession(disk, f"{SCRATCH}/hr.{runTag}.a.vice.log", args, label="A", warp=False)
|
|
b = ViceSession(disk, f"{SCRATCH}/hr.{runTag}.b.vice.log", args, label="B", warp=False)
|
|
sessions = [(a, "A"), (b, "B")]
|
|
runs = []
|
|
try:
|
|
a.connect()
|
|
b.connect()
|
|
for session, tag in sessions:
|
|
command(session, "warp on")
|
|
a.bootPastLoader(waitSecs=900)
|
|
b.bootPastLoader(waitSecs=900)
|
|
pauseAll(sessions)
|
|
for session, tag in sessions:
|
|
command(session, "warp off")
|
|
print(f"[{tag}] {command(session, 'warp').strip()}", flush=True)
|
|
resumeAll(sessions)
|
|
a.findWindow()
|
|
b.findWindow()
|
|
a.focus()
|
|
b.focus()
|
|
time.sleep(25)
|
|
a.shot(f"{SHOTS}/hr{runTag}01aMenu.png")
|
|
b.shot(f"{SHOTS}/hr{runTag}01bMenu.png")
|
|
pickModemOpponent(a, "A", "a")
|
|
pickModemOpponent(b, "B", "o")
|
|
# Each machine gets its own rate, chosen at the "PRESS A OR O" prompt before the link is
|
|
# ever opened, which is the order README.md tells the player to use. baudIndex comes off
|
|
# the disk as 0, so 300 needs no key - but pressing C= + 3 anyway proves the module is
|
|
# resident and programs the chip at the same point in the sequence as every other rate.
|
|
for (session, tag), rate in zip(sessions, (rateA, rateB)):
|
|
if not moduleResident(session, tag):
|
|
raise SystemExit(f"{tag}: the opponent module never loaded")
|
|
selectRate(session, tag, rate)
|
|
answerModemPrompts(a, "A", "a")
|
|
answerModemPrompts(b, "B", "o")
|
|
ok, phases = waitForPhase(sessions, 3, 180)
|
|
print(f"both in the packet phase: {ok} ({phases})", flush=True)
|
|
a.shot(f"{SHOTS}/hr{runTag}02aLinked.png")
|
|
b.shot(f"{SHOTS}/hr{runTag}02bLinked.png")
|
|
for state in [snapshot(session, tag) for session, tag in sessions]:
|
|
print(f" [{state['tag']}] control {decodeControl(state['aciaControl'])}", flush=True)
|
|
showState(state)
|
|
|
|
for index in range(cleanWindows):
|
|
runs.append((f"clean w{index + 1}",
|
|
measure(relay, sessions, seconds,
|
|
f"{rateA}/{rateB} baud, clean [{index + 1}/{cleanWindows}]",
|
|
[], runTag, f"clean{index + 1}")))
|
|
a.shot(f"{SHOTS}/hr{runTag}03aAfterClean.png")
|
|
b.shot(f"{SHOTS}/hr{runTag}03bAfterClean.png")
|
|
|
|
runs.append(("sync/recover", measure(relay, sessions, seconds,
|
|
f"{rateA}/{rateB} baud, sync tracepoints",
|
|
[f"${SYNC_ENTER:04x}", f"${SYNC_DONE:04x}"],
|
|
runTag, "sync")))
|
|
runs.append(("errors", measure(relay, sessions, min(seconds, 20.0),
|
|
f"{rateA}/{rateB} baud, error tracepoints",
|
|
["$e73f", "$e505 if x > $13"], runTag, "err")))
|
|
a.shot(f"{SHOTS}/hr{runTag}04aAfterTraced.png")
|
|
b.shot(f"{SHOTS}/hr{runTag}04bAfterTraced.png")
|
|
|
|
print("\n---- uartRxCount $E42D sampled repeatedly ----", flush=True)
|
|
rows = sampleRings(sessions, 24, 0.5, runTag)
|
|
probe = probeTxDeadlock(relay, sessions, runs[-1][1]["states"])
|
|
print(f"deadlock probe: {probe}", flush=True)
|
|
# A direction that carried nothing for a whole window is the wedge case, and it is worth more
|
|
# than the byte count: dump where that machine actually is.
|
|
# Both ends are dumped rather than the silent one, because which relay connection belongs to
|
|
# which emulator is an inference (conn0 is whichever opened its ACIA first) and the machine
|
|
# that is still talking is half the evidence.
|
|
pending = None
|
|
if any(0 in run["counts"] for _, run in runs):
|
|
for session, tag in sessions:
|
|
wedgeDump(session, tag)
|
|
pending = probePendingCount(relay, sessions)
|
|
for session, tag in sessions:
|
|
wedgeDump(session, tag, times=2)
|
|
a.shot(f"{SHOTS}/hr{runTag}05aEnd.png")
|
|
b.shot(f"{SHOTS}/hr{runTag}05bEnd.png")
|
|
|
|
print("\n================ SUMMARY ================", flush=True)
|
|
print(f"A asked for {rateA} baud, B asked for {rateB} baud", flush=True)
|
|
for name, run in runs:
|
|
summarise(name, run)
|
|
finalPhases = sorted({row["connectionPhase"] for row in rows})
|
|
print(f"\nconnectionPhase values seen across the ring sampling: {finalPhases}", flush=True)
|
|
print(f"deadlock probe: {probe}", flush=True)
|
|
print(f"uartPendingCount probe: {pending}", flush=True)
|
|
finally:
|
|
a.close()
|
|
b.close()
|
|
time.sleep(1)
|
|
relay.stop()
|
|
for i in (0, 1):
|
|
print(f"[relay] conn{i} total {len(relay.log[i])} bytes, "
|
|
f"tail {bytes(relay.log[i][-60:])!r}", flush=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|