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>
This commit is contained in:
@@ -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 <bridge#> -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.
|
||||
|
||||
@@ -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("<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()
|
||||
Reference in New Issue
Block a user