#!/usr/bin/env python
"""btconsole.py -- minimal Tesla/WinTesla CONSOLE emulator (the operator station).

The pod-side engine (MUNGA_L4/L4NET.CPP) boots `-net <port>` into ConsoleOnly state:
it listens on TCP <port> and waits for a console to connect and stream the mission
egg.  The real console software is absent from every archive, so this tool speaks
the console's wire protocol:

  packet := NetworkPacketHeader + NetworkManager::ReceiveEggFileMessage
    off  0  int32  clientID      = 0    (NetworkClient::NetworkManagerClientID)
    off  4  int32  gameID        = 0    (NETWORK.cpp:121 -- always 0)
    off  8  int32  fromHost      = 1    (FirstLegalHostID == the console)
    off 12  int32  timeStamp     = 0    (Time; unused by the egg handler)
    off 16  uint32 messageLength = 1024 (sizeof(ReceiveEggFileMessage))
    off 20  int32  messageID     = 3    (NetworkManager::ReceiveEggFileMessageID)
    off 24  uint32 messageFlags  = 1    (Receiver::Message::ReliableFlag)
    off 28  int32  sequenceNumber      (0..n-1; -1 is the solo-local path only)
    off 32  int32  notationFileLength  (total egg bytes)
    off 36  int32  thisMessageLength   (bytes used in this chunk, <= 1000)
    off 40  char   notationData[1000]
    total 1040 bytes per chunk
  (constants verified from the port build via `btl4.exe` BT_NET_PROBE=1)

The pod reassembles chunks sequentially (L4NET.CPP:773-816: seq 0 allocates,
append until notationFileLength reached), sets NormalState, CreateMission ->
StartConnecting (the egg's [pilots] list forms the pod mesh), and sends an
AcknowledgeEggFile back on this socket.  The console must STAY CONNECTED (a
disconnect trips the pod's console-loss path, which also closes the game
listener -- an engine bug).

Usage:
  python btconsole.py <egg-file> <host:port> [<host:port> ...]
Example (two instances on one box, -net 1501 / -net 1601):
  python btconsole.py MP.EGG 127.0.0.1:1501 127.0.0.1:1601
"""
import socket
import struct
import sys
import threading
import time

CHUNK = 1000
PKT_FMT_HDR = "<iiii"          # clientID, gameID, fromHost, timeStamp
PKT_FMT_MSG = "<IiIiii"        # messageLength, messageID, messageFlags, seq, fileLen, thisLen
MESSAGE_LENGTH = 1024          # sizeof(ReceiveEggFileMessage)
EGG_MESSAGE_ID = 3             # NetworkManager::ReceiveEggFileMessageID
RELIABLE_FLAG = 1
CONSOLE_HOST_ID = 1            # FirstLegalHostID

# The LAUNCH: pods load to WaitingForLaunch and sit until the console sends
# Application::RunMissionMessage (the operator's launch button).  The handler
# ladder needs it twice: WaitingForLaunch->LaunchingMission (dispatches the
# player MissionStarting/translocation), then LaunchingMission->RunningMission.
# Sending it early (state==CreatingMission) hits the handler's Fail() -- hence
# the settle delay.  Constants from BT_NET_PROBE=1.
APPLICATION_CLIENT_ID = 4      # NetworkClient::ApplicationClientID
RUN_MISSION_MESSAGE_ID = 5     # Application::RunMissionMessageID
LAUNCH_SETTLE_SECONDS = 20.0   # egg -> first launch (pods must reach WaitingForLaunch)
LAUNCH_STEP_SECONDS = 4.0      # first launch -> second (Launching -> Running)


def run_mission_packet():
    pkt = struct.pack(PKT_FMT_HDR, APPLICATION_CLIENT_ID, 0, CONSOLE_HOST_ID, 0)
    pkt += struct.pack("<IiI", 12, RUN_MISSION_MESSAGE_ID, RELIABLE_FLAG)
    assert len(pkt) == 16 + 12
    return pkt


def egg_wire_bytes(egg_text_bytes):
    """Convert raw egg TEXT to the wire form NotationFile::ReadText expects:
    NUL-separated lines (NOTATION.cpp:1043 walks `strchr(buffer,'\\0')+1` per
    line).  The real 1996 console pre-processed the egg the same way; sending
    the raw file text makes the whole egg parse as one garbage line ("ERROR:
    no map in egg!")."""
    lines = egg_text_bytes.replace(b"\r\n", b"\n").split(b"\n")
    return b"".join(line + b"\0" for line in lines)


def egg_packets(egg_bytes):
    total = len(egg_bytes)
    seq = 0
    off = 0
    while off < total:
        chunk = egg_bytes[off:off + CHUNK]
        pkt = struct.pack(PKT_FMT_HDR, 0, 0, CONSOLE_HOST_ID, 0)
        pkt += struct.pack(PKT_FMT_MSG, MESSAGE_LENGTH, EGG_MESSAGE_ID, RELIABLE_FLAG,
                           seq, total, len(chunk))
        pkt += chunk.ljust(CHUNK, b"\0")
        assert len(pkt) == 16 + MESSAGE_LENGTH
        yield pkt
        off += len(chunk)
        seq += 1


def serve_pod(hostport, egg_bytes):
    host, port = hostport.rsplit(":", 1)
    port = int(port)
    name = f"{host}:{port}"
    # The pod may not be listening yet -- retry like the pods do to each other.
    while True:
        try:
            s = socket.create_connection((host, port), timeout=5)
            break
        except OSError as e:
            print(f"[{name}] waiting for pod ({e})")
            time.sleep(1)
    print(f"[{name}] connected; streaming egg ({len(egg_bytes)} bytes)")
    n = 0
    for pkt in egg_packets(egg_bytes):
        s.sendall(pkt)
        n += 1
    print(f"[{name}] egg sent in {n} chunk(s); launch in {LAUNCH_SETTLE_SECONDS}s")
    s.settimeout(2.0)
    launch_at = time.time() + LAUNCH_SETTLE_SECONDS
    launches_sent = 0
    while True:
        if launches_sent < 2 and time.time() >= launch_at:
            s.sendall(run_mission_packet())
            launches_sent += 1
            launch_at = time.time() + LAUNCH_STEP_SECONDS
            print(f"[{name}] RunMission #{launches_sent} sent")
        try:
            data = s.recv(4096)
            if not data:
                print(f"[{name}] pod closed the console socket")
                return
            # Expect the AcknowledgeEggFile (and possibly other console traffic).
            if len(data) >= 24:
                (clid, gid, fh, ts) = struct.unpack_from("<iiii", data, 0)
                (mlen, mid) = struct.unpack_from("<Ii", data, 16)
                print(f"[{name}] pod->console packet: clientID={clid} fromHost={fh} "
                      f"msgID={mid} len={mlen} ({len(data)} bytes)")
            else:
                print(f"[{name}] pod->console {len(data)} bytes")
        except socket.timeout:
            continue


def main():
    if len(sys.argv) < 3:
        print(__doc__)
        return 2
    egg_bytes = egg_wire_bytes(open(sys.argv[1], "rb").read())
    pods = sys.argv[2:]
    threads = [threading.Thread(target=serve_pod, args=(p, egg_bytes), daemon=True)
               for p in pods]
    for t in threads:
        t.start()
    try:
        while any(t.is_alive() for t in threads):
            time.sleep(0.5)
    except KeyboardInterrupt:
        print("console shutting down")
    return 0


if __name__ == "__main__":
    sys.exit(main())
