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

187 lines
7 KiB
Python

#!/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 <disk.d64> <shotPrefix>
#
# 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()