#!/usr/bin/env python3 # testModemSelect.py - boot the patched disk, choose COMPETE WITH MODEM OPPONENT, and record every # access the game makes to the SwiftLink ACIA registers at $DE00-$DE03. # # python3 testModemSelect.py # # A tiny TCP server stands in for the far end of the serial cable: VICE's rsdev is a TCP client, so # something has to be listening or the ACIA has nowhere to put the bytes. Everything it receives is # logged, which is the proof that bytes really left the emulated chip. # # The options menu keeps its highlighted row in $91D5 (0 = COMPETE WITH MODEM OPPONENT), so the # script can assert that the joystick input actually landed instead of guessing from a screenshot # taken during the highlight's blink-off phase. import os import socket import sys import threading import time sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from viceHarness import ViceSession, aciaArgs, pickFreePort, readByte, ACIA_BASE, SCRATCH SHOTS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shots") class SerialSink: # Accepts VICE's outgoing TCP connection and records every byte the emulated ACIA transmits. def __init__(self, reply=b""): self.port = pickFreePort() self.listener = socket.socket() self.listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.listener.bind(("127.0.0.1", self.port)) self.listener.listen(2) self.data = bytearray() self.reply = reply self.connected = False self.running = True threading.Thread(target=self.serve, daemon=True).start() def serve(self): self.listener.settimeout(1.0) while self.running: try: conn, _ = self.listener.accept() except socket.timeout: continue except OSError: return self.connected = True conn.settimeout(1.0) if self.reply: try: conn.sendall(self.reply) except OSError: pass while self.running: try: chunk = conn.recv(4096) except socket.timeout: continue except OSError: break if not chunk: break self.data += chunk conn.close() def stop(self): self.running = False try: self.listener.close() except OSError: pass def collectHits(session, seconds, maxHits=150): # Run with watchpoints armed, logging each stop and resuming from it. out = [] deadline = time.time() + seconds while time.time() < deadline and len(out) < maxHits: text = session.recv(min(3, max(0.5, deadline - time.time()))) if text.strip(): out.append(text) session.sock.sendall(b"x\n") return out def main(): disk = os.path.abspath(sys.argv[1]) prefix = sys.argv[2] os.makedirs(SHOTS, exist_ok=True) sink = SerialSink() print(f"serial sink listening on 127.0.0.1:{sink.port}", flush=True) args = aciaArgs(rsDevAddress=f"127.0.0.1:{sink.port}", baud=2400) print("acia args:", args, flush=True) session = ViceSession(disk, f"{SCRATCH}/{prefix}.vice.log", args, label=prefix) try: session.connect() session.bootPastLoader() session.findWindow() session.focus() time.sleep(8) session.shot(f"{SHOTS}/{prefix}01menu.png") session.enterMonitor() row = readByte(session, 0x91D5) print(f"menu row before input: {row}", flush=True) session.mon("m e000 e00f") session.sock.sendall(b"x\n") session.recv(3) # move the highlight up onto COMPETE WITH MODEM OPPONENT and prove it moved for _ in range(3): session.enterMonitor() row = readByte(session, 0x91D5) session.sock.sendall(b"x\n") session.recv(3) if row == 0: break session.hold("KP_8", 250) time.sleep(0.8) print(f"menu row after input: {row}", flush=True) session.shot(f"{SHOTS}/{prefix}02highlight.png") # Arm the ACIA watchpoints before anything can touch the cartridge. Nothing in the game # reads or writes $DE00-$DE03 until the link is opened, so any stop from here on is the # driver talking to the 6551 and there is no risk of losing the first access to a race. session.enterMonitor() session.mon(f"watch store ${ACIA_BASE:04x} ${ACIA_BASE + 3:04x}") session.mon(f"watch load ${ACIA_BASE:04x} ${ACIA_BASE + 3:04x}") session.sock.sendall(b"x\n") session.recv(2) # Fire. The game reloads the $E000 module from track 18 s7 + track 34 (the SwiftLink # build), cold-initialises it, and then asks two questions in a row: # startGameFromSetup ($0B17) "GET OPPONENT ON PHONE AND / PRESS A OR O AND SET MODEM." # -> A = answer mode, O = originate mode # openCommLink ($1B69) "PRESS SPACE, WAIT, HANGUP PHONE." # -> space, and only then does $E003 X=0 reach the module # So nothing touches the 6551 until both have been answered. How long the disk load takes # varies, so offer both keys in a loop until the ACIA is finally touched. session.hold("KP_0", 300) hits = [] for attempt in range(24): session.hold("a", 250) session.hold("space", 250) hits += collectHits(session, 4) if hits: print(f"first ACIA access after {attempt + 1} attempt(s) at the modem prompts", flush=True) break hits += collectHits(session, 20) blob = "".join(hits) open(f"{SCRATCH}/{prefix}.hits.txt", "w").write(blob) print(f"=== {len(hits)} watchpoint stops; first 8000 chars ===", flush=True) print(blob[:8000], flush=True) session.enterMonitor() session.mon("del") session.sock.sendall(b"x\n") session.recv(3) time.sleep(6) session.shot(f"{SHOTS}/{prefix}03afterOpen.png") session.enterMonitor() session.mon("m e000 e00f") session.mon("m e540 e56f") session.mon("m e030 e05f") session.mon("m e5b8 e5bf") session.mon(f"m {ACIA_BASE:04x} {ACIA_BASE + 3:04x}") session.mon("d fffa fffb") session.mon("m e6fd e70f") session.sock.sendall(b"x\n") session.recv(3) time.sleep(15) session.shot(f"{SHOTS}/{prefix}05later.png") finally: session.close() time.sleep(1) sink.stop() print(f"serial sink connected={sink.connected} bytes={len(sink.data)}", flush=True) print("serial bytes:", sink.data[:400].hex(" "), flush=True) print("as text:", repr(bytes(sink.data[:400])), flush=True) if __name__ == "__main__": main()