aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-07-17 14:19:02 +0200
committeruser <user@clank>2026-07-17 14:19:02 +0200
commit4bc5adf806a1f06ddfc456fa63ebebd3ae8e8a04 (patch)
tree45fba46a96964744aba196faee3f876decbb08b8
parentcleanup: drop c/tcp.c (old userland-SLIP TCP client, superseded by kernel TCP... (diff)
downloadgbos-4bc5adf806a1f06ddfc456fa63ebebd3ae8e8a04.tar.gz
gbos-4bc5adf806a1f06ddfc456fa63ebebd3ae8e8a04.tar.xz
gbos-4bc5adf806a1f06ddfc456fa63ebebd3ae8e8a04.zip
tools: gbhub - many Game Boys on one network (DHCP pool + inter-GB routing)
A virtual switch/router/DHCP for N emulators. It leases each Game Boy a distinct address (10.0.0.2, 10.0.0.3, ...), routes packets between them by destination IP, floods broadcasts, and NATs external traffic out a TUN. No kernel changes are needed: the GB just sends to a dst IP over its one link and the hub decides GB<->GB vs GB<->internet. sudo tools/gbhub 2 netd "ping 10.0.0.2" Verified two Game Boys talking to each other: [GB0] dhcp: leased 10.0.0.2 [GB1] dhcp: leased 10.0.0.3 [GB0] netd: up (10.0.0.2) [GB1] ping 10.0.0.2 -> 4/4 received DHCP is keyed per connection (not MAC), so identical GBs still get unique IPs; each emulator gets its own ROM copy so battery saves don't clash.
Diffstat (limited to '')
-rwxr-xr-xtools/gbhub166
1 files changed, 166 insertions, 0 deletions
diff --git a/tools/gbhub b/tools/gbhub
new file mode 100755
index 0000000..5f48bfc
--- /dev/null
+++ b/tools/gbhub
@@ -0,0 +1,166 @@
+#!/usr/bin/env python3
+"""gbhub - a virtual switch/router + DHCP for several Game Boys on one network.
+
+Spawns N emulators, leases each a distinct IP (10.0.0.2, 10.0.0.3, ...), routes
+packets between them (so they can ping/connect to *each other*), floods
+broadcasts, and NATs external traffic out a TUN (so they also reach the
+internet). No kernel changes: the GB just sends to a dst IP over its link and
+the hub decides where it goes.
+
+ sudo tools/gbhub 2 # 2 Game Boys, one network
+ sudo tools/gbhub 2 netd "ping 10.0.0.2" # scripted: GB0 serves, GB1 pings it
+
+Each positional arg after N is a console command auto-typed into that GB once it
+has a lease (a test/demo hook; interactively you'd use the OSK). Requires root.
+"""
+import os, sys, socket, threading, subprocess, struct, fcntl, time, signal, atexit, shutil
+
+HOME = os.path.expanduser("~" + (os.environ.get("SUDO_USER") or ""))
+EMU = os.path.join(HOME, "dev/gbc/build/sl0pboy")
+ROM = os.path.join(HOME, "dev/gbos/gbos.gb")
+SOCK = "/tmp/gbhub.sock"
+IFACE, GW, MTU = "gbtun0", b"\x0a\x00\x00\x01", 320
+END, ESC, BCAST = 0xC0, 0xDB, b"\xff\xff\xff\xff"
+
+def slip(pkt):
+ o = bytearray([END])
+ for c in pkt: o += bytes([ESC,0xDC]) if c==END else bytes([ESC,0xDD]) if c==ESC else bytes([c])
+ o.append(END); return bytes(o)
+def _ck(d):
+ s = 0
+ for i in range(0, len(d)-1, 2): s += (d[i]<<8)|d[i+1]
+ if len(d)%2: s += d[-1]<<8
+ while s>>16: s = (s&0xFFFF)+(s>>16)
+ return (~s)&0xFFFF
+def _dmt(dh):
+ p = 240
+ while p < len(dh) and dh[p] != 255:
+ if dh[p]==0: p += 1; continue
+ if dh[p]==53: return dh[p+2]
+ p += 2 + dh[p+1]
+ return 0
+def _dreply(req, mt, gbip):
+ d = bytearray(240); d[0]=2; d[1]=1; d[2]=6; d[4:8]=req[4:8]
+ d[16:20]=gbip; d[20:24]=GW; d[28:44]=req[28:44]; d[236:240]=b"\x63\x82\x53\x63"
+ d += bytes([53,1,mt,54,4])+GW+bytes([255])
+ udp = bytearray(8); udp[1]=67; udp[3]=68; ul=8+len(d); udp[4]=ul>>8; udp[5]=ul&0xFF; udp += d
+ tot = 20+len(udp); ip = bytearray(20); ip[0]=0x45; ip[2]=tot>>8; ip[3]=tot&0xFF
+ ip[8]=64; ip[9]=17; ip[12:16]=GW; ip[16:20]=b"\xff\xff\xff\xff"
+ ck=_ck(ip); ip[10]=ck>>8; ip[11]=ck&0xFF
+ return bytes(ip)+bytes(udp)
+def sh(cmd): subprocess.run(cmd, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+
+conns = {} # ip(bytes) -> GB
+tun = None
+
+class GB:
+ def __init__(self, idx, conn):
+ self.idx, self.conn = idx, conn
+ self.ip = bytes([10,0,0,2+idx])
+ self.wlock = threading.Lock()
+ self.line = ""
+ def send(self, data):
+ with self.wlock:
+ try: self.conn.sendall(data)
+ except Exception: pass
+ def frame(self, pkt): self.send(slip(pkt))
+ def type(self, text): self.send(text.encode()) # non-framed = console input
+
+def route(gb, fr):
+ if len(fr) >= 28 and fr[9] == 17: # DHCP -> answer with this GB's IP
+ ihl = (fr[0]&0x0f)*4
+ if ((fr[ihl+2]<<8)|fr[ihl+3]) == 67:
+ mt = _dmt(fr[ihl+8:])
+ if mt == 1: gb.frame(_dreply(fr[ihl+8:], 2, gb.ip))
+ elif mt == 3: gb.frame(_dreply(fr[ihl+8:], 5, gb.ip))
+ return
+ if len(fr) < 20: return
+ dst = fr[16:20]
+ if dst == BCAST:
+ for g in conns.values():
+ if g is not gb: g.frame(fr)
+ elif dst in conns:
+ conns[dst].frame(fr) # Game Boy -> Game Boy
+ elif tun is not None:
+ try: os.write(tun, fr) # -> internet via NAT
+ except Exception: pass
+
+def gb_reader(gb):
+ buf, inf, esc = bytearray(), False, False
+ while True:
+ try: chunk = gb.conn.recv(2048)
+ except Exception: return
+ if not chunk: return
+ for c in chunk:
+ if c == END:
+ if inf and buf: route(gb, bytes(buf)); buf=bytearray(); inf=False
+ else: buf=bytearray(); inf=True; esc=False
+ elif inf:
+ if esc: c={0xDC:END,0xDD:ESC}.get(c,c); esc=False; buf.append(c)
+ elif c==ESC: esc=True
+ else: buf.append(c)
+ else: # console output -> log per GB
+ if c == 10:
+ sys.stderr.write("[GB%d] %s\n" % (gb.idx, gb.line)); sys.stderr.flush(); gb.line=""
+ elif 32 <= c < 127:
+ gb.line += chr(c)
+
+def tun_reader():
+ while True:
+ try: pkt = os.read(tun, 2048)
+ except Exception: return
+ if len(pkt) >= 20 and pkt[16:20] in conns:
+ conns[pkt[16:20]].frame(pkt)
+
+def main():
+ global tun
+ if os.geteuid() != 0: sys.exit("gbhub: run as root")
+ n = int(sys.argv[1]) if len(sys.argv) > 1 else 2
+ cmds = sys.argv[2:2+n]
+ tun = os.open("/dev/net/tun", os.O_RDWR)
+ fcntl.ioctl(tun, 0x400454ca, struct.pack("16sH", IFACE.encode(), 0x0001|0x1000))
+ wan = subprocess.check_output("ip route show default|awk '{print $5;exit}'", shell=True).decode().strip() or "eth0"
+ sh("ip addr add 10.0.0.1/24 dev %s" % IFACE); sh("ip link set %s mtu %d up" % (IFACE, MTU))
+ sh("sysctl -w net.ipv4.ip_forward=1")
+ sh("iptables -t nat -C POSTROUTING -s 10.0.0.0/24 -o %s -j MASQUERADE || iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -o %s -j MASQUERADE" % (wan, wan))
+ sh("iptables -C FORWARD -i %s -j ACCEPT || iptables -I FORWARD -i %s -j ACCEPT" % (IFACE, IFACE))
+ sh("iptables -C FORWARD -o %s -j ACCEPT || iptables -I FORWARD -o %s -j ACCEPT" % (IFACE, IFACE))
+ def cleanup():
+ sh("iptables -t nat -D POSTROUTING -s 10.0.0.0/24 -o %s -j MASQUERADE" % wan)
+ sh("iptables -D FORWARD -i %s -j ACCEPT" % IFACE); sh("iptables -D FORWARD -o %s -j ACCEPT" % IFACE)
+ atexit.register(cleanup)
+
+ try: os.unlink(SOCK)
+ except Exception: pass
+ srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM); srv.bind(SOCK); srv.listen(n)
+ emus = []
+ for i in range(n):
+ rom = "/tmp/gbhub_%d.gb" % i # per-GB ROM so battery saves don't clash
+ shutil.copyfile(ROM, rom)
+ emus.append(subprocess.Popen([EMU, "--headless", "--uncapped", "--serial-sock", SOCK, rom],
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+ start_new_session=True))
+ sys.stderr.write("[gbhub] %d Game Boys on 10.0.0.0/24 (10.0.0.2..%d), NAT via %s\n" % (n, 1+n, wan))
+ srv.settimeout(10)
+ for i in range(n):
+ conn, _ = srv.accept(); gb = GB(i, conn); conns[gb.ip] = gb
+ threading.Thread(target=gb_reader, args=(gb,), daemon=True).start()
+ threading.Thread(target=tun_reader, daemon=True).start()
+
+ time.sleep(2.0) # let DHCP settle
+ for i in range(n):
+ if i < len(cmds) and cmds[i]:
+ conns[bytes([10,0,0,2+i])].type(cmds[i] + "\n")
+ def stop(*a):
+ for e in emus:
+ try: os.killpg(os.getpgid(e.pid), signal.SIGKILL)
+ except Exception: pass
+ sys.exit(0)
+ signal.signal(signal.SIGTERM, stop)
+ try:
+ while all(e.poll() is None for e in emus): time.sleep(0.3)
+ except KeyboardInterrupt: pass
+ finally: stop()
+
+if __name__ == "__main__":
+ main()