Files
CydandClaude Fable 5 bda2f7b8b3 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 <noreply@anthropic.com>
2026-07-05 17:14:48 -05:00

130 lines
5.7 KiB
Python

#!/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("<i", v) for v in vals)
def packet(client_id, game_id, from_host, ts, msg_id, msg_flags, body=b""):
"""NetworkPacketHeader(16) + Receiver::Message(12 + body)."""
hdr = le(client_id, game_id, from_host, ts)
msg_len = 12 + len(body)
msg = struct.pack("<Iii", msg_len, msg_id, msg_flags) + body
return hdr + msg
def state_response():
body = le(RESP_HOST_ID, APP_STATE, APP_ID) # 12 bytes
return packet(RESP_CLIENT_ID, 0, RESP_FROM_HOST,
int(time.time()) & 0x7fffffff,
STATE_RESP_MSGID, 0, body) # -> 40-byte packet
def parse_hdr(buf):
if len(buf) < 28:
return None
cid, gid, fh, ts = struct.unpack("<iiii", buf[0:16])
mlen, mid, mfl = struct.unpack("<Iii", buf[16:28])
return dict(clientID=cid, gameID=gid, fromHost=fh, ts=ts,
msgLen=mlen, msgID=mid, msgFlags=mfl)
def hexdump(b, n=64):
out = []
for i in range(0, min(len(b), n), 16):
c = b[i:i+16]
out.append(f" {i:04X} " +
" ".join(f"{x:02X}" for x in c).ljust(48) + " " +
"".join(chr(x) if 32 <= x < 127 else "." for x in c))
if len(b) > 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("<iii", pkt[28:40])
data = pkt[40:40+tlen]
egg.extend(data)
print(f" EGG chunk seq={seq} total={flen} "
f"this={tlen} (have {len(egg)})")
globals()['egg_total'] = flen
if flen and len(egg) >= 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()