#!/usr/bin/env python3 # viceHarness.py - shared plumbing for driving x64sc with a SwiftLink cartridge on a private Xvfb # display. Nothing here is Modem Wars specific except the "break at $0800 while warping through the # loader" trick, which every earlier session in this project has used. # # The machine is shared, so every resource this module grabs is picked at run time: a random free X # display, a kernel assigned TCP port for the remote monitor, another for the serial line, and a # private VICE config file so the user's ~/.config/vice/vicerc is neither read nor written. import os import random import re import socket import subprocess import sys import time SCRATCH = os.environ.get("SWIFTLINK_SCRATCH", "/tmp/claude-1000/-home-scott-claude-modemwars/" "d69befd0-05e0-41b3-8bba-17d73f8cf6d9/scratchpad") # Which page the SwiftLink is strapped to for this run. A real cartridge has a jumper for $DE00 or # $DF00 and the driver probes for it, so every test in this directory has to be runnable against # either. $DE00 stays the default, so nothing that does not set the variable changes behaviour. # # SWIFTLINK_ACIA_BASE=0xDF00 python3 testBoot.py ... ACIA_BASE = int(os.environ.get("SWIFTLINK_ACIA_BASE", "0xDE00"), 16) def pickFreePort(): s = socket.socket() s.bind(("127.0.0.1", 0)) port = s.getsockname()[1] s.close() return port def pickFreeDisplay(): # Other people are on this box; do not assume a range is ours. for _ in range(200): n = random.randint(200, 900) if not os.path.exists(f"/tmp/.X11-unix/X{n}"): return n raise SystemExit("no free X display") class ViceSession: def __init__(self, disk, logPath, aciaArgs, extraArgs=(), label="vice", warp=True): # warp=True races through the loader and the game; warp=False still warps the autostart # phase but then runs at true C64 speed, which is what a real-time measurement needs. self.warp = warp self.label = label self.display = f":{pickFreeDisplay()}" self.port = pickFreePort() self.logPath = logPath self.configPath = os.path.join(SCRATCH, f"vicerc.{label}.{os.getpid()}") self.env = dict(os.environ, DISPLAY=self.display) self.sock = None self.winId = None self.xvfb = subprocess.Popen(["Xvfb", self.display, "-screen", "0", "800x600x24", "-nolisten", "tcp"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) time.sleep(2) cmd = ["x64sc", "-config", self.configPath, "+saveres", "-remotemonitor", "-remotemonitoraddress", f"127.0.0.1:{self.port}", "-drive8truedrive", "-drive8type", "1541", "-sounddev", "dummy", "+sound", ("-warp" if self.warp else "+warp"), "-jamaction", "2", "-autostart-warp", "-model", "ntsc", "-joydev2", "1", "-chdir", SCRATCH] + list(aciaArgs) + list(extraArgs) + \ ["-autostart", disk] self.cmd = cmd self.vice = subprocess.Popen(cmd, env=self.env, stdout=open(logPath, "w"), stderr=subprocess.STDOUT) def connect(self, timeout=60): deadline = time.time() + timeout while time.time() < deadline: try: self.sock = socket.create_connection(("127.0.0.1", self.port), timeout=1) break except OSError: time.sleep(0.3) if self.sock is None: raise SystemExit(f"{self.label}: monitor did not open") self.recv(10) return self def recv(self, timeout): self.sock.settimeout(0.5) buf = b"" deadline = time.time() + timeout while time.time() < deadline: try: chunk = self.sock.recv(65536) if not chunk: break buf += chunk tail = buf[-40:] if b") " in tail and (b"(C:$" in tail or b"(8:$" in tail): break except socket.timeout: pass return buf.decode("latin-1") def mon(self, cmd, timeout=15): self.sock.sendall((cmd + "\n").encode()) out = self.recv(timeout) print(f"[{self.label}] >>> {cmd}\n{out}", flush=True) return out def enterMonitor(self, timeout=15): # Any line of input while the emulator is running drops back into the monitor. self.sock.sendall(b"\n") return self.recv(timeout) def bootPastLoader(self, waitSecs=400): # The loader runs for a couple of minutes of warp and ends with JMP $0800, so a breakpoint # there is the reliable "the game is up" signal. When two emulators are started at once the # second one can be past $0800 before its breakpoint is set, and then the break would never # fire; the monitor's own prompt gives away where the CPU is, so check that first. out = self.mon("break 0800") match = re.search(r"\(C:\$([0-9a-f]{4})\)", out) pc = int(match.group(1), 16) if match else 0 if 0x0800 <= pc < 0xC000: print(f"[{self.label}] already past the loader (PC ${pc:04X})", flush=True) else: self.sock.sendall(b"x\n") self.recv(5) print(f"[{self.label}] loading under warp ...", flush=True) out = self.recv(waitSecs) print(f"[{self.label}] {out[-200:]}", flush=True) self.mon("del") self.setWarp(0) self.sock.sendall(b"x\n") self.recv(3) return out def setWarp(self, value): # The monitor's resource syntax has changed between VICE releases; try the spellings until # one is not rejected, so the game runs at real speed once the loader is done. for name in ("WarpMode", "Warp", "WarpModeEnabled"): out = self.mon(f'resourceset "{name}" "{value}"') if "ERROR" not in out and "Unknown resource" not in out: return name return None def findWindow(self, timeout=20): deadline = time.time() + timeout while time.time() < deadline: out = subprocess.run(["xdotool", "search", "--name", "VICE"], env=self.env, capture_output=True, text=True).stdout.split() if out: self.winId = out[-1] return self.winId time.sleep(0.5) raise SystemExit(f"{self.label}: no VICE window on {self.display}") def focus(self): # There is no window manager on the private display, so windowactivate cannot work; plain # XSetInputFocus does, and the keys then have to go through XTEST (xdotool without --window). # Synthetic XSendEvent keys - what "xdotool key --window" sends - are ignored by VICE's GTK # front end, which is why earlier attempts at driving the menu did nothing. subprocess.run(["xdotool", "windowfocus", "--sync", self.winId], env=self.env, check=False) time.sleep(0.5) def key(self, keysym, times=1, gap=0.25): for _ in range(times): subprocess.run(["xdotool", "key", "--clearmodifiers", keysym], env=self.env, check=False) time.sleep(gap) def hold(self, keysym, ms=300): subprocess.run(["xdotool", "keydown", keysym], env=self.env, check=False) time.sleep(ms / 1000.0) subprocess.run(["xdotool", "keyup", keysym], env=self.env, check=False) time.sleep(0.3) def shot(self, path): self.enterMonitor() self.mon(f'screenshot "{path}" 2') self.sock.sendall(b"x\n") self.recv(5) print(f"[{self.label}] shot {path}", flush=True) def close(self): try: if self.sock: self.sock.sendall(b"quit\n") time.sleep(1) except OSError: pass for proc in (self.vice, self.xvfb): try: proc.kill() proc.wait(timeout=10) except Exception: pass try: os.remove(self.configPath) except OSError: pass def readByte(session, addr, tries=4): # One byte out of the monitor's "m" output. The remote monitor interleaves the reply to one # command with the prompt of the next, so the answer can arrive split across two reads; match on # the address label rather than on line position, and ask again when it does not turn up. for _ in range(tries): out = session.mon(f"m {addr:04x} {addr:04x}") m = re.search(r"C:%04x\s+([0-9a-f]{2})" % addr, out) if m: return int(m.group(1), 16) return None def aciaArgs(base=None, rsDevAddress=None, baud=2400, mode=1, irq=1, dev=0): base = base if base is not None else f"0x{ACIA_BASE:04X}" args = ["-acia1", "-acia1mode", str(mode), "-acia1base", base, "-acia1irq", str(irq), "-myaciadev", str(dev)] if rsDevAddress is not None: args += [f"-rsdev{dev+1}", rsDevAddress, f"-rsdev{dev+1}baud", str(baud)] return args