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