#!/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 # SWIFTLINK_ACIA_BASE=0xDF00 python3 testHarden.py # # 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()