187 lines
7 KiB
Python
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()
|