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
|
outbound TCP to 200.0.0.113:1501 reaches the pod and we capture the egg
|
||||||
exchange (resolves the framing + endianness caveats above).
|
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
|
## Open questions / notes
|
||||||
- Exact TCP listen port(s) — not in the source grep; get from NETNUB.EXE
|
- Exact TCP listen port(s) — not in the source grep; get from NETNUB.EXE
|
||||||
or a capture at milestone 3.
|
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