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

393 lines
19 KiB
Python

#!/usr/bin/env python3
# testHarden.py - verify the two hardenings the driver has just been given, on either strap:
#
# 1. the page latch (aciaPageLatch $E5BF). aciaDetect probes $DE00 once, on a cold start, and
# never again: every later call - every link open and every baud hot key - sees a non-zero latch
# and returns in five bytes. So on a $DF00 machine nothing is ever written into $DE00-$DE03
# after the single cold-start probe, and on either strap the only thing a rate change writes to
# the control register is the rate itself, never a probe pattern.
# 2. the 38400 retirement. baudEntryTable's last entry keeps its control byte $1F but its hot-key
# code is now $00, which the search at $E344 can never match because it only runs on codes with
# bit 7 set. C= + 8 must therefore change nothing and be swallowed like any other unrecognised
# Commodore-key combination.
#
# python3 testHarden.py <disk.d64> <tag>
# SWIFTLINK_ACIA_BASE=0xDF00 python3 testHarden.py <disk.d64> <tag>
#
# Two machines are booted and cross connected through the socket null modem, so every measurement
# below is taken on a link that has reached connection phase 3 - the game's own packet protocol -
# rather than on a machine talking to itself. Both machines run at true C64 speed; only the loader
# is warped.
#
# Everything is measured with VICE *tracepoints*, not watchpoints. A watchpoint stops the emulator
# at every hit, which is fine for a cold-start log (strapReport.md counted 800 of them that way) but
# useless while keys are being pressed at true speed. A tracepoint prints the accessing instruction
# and its A register and lets the machine run on, so an ordered access log costs nothing and the hot
# keys land normally.
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
from testRealtime import (command, decodeControl, flush, hotKey, pauseAll, readRange, resumeAll,
showState, snapshot, waitForPhase, BAUD_HOTKEY, BAUD_INDEX,
CONTROL_RATES)
from testHighRates import MODULE_JUMP_TABLE
SHOTS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shots")
LOGS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "testLogs")
OTHER_BASE = 0xDF00 if ACIA_BASE == 0xDE00 else 0xDE00
PROBE_PATTERNS = (0x1E, 0x15) # aciaProbe's two control-register patterns
# The six one-instruction ACIA accessors; the operand high byte is the driver's whole record of where
# the hardware is, and aciaPageLatch $E5BF is the readable seventh copy of it.
ACCESSORS = [(0xE554, "aciaCmdWrite sta $xx02 command"),
(0xE558, "aciaStatusRead lda $xx01 status"),
(0xE55F, "aciaDataRead lda $xx00 receive"),
(0xE563, "aciaDataWrite sta $xx00 transmit"),
(0xE567, "aciaCtrlWrite sta $xx03 control"),
(0xE56B, "aciaCtrlRead lda $xx03 control read-back / the probe")]
TRACE_HEAD = re.compile(r"#(\d+)\s+\(Trace\s+(\w+)\s+([0-9a-f]{4})\)")
DISASM = re.compile(r"\.C:([0-9a-f]{4})\s+((?:[0-9A-F]{2} )+)\s*(\S+)\s+(\S*)\s*-\s*A:([0-9A-Fa-f]{2})")
# The order the hot keys are pressed in. 38400 is in the middle rather than at the end so that the
# rates either side of it prove the keyboard was still working when it was pressed.
RATE_SEQUENCE = [1200, 2400, 4800, 38400, 9600, 19200, 300]
def armTraces(sessions, ranges):
# ranges: list of (first, last). Both directions on each, deleted first so a phase's log
# contains only what that phase armed.
pauseAll(sessions)
for session, tag in sessions:
command(session, "del")
for first, last in ranges:
command(session, f"trace store ${first:04x} ${last:04x}")
command(session, f"trace load ${first:04x} ${last:04x}")
resumeAll(sessions)
def collect(sessions, seconds, sink):
# Read whatever the tracepoints printed, without stopping anything. sink is a dict of tag ->
# list of strings.
deadline = time.time() + seconds
while time.time() < deadline:
for session, tag in sessions:
sink[tag].append(flush(session, 0.25, 0.4))
def describe(blob):
# Ordered "who touched what, with what" out of the tracepoint stream.
events = []
lines = blob.splitlines()
for index, line in enumerate(lines):
head = TRACE_HEAD.search(line)
if not head:
continue
for follow in lines[index:index + 3]:
hit = DISASM.search(follow)
if hit:
events.append({"kind": head.group(2), "addr": int(head.group(3), 16),
"pc": int(hit.group(1), 16), "op": hit.group(3),
"operand": hit.group(4), "a": int(hit.group(5), 16)})
break
return events
def pageOf(addr):
return addr & 0xFF00
def readAccessors(session, tag):
pauseAll([(session, tag)])
values = {}
for addr, name in ACCESSORS:
values[addr] = (name, readRange(session, addr + 2, 1)[0])
latch = readRange(session, 0xE5BF, 1)[0]
resumeAll([(session, tag)])
return values, latch
def showAccessors(values, latch, tag, note):
print(f"--- [{tag}] the six ACIA accessors ({note}) ---", flush=True)
pages = set()
for addr, name in ACCESSORS:
_, value = values[addr]
pages.add(value)
print(f" ${addr:04X} {name:42s} operand high byte at ${addr + 2:04X} = "
f"${'??' if value is None else '%02X' % value}", flush=True)
print(f" aciaPageLatch $E5BF = ${'??' if latch is None else '%02X' % latch} "
f"all six agree: {len(pages) == 1} they point at page "
f"${(pages.pop() if len(pages) == 1 else 0):02X}00", flush=True)
return latch
def summariseEvents(events, label):
counts = {}
for event in events:
key = (event["addr"], event["kind"])
counts[key] = counts.get(key, 0) + 1
byPage = {}
for (addr, kind), n in counts.items():
byPage[pageOf(addr)] = byPage.get(pageOf(addr), 0) + n
print(f"=== {label}: {len(events)} accesses ===", flush=True)
for (addr, kind) in sorted(counts):
print(f" ${addr:04X} {kind:5s} x {counts[(addr, kind)]}", flush=True)
for page in sorted(byPage):
print(f" page ${page:04X}: {byPage[page]} accesses", flush=True)
return counts, byPage
def moduleResident(session, tag, tries=60):
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
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 openLink(sessions, keys, sink, tries=24):
# Answer "PRESS A OR O" and then "PRESS SPACE", with the cold-detect traces already armed and
# nothing sent to either monitor, so the log of the probe itself is clean.
for attempt in range(tries):
for (session, tag), key in zip(sessions, keys):
session.focus()
session.hold(key, 250)
session.hold("space", 250)
collect(sessions, 3, sink)
if all(describe("".join(sink[tag])) for _, tag in sessions):
print(f"both machines touched an ACIA register after {attempt + 1} attempt(s)",
flush=True)
collect(sessions, 4, sink)
return True
return False
def pressRate(sessions, rate, sink, tries=4):
# Press one speed hot key on both machines and report what each driver did with it. 38400 is in
# the table by index but has no key code any more, so C= + 8 is expected to do nothing at all -
# which is why "landed" is reported rather than asserted.
key = BAUD_HOTKEY[rate]
want = [code for code, value in CONTROL_RATES.items() if value == rate][0]
before = [snapshot(session, tag) for session, tag in sessions]
for state in before:
showState(state)
for attempt in range(tries):
for session, tag in sessions:
hotKey(session, key)
collect(sessions, 2, sink)
after = [snapshot(session, tag) for session, tag in sessions]
landed = all(state["aciaControl"] == want and state["baudIndex"] == BAUD_INDEX[rate]
for state in after)
if landed or rate == 38400:
break
print(f" C= + {key} did not land on both machines - retrying", flush=True)
for state in after:
showState(state)
print(f" [{state['tag']}] control {decodeControl(state['aciaControl'])}", flush=True)
return {"rate": rate, "key": key, "wantControl": want, "before": before, "after": after,
"landed": landed}
def swallowProof(session, tag):
# $E38A is the 'lda #$FF' that swallows an unrecognised Commodore-key combination, and it is the
# only way out of the hot-key layer for C= + 8 now that the baud walk cannot match it. Arm the
# breakpoint before the key goes down: entering the monitor clears VICE's emulated key matrix.
pauseAll([(session, tag)])
command(session, "del")
command(session, "break e38a")
resumeAll([(session, tag)])
session.focus()
subprocess.run(["xdotool", "keydown", "Tab"], env=session.env, check=False)
time.sleep(0.2)
subprocess.run(["xdotool", "keydown", "8"], env=session.env, check=False)
hit = flush(session, 0.6, 12)
print(f"[{tag}] break at $E38A while C= + 8 is held:\n{hit[-600:]}", flush=True)
code = readRange(session, 0xE046, 1)[0]
baud = readRange(session, 0xE055, 2)
control = readRange(session, ACIA_BASE + 3, 1)[0]
print(f"[{tag}] at the swallow: lastHotkeyCode $E046 = "
f"{'??' if code is None else '$%02X' % code}, baudIndex $E055 = {baud[0]}, "
f"bitPeriodLo $E056 = {'??' if baud[1] is None else '$%02X' % baud[1]}, "
f"${ACIA_BASE + 3:04X} = {'??' if control is None else '$%02X' % control}", flush=True)
command(session, "del")
resumeAll([(session, tag)])
subprocess.run(["xdotool", "keyup", "8"], env=session.env, check=False)
subprocess.run(["xdotool", "keyup", "Tab"], env=session.env, check=False)
time.sleep(0.4)
return {"hit": hit, "reachedSwallow": "e38a" in hit.lower(), "lastHotkeyCode": code,
"baudIndex": baud[0], "bitPeriodLo": baud[1], "control": control}
def main():
disk = os.path.abspath(sys.argv[1])
tag = sys.argv[2] if len(sys.argv) > 2 else "harden"
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)
print(f"acia base for this run: ${ACIA_BASE:04X}; the other page is ${OTHER_BASE:04X}",
flush=True)
args = aciaArgs(rsDevAddress=f"127.0.0.1:{relay.port}", baud=2400)
a = ViceSession(disk, f"{SCRATCH}/hd.{tag}.a.vice.log", args, label="A", warp=False)
b = ViceSession(disk, f"{SCRATCH}/hd.{tag}.b.vice.log", args, label="B", warp=False)
sessions = [(a, "A"), (b, "B")]
sink = {"A": [], "B": []}
results = {}
try:
a.connect()
b.connect()
for session, name in sessions:
command(session, "warp on")
command(session, "sidefx off")
a.bootPastLoader(waitSecs=900)
b.bootPastLoader(waitSecs=900)
pauseAll(sessions)
for session, name in sessions:
command(session, "warp off")
print(f"[{name}] {command(session, 'warp').strip()}", flush=True)
resumeAll(sessions)
a.findWindow()
b.findWindow()
a.focus()
b.focus()
time.sleep(25)
a.shot(f"{SHOTS}/hd{tag}01aMenu.png")
b.shot(f"{SHOTS}/hd{tag}01bMenu.png")
pickModemOpponent(a, "A", "a")
pickModemOpponent(b, "B", "o")
for session, name in sessions:
if not moduleResident(session, name):
raise SystemExit(f"{name}: the opponent module never loaded")
# ---- phase 1: the cold detect, with both pages watched -------------------------------
armTraces(sessions, [(0xDE00, 0xDE03), (0xDF00, 0xDF03)])
ok = openLink(sessions, ["a", "o"], sink)
print(f"link opened on both machines: {ok}", flush=True)
coldEvents = {name: describe("".join(sink[name])) for _, name in sessions}
results["cold"] = {}
for _, name in sessions:
print(f"=== [{name}] the first 12 cartridge-page accesses, in order ===", flush=True)
for event in coldEvents[name][:12]:
print(f" {event['kind']:5s} ${event['addr']:04X} {event['op']} "
f"{event['operand']} at ${event['pc']:04X} A=${event['a']:02X}", flush=True)
counts, byPage = summariseEvents(coldEvents[name], f"[{name}] cold detect + link open")
results["cold"][name] = {"events": len(coldEvents[name]), "byPage": byPage,
"counts": {f"{k[0]:04X}:{k[1]}": v for k, v in counts.items()}}
open(f"{LOGS}/harden.{tag}.{name}.cold.txt", "w").write("".join(sink[name]))
okPhase, phases = waitForPhase(sessions, 3, 240)
print(f"both machines in the packet phase: {okPhase} ({phases})", flush=True)
results["reachedPhase3"] = okPhase
a.shot(f"{SHOTS}/hd{tag}02aLinked.png")
b.shot(f"{SHOTS}/hd{tag}02bLinked.png")
for session, name in sessions:
values, latch = readAccessors(session, name)
showAccessors(values, latch, name, "after the cold detect")
results.setdefault("accessors", {})[name] = {
"operands": {f"{addr:04X}": values[addr][1] for addr, _ in ACCESSORS},
"latch": latch}
# ---- phase 2: the hot keys, with the other page and the control register watched ------
sink = {"A": [], "B": []}
armTraces(sessions, [(OTHER_BASE, OTHER_BASE + 3), (ACIA_BASE + 3, ACIA_BASE + 3)])
results["rates"] = []
for rate in RATE_SEQUENCE:
print(f"\n---- C= + {BAUD_HOTKEY[rate]} ({rate} baud) on a live link ----", flush=True)
results["rates"].append(pressRate(sessions, rate, sink))
collect(sessions, 3, sink)
hotEvents = {name: describe("".join(sink[name])) for _, name in sessions}
results["hotkeyPhase"] = {}
for _, name in sessions:
print(f"=== [{name}] every access to ${OTHER_BASE:04X}-${OTHER_BASE + 3:04X} and to the "
f"control register ${ACIA_BASE + 3:04X} during the whole hot-key sweep ===",
flush=True)
for event in hotEvents[name]:
print(f" {event['kind']:5s} ${event['addr']:04X} {event['op']} "
f"{event['operand']} at ${event['pc']:04X} A=${event['a']:02X}", flush=True)
counts, byPage = summariseEvents(hotEvents[name], f"[{name}] hot-key sweep")
writes = [e for e in hotEvents[name]
if e["kind"] == "store" and e["addr"] == ACIA_BASE + 3]
# $1E and $15 are both probe patterns AND real rate codes (19200 and 300), so a single
# value proves nothing. What a re-probe would look like is the pair $1E, $15 written
# back to back in front of the rate the player asked for, i.e. three writes per hot key
# instead of one. So the honest measurement is the ordered list of everything written
# to the control register during the sweep, against the rates that were asked for.
written = " ".join(f"${e['a']:02X}" for e in writes)
pairs = sum(1 for i in range(len(writes) - 1)
if writes[i]["a"] == PROBE_PATTERNS[0]
and writes[i + 1]["a"] == PROBE_PATTERNS[1])
print(f" control-register writes, in order: {written}", flush=True)
print(f" back-to-back $1E,$15 pairs (the probe's signature): {pairs}", flush=True)
results["hotkeyPhase"][name] = {
"otherPageAccesses": byPage.get(OTHER_BASE, 0),
"controlWrites": [e["a"] for e in writes],
"probePairs": pairs,
"events": len(hotEvents[name])}
open(f"{LOGS}/harden.{tag}.{name}.hotkeys.txt", "w").write("".join(sink[name]))
# ---- phase 3: a census of every register access, both pages, 20 s --------------------
sink = {"A": [], "B": []}
armTraces(sessions, [(0xDE00, 0xDE03), (0xDF00, 0xDF03)])
collect(sessions, 20, sink)
results["census"] = {}
for _, name in sessions:
events = describe("".join(sink[name]))
counts, byPage = summariseEvents(events, f"[{name}] 20 s census on a live link")
results["census"][name] = {"byPage": byPage, "total": len(events)}
open(f"{LOGS}/harden.{tag}.{name}.census.txt", "w").write("".join(sink[name]))
pauseAll(sessions)
for session, name in sessions:
command(session, "del")
resumeAll(sessions)
# ---- phase 4: C= + 8 really does reach the swallow ------------------------------------
a.shot(f"{SHOTS}/hd{tag}03aBeforeC8.png")
results["swallow"] = swallowProof(a, "A")
time.sleep(3)
a.shot(f"{SHOTS}/hd{tag}04aAfterC8.png")
b.shot(f"{SHOTS}/hd{tag}04bAfterC8.png")
for session, name in sessions:
values, latch = readAccessors(session, name)
showAccessors(values, latch, name, "at the end of the run")
print("\n================ SUMMARY ================", flush=True)
print(f"strap: ${ACIA_BASE:04X} other page: ${OTHER_BASE:04X}", flush=True)
print(f"both reached phase 3: {results.get('reachedPhase3')}", flush=True)
for _, name in sessions:
print(f"[{name}] cold: {results['cold'][name]['byPage']}", flush=True)
print(f"[{name}] latch/accessors: {results['accessors'][name]}", flush=True)
print(f"[{name}] hot-key sweep: {results['hotkeyPhase'][name]}", flush=True)
print(f"[{name}] census: {results['census'][name]}", flush=True)
for entry in results["rates"]:
print(f" C= + {entry['key']} -> {entry['rate']} baud: landed={entry['landed']} "
f"control={[('%02X' % s['aciaControl']) if s['aciaControl'] is not None else '??' for s in entry['after']]} "
f"baudIndex={[s['baudIndex'] for s in entry['after']]}", flush=True)
print(f"C= + 8 swallow: {results['swallow']['reachedSwallow']}, "
f"lastHotkeyCode={results['swallow']['lastHotkeyCode']}", 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()