From bda2f7b8b3cb21238e2044e40500c3290478c98c Mon Sep 17 00:00:00 2001 From: Cyd Date: Sun, 5 Jul 2026 17:14:48 -0500 Subject: [PATCH] Networking WORKS: real console <-> real pod over the emulated wire The emulated SheepShaver console and DOSBox pod now exchange the live console protocol on TCP 1501. Required chain: rebuild the DOSBox pcap backend + launch with C:\Windows\System32\Npcap on PATH (npcap DLL location); a two-TAP Windows bridge (single shared TAP fails); TAP2 MediaStatus=Always-Connected; and binding the pod's pcap to the BRIDGE MINIPORT (not a member TAP -- member-injected frames aren't re-forwarded), via realnic=DB5521D (letter-leading GUID fragment; leading-digit is parsed as an interface index). Captured + decoded the real protocol values (StateResponse clientID=5/flags=1/host=0/state=1/app=1=BattleTech), which corrects the earlier synthetic guesses. Full writeup in NET-NOTES.md. Co-Authored-By: Claude Fable 5 --- emulator/NET-NOTES.md | 48 +++++++++++ emulator/net-tools/pod_responder.py | 129 ++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 emulator/net-tools/pod_responder.py diff --git a/emulator/NET-NOTES.md b/emulator/NET-NOTES.md index 5266843..e18f8f5 100644 --- a/emulator/NET-NOTES.md +++ b/emulator/NET-NOTES.md @@ -292,6 +292,54 @@ same TAP/bridge (200.0.0.113 already via WATTCP). Then the console's outbound TCP to 200.0.0.113:1501 reaches the pod and we capture the egg exchange (resolves the framing + endianness caveats above). +## MILESTONE: real console <-> real pod talking over the wire (2026-07-05) + +The emulated SheepShaver console and the emulated DOSBox pod now exchange +the live console protocol on TCP 1501. Hard-won setup (all required): + +1. **DOSBox-X pcap backend had to be rebuilt + npcap DLL path.** config.h + had `C_PCAP 1` but the stale `ethernet.o` predated it; force-recompile + (`rm src/misc/ethernet.o ethernet_pcap.o; make`). Runtime: npcap installs + its DLLs in `C:\Windows\System32\Npcap\` (npcap-only mode), NOT System32, + so DOSBox couldn't load wpcap.dll -> **launch dosbox-x.exe with + `C:\Windows\System32\Npcap` prepended to PATH.** +2. **Two-TAP Windows bridge** (single shared TAP fails: SheepShaver holds the + user-mode handle, DOSBox rides NDIS, frames don't cross). Console + SheepShaver -> TAP1 (`ether tap` + etherguid); pod -> TAP2; both bridged. +3. **TAP2 media status = Always-Connected** (registry MediaStatus=1 on the + TAP2 class key, needs elevation; the GUI toggle didn't persist). Without + it TAP2 reports "unplugged" and the bridge won't forward to it. +4. **Pod pcap binds to the BRIDGE MINIPORT, not a member TAP.** Injecting on + a bridge *member* (TAP2) isn't re-forwarded by the bridge (the pod TX'd + but the console never saw it). Bind DOSBox `realnic` to the bridge + miniport = "Microsoft Network Adapter Multiplexor Driver". GOTCHA: DOSBox + `realnic` matches a substring of the pcap device NAME (`\Device\NPF_{GUID}`) + and parses a *leading-digit* value as an interface index -- the bridge + GUID `5DB5521D...` starts with 5 -> picked iface #5 (Bluetooth!). Use a + letter-leading fragment: `realnic=DB5521D`. +5. Diagnostics: added `PCAP TX/RX` counters to `ethernet_pcap.cpp` + (SendPacket/GetPackets) -- confirmed the pod WAS transmitting all along; + the bridge forwarding was the only gap. Capture the wire with + `dumpcap -i -f "arp or tcp port 1501" -w cap.pcapng`. + +**Verified protocol on the wire (little-endian, matches the source decode):** +- Console->pod **StateQuery** (32B): hdr clientID=4(App) gameID=0 fromHost=1 + ts=.. ; msg len=16 id=3 flags=0 ; body requestingHost=1. +- Pod->console **StateResponse** (40B): hdr **clientID=5 (ConsoleClientID)** + gameID=0 fromHost=0 ts=.. ; msg len=24 id=1 **flags=1 (Reliable)** ; body + respondingHostID=0, **applicationState=1**, application=1 (BTL4/BattleTech). + (My earlier synthetic guess had clientID=4/flags=0/host=1/state=0 -- all + wrong, which is why the real console rejected the stand-in. These are the + correct values for pod_responder.py.) +- Console polls StateQuery ~every 2s; pod ACKs + StateResponse each time. + Clean 3-way handshake; zero-length "dup ACKs" are benign keepalives. + +OPEN: with this working, the console still hadn't enabled mission-send in +the stripped-down (keyboard-only, no-RIO, no-sound) pod boot -- retrying +with a full RIO+sound boot (`net_full.conf`) so the pod presents its normal +state (applicationState may change when fully booted). Need the console UI +readout to know what state it wants before it will send the mission egg. + ## Open questions / notes - Exact TCP listen port(s) — not in the source grep; get from NETNUB.EXE or a capture at milestone 3. diff --git a/emulator/net-tools/pod_responder.py b/emulator/net-tools/pod_responder.py new file mode 100644 index 0000000..ed4aa1e --- /dev/null +++ b/emulator/net-tools/pod_responder.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Minimal stand-in POD: answer the 4.10 console's StateQuery so it unlocks +mission-send, then capture the mission-egg delivery. + +Protocol (decoded from CODE/RP/MUNGA/{APPMSG,CONSOLE,NETWORK}.HPP): + - Console TCP-connects to the pod (port 1501) and repeatedly sends an + Application StateQueryMessage (clientID=4 App, wire messageID=3) until + the pod answers, THEN it lets the operator send a mission. + - The pod answers with a ConsoleApplicationStateResponseMessage + (messageID=1, size 24: respondingHostID, applicationState, application). + application = BTL4 = 1 (Puck runs BattleTech). + - Wire is LITTLE-ENDIAN (Mac console byte-swaps via LEDWORD; confirmed by + capture). Every packet = NetworkPacketHeader(16) + Receiver::Message. + +A few values (the "ready" applicationState code, host-ID routing, clientID) +are inferred; tweak the TUNE block and re-run if the console doesn't accept +the response. Once it does, send a mission from the console and this logs + +reassembles the ReceiveEggFileMessage chunks (the egg). + +Usage: python pod_responder.py [port] +""" +import socket, struct, sys, time + +PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 1501 + +# ---- TUNE: values inferred from headers; adjust if the console rejects ---- +RESP_CLIENT_ID = 4 # ClientID in the response header (query used 4=App) +RESP_FROM_HOST = 1 # header.fromHost of our response +RESP_HOST_ID = 1 # respondingHostID (echo the query's requestingHost) +APP_STATE = 0 # applicationState: 0 = idle/ready (best guess) +APP_ID = 1 # ApplicationID: RPL4=0, BTL4=1, BTW4=2 -> Puck=BT +STATE_RESP_MSGID = 1 # ConsoleApplicationStateResponseMessageID +# --------------------------------------------------------------------------- + +RAW = f"pod_responder_{int(time.time())}.bin" + +def le(*vals): + return b"".join(struct.pack(" 40-byte packet + +def parse_hdr(buf): + if len(buf) < 28: + return None + cid, gid, fh, ts = struct.unpack(" n: + out.append(f" ... (+{len(b)-n})") + return "\n".join(out) + +def main(): + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind(("0.0.0.0", PORT)); srv.listen(1) + print(f"pod_responder: listening on 0.0.0.0:{PORT} (raw -> {RAW})") + print(f" reply = StateResponse(host={RESP_HOST_ID}, state={APP_STATE}, " + f"app={APP_ID}) clientID={RESP_CLIENT_ID}") + raw = open(RAW, "wb") + egg = bytearray() # reassembled egg + egg_total = None + while True: + conn, addr = srv.accept() + print(f"\n=== connection from {addr} ===") + buf = b""; queries = 0 + conn.settimeout(30) + try: + while True: + chunk = conn.recv(4096) + if not chunk: + break + raw.write(chunk); raw.flush(); buf += chunk + # frame out concatenated packets: 16B hdr + msgLen body + while len(buf) >= 28: + h = parse_hdr(buf) + total = 16 + h["msgLen"] + if h["msgLen"] < 12 or h["msgLen"] > 2000: + print(" ! bad msgLen, dumping:\n" + hexdump(buf)) + buf = b""; break + if len(buf) < total: + break # wait for the rest + pkt, buf = buf[:total], buf[total:] + print(f"\n<- pkt {total}B {h}") + print(hexdump(pkt)) + if h["msgID"] == 3: # StateQuery + queries += 1 + r = state_response() + conn.sendall(r) + print(f"-> StateResponse ({len(r)}B) [query #{queries}]") + elif h["msgLen"] == 1024: # ReceiveEggFileMessage + seq, flen, tlen = struct.unpack("= flen: + fn = f"received_egg_{int(time.time())}.egg" + open(fn, "wb").write(egg) + print(f" *** EGG COMPLETE -> {fn} ({len(egg)}B)") + else: + print(f" (msgID={h['msgID']} — not handled)") + except socket.timeout: + print("(idle 30s)") + print("=== closed ===") + conn.close() + +if __name__ == "__main__": + main()