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

219 lines
9 KiB
Python

#!/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