Skip to content

Commit 6842090

Browse files
committed
feat(web): Tunnel LAN multiplayer over a WebSocket relay
Browsers cannot open raw UDP sockets, so LAN play needs a different transport. Reimplement the UDP class on the emscripten_websocket_* API for Emscripten: every datagram is framed [srcIP|srcPort|dstIP|dstPort|payload] over a single WebSocket shared by the whole process, and relay.py routes it - unicast by destination virtual IP, and 255.255.255.255 (LANAPI host discovery) fanned out to every peer. Inbound datagrams are demuxed into per-bound-port queues. The native implementation moves behind the #else untouched. The browser has no network interfaces to enumerate either, so offer the loopback range 127.0.0.1 .. 127.0.0.8 as selectable LAN identities, and let an optional ?ip=N URL parameter pin one - same-origin tabs share IndexedDB, so without it two tabs would take the same saved IP. For the same reason they would also share the saved player name, so the LAN name picks up the same N as a suffix. Ported from the branch this web port came from, along with the -lwebsocket.js the WebSocket API lives in, which had not come across.
1 parent 27dfd86 commit 6842090

5 files changed

Lines changed: 518 additions & 0 deletions

File tree

Core/GameEngine/Source/GameNetwork/IPEnumeration.cpp

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,26 @@
2828
#include "GameNetwork/networkutil.h"
2929
#include "GameClient/ClientInstance.h"
3030

31+
#if defined(__EMSCRIPTEN__)
32+
#include <emscripten.h>
33+
34+
// TheSuperHackers @feature githubawn 30/07/2026 Read the optional ?ip=N (1..8) URL
35+
// parameter that lets each browser tab take a distinct loopback identity (127.0.0.N).
36+
// Shared by the IP enumeration below (network identity) and the LAN lobby, which suffixes
37+
// the player name with N as well - same-origin tabs share IndexedDB and therefore the saved
38+
// name, which would otherwise collide. Returns 0 when unset or out of range.
39+
extern "C" int ggc_url_ip_index(void)
40+
{
41+
return EM_ASM_INT({
42+
var s = location.search || '';
43+
var i = s.indexOf('ip=');
44+
if (i < 0) return 0;
45+
var v = parseInt(s.substring(i + 3), 10);
46+
return (v >= 1 && v <= 8) ? v : 0;
47+
});
48+
}
49+
#endif
50+
3151
#ifndef _WIN32
3252
#include <ifaddrs.h>
3353
#include <net/if.h>
@@ -65,6 +85,31 @@ EnumeratedIP * IPEnumeration::getAddresses()
6585
if (m_IPlist)
6686
return m_IPlist;
6787

88+
#if defined(__EMSCRIPTEN__)
89+
// TheSuperHackers @feature githubawn 30/07/2026 The browser has no real network
90+
// interfaces; LAN traffic is tunneled over the WebSocket relay (see udp.cpp). Offer the
91+
// loopback range 127.0.0.1 .. 127.0.0.8 so each tab can take a distinct LAN identity in
92+
// the Options "IP" combo box. The relay routes by the selected IP, and the game's ports
93+
// are hardcoded, so peers have to differ by IP. Mirrors the desktop
94+
// -multiInstance 127.0.0.<id> scheme.
95+
//
96+
// An optional ?ip=N (1..8) URL parameter pins this tab to 127.0.0.N and nothing else, so
97+
// several same-origin tabs - which share IndexedDB and therefore the saved IP preference
98+
// - can still each take their own identity without touching Options. Read here rather
99+
// than at startup because a GlobalData re-init would clobber an early m_defaultIP.
100+
{
101+
const int ipN = ggc_url_ip_index();
102+
if (ipN >= 1 && ipN <= 8)
103+
{
104+
addNewIP(127, 0, 0, (UnsignedByte)ipN);
105+
return m_IPlist;
106+
}
107+
}
108+
for (UnsignedByte n = 1; n <= 8; ++n)
109+
addNewIP(127, 0, 0, n);
110+
return m_IPlist;
111+
#endif
112+
68113
if (!m_isWinsockInitialized)
69114
{
70115
// TheSuperHackers @bugfix bobtista 09/06/2026 Only validate the Winsock

Core/GameEngine/Source/GameNetwork/udp.cpp

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,210 @@ typedef int socklen_t;
4242

4343
//-------------------------------------------------------------------------
4444

45+
#if defined(__EMSCRIPTEN__)
46+
// TheSuperHackers @feature githubawn 27/06/2026 WebAssembly LAN networking.
47+
// Browsers cannot open raw UDP/TCP sockets, so on Emscripten the UDP class is
48+
// reimplemented on top of a single WebSocket to a relay (relay.py). Every datagram is
49+
// framed [srcIP|srcPort|dstIP|dstPort|payload] (big-endian header) and the relay routes
50+
// unicast by destination virtual IP and 255.255.255.255 broadcast (LANAPI discovery) to
51+
// all peers. One WebSocket is shared by every UDP in the process; inbound datagrams are
52+
// demuxed into per-bound-port queues. Everything runs on the main browser thread (LAN is
53+
// polled each frame), so the WebSocket callbacks and Read()/Write() never race.
54+
#include <emscripten/emscripten.h>
55+
#include <emscripten/websocket.h>
56+
#include <map>
57+
#include <deque>
58+
#include <vector>
59+
#include <cstring>
60+
#include <cstdio>
61+
62+
namespace
63+
{
64+
inline void putBE32(unsigned char *p, UnsignedInt v) { p[0]=(unsigned char)(v>>24); p[1]=(unsigned char)(v>>16); p[2]=(unsigned char)(v>>8); p[3]=(unsigned char)v; }
65+
inline void putBE16(unsigned char *p, UnsignedShort v) { p[0]=(unsigned char)(v>>8); p[1]=(unsigned char)v; }
66+
inline UnsignedInt getBE32(const unsigned char *p) { return ((UnsignedInt)p[0]<<24)|((UnsignedInt)p[1]<<16)|((UnsignedInt)p[2]<<8)|(UnsignedInt)p[3]; }
67+
inline UnsignedShort getBE16(const unsigned char *p) { return (UnsignedShort)(((UnsignedInt)p[0]<<8)|(UnsignedInt)p[1]); }
68+
69+
struct WsDatagram
70+
{
71+
UnsignedInt srcIP;
72+
UnsignedShort srcPort;
73+
std::vector<unsigned char> data;
74+
};
75+
76+
class WsRelay
77+
{
78+
public:
79+
static WsRelay &get() { static WsRelay s; return s; }
80+
81+
void connect()
82+
{
83+
if (m_sock || !emscripten_websocket_is_supported())
84+
return;
85+
char url[256];
86+
EM_ASM({
87+
var h = (typeof location !== 'undefined' && location.hostname) ? location.hostname : 'localhost';
88+
stringToUTF8('ws://' + h + ':8090', $0, 256);
89+
}, url);
90+
EmscriptenWebSocketCreateAttributes attr;
91+
emscripten_websocket_init_create_attributes(&attr);
92+
attr.url = url;
93+
attr.createOnMainThread = EM_TRUE;
94+
m_sock = emscripten_websocket_new(&attr);
95+
if (m_sock <= 0) { m_sock = 0; return; }
96+
emscripten_websocket_set_onopen_callback(m_sock, this, &WsRelay::onOpenCb);
97+
emscripten_websocket_set_onmessage_callback(m_sock, this, &WsRelay::onMessageCb);
98+
emscripten_websocket_set_onclose_callback(m_sock, this, &WsRelay::onCloseCb);
99+
emscripten_websocket_set_onerror_callback(m_sock, this, &WsRelay::onErrorCb);
100+
}
101+
102+
UnsignedInt localIP() const { return m_assignedIP; }
103+
104+
void registerPort(UnsignedShort port) { m_queues[port]; }
105+
void unregisterPort(UnsignedShort port) { m_queues.erase(port); }
106+
107+
void send(UnsignedInt srcIP, UnsignedShort srcPort, UnsignedInt dstIP, UnsignedShort dstPort,
108+
const unsigned char *p, UnsignedInt len)
109+
{
110+
if (!m_open)
111+
return;
112+
std::vector<unsigned char> buf(12 + len);
113+
putBE32(&buf[0], srcIP); putBE16(&buf[4], srcPort);
114+
putBE32(&buf[6], dstIP); putBE16(&buf[10], dstPort);
115+
if (len) memcpy(&buf[12], p, len);
116+
emscripten_websocket_send_binary(m_sock, buf.data(), (uint32_t)buf.size());
117+
}
118+
119+
bool recv(UnsignedShort port, WsDatagram &out)
120+
{
121+
std::map<UnsignedShort, std::deque<WsDatagram> >::iterator it = m_queues.find(port);
122+
if (it == m_queues.end() || it->second.empty())
123+
return false;
124+
out = it->second.front();
125+
it->second.pop_front();
126+
return true;
127+
}
128+
129+
private:
130+
WsRelay() : m_sock(0), m_open(false), m_assignedIP(0) {}
131+
132+
void handleText(const char *s)
133+
{
134+
unsigned a, b, c, d;
135+
if (s && strncmp(s, "IP ", 3) == 0 && sscanf(s + 3, "%u.%u.%u.%u", &a, &b, &c, &d) == 4)
136+
m_assignedIP = (a << 24) | (b << 16) | (c << 8) | d;
137+
}
138+
139+
void handleBinary(const unsigned char *p, int len)
140+
{
141+
if (len < 12)
142+
return;
143+
WsDatagram dg;
144+
dg.srcIP = getBE32(p);
145+
dg.srcPort = getBE16(p + 4);
146+
UnsignedShort dstPort = getBE16(p + 10);
147+
dg.data.assign(p + 12, p + len);
148+
m_queues[dstPort].push_back(dg);
149+
}
150+
151+
static EM_BOOL onOpenCb(int, const EmscriptenWebSocketOpenEvent *, void *ud) { ((WsRelay *)ud)->m_open = true; return EM_TRUE; }
152+
static EM_BOOL onCloseCb(int, const EmscriptenWebSocketCloseEvent *, void *ud) { ((WsRelay *)ud)->m_open = false; return EM_TRUE; }
153+
static EM_BOOL onErrorCb(int, const EmscriptenWebSocketErrorEvent *, void *) { return EM_TRUE; }
154+
static EM_BOOL onMessageCb(int, const EmscriptenWebSocketMessageEvent *e, void *ud)
155+
{
156+
WsRelay *self = (WsRelay *)ud;
157+
if (e->isText) self->handleText((const char *)e->data);
158+
else self->handleBinary(e->data, (int)e->numBytes);
159+
return EM_TRUE;
160+
}
161+
162+
EMSCRIPTEN_WEBSOCKET_T m_sock;
163+
bool m_open;
164+
UnsignedInt m_assignedIP;
165+
std::map<UnsignedShort, std::deque<WsDatagram> > m_queues;
166+
};
167+
} // namespace
168+
169+
// Exposed to IPEnumeration / SDL3Main so the engine can learn its relay-assigned LAN IP
170+
// and start connecting early (well before the user reaches the LAN lobby).
171+
extern "C" void ggc_ws_connect(void) { WsRelay::get().connect(); }
172+
extern "C" unsigned int ggc_ws_local_ip(void) { WsRelay &r = WsRelay::get(); r.connect(); return r.localIP(); }
173+
174+
UDP::UDP() { fd = 0; myIP = 0; myPort = 0; m_lastError = 0; WsRelay::get().connect(); }
175+
UDP::~UDP() { if (fd) WsRelay::get().unregisterPort(myPort); }
176+
177+
Int UDP::Bind(const char * /*Host*/, UnsignedShort port) { return Bind((UnsignedInt)0, port); }
178+
179+
Int UDP::Bind(UnsignedInt IP, UnsignedShort Port)
180+
{
181+
WsRelay &r = WsRelay::get();
182+
r.connect();
183+
// The caller passes the local IP it chose (the 127.0.0.N selected in Options, surfaced
184+
// via IPEnumeration / m_defaultIP). That value is this client's identity on the relay:
185+
// the relay learns it from the src field of our datagrams and routes peers' unicasts to
186+
// it. Fall back to the relay-assigned IP only if none was supplied.
187+
myIP = IP ? IP : r.localIP();
188+
if (Port == 0)
189+
{
190+
static UnsignedShort s_ephemeral = 50000;
191+
Port = ++s_ephemeral; // implicit bind -> pick a high ephemeral port
192+
}
193+
myPort = Port;
194+
r.registerPort(myPort);
195+
fd = 1; // non-zero = bound
196+
return OK;
197+
}
198+
199+
Int UDP::getLocalAddr(UnsignedInt &ip, UnsignedShort &port)
200+
{
201+
if (myIP == 0) myIP = WsRelay::get().localIP();
202+
ip = myIP;
203+
port = myPort;
204+
return OK;
205+
}
206+
207+
Int UDP::SetBlocking(Int /*block*/) { return OK; } // always non-blocking on web
208+
209+
Int UDP::Write(const unsigned char *msg, UnsignedInt len, UnsignedInt IP, UnsignedShort port)
210+
{
211+
if ((IP == 0) || (port == 0)) return ADDRNOTAVAIL;
212+
UnsignedInt srcIP = myIP ? myIP : WsRelay::get().localIP();
213+
WsRelay::get().send(srcIP, myPort, IP, port, msg, len);
214+
return (Int)len;
215+
}
216+
217+
Int UDP::Read(unsigned char *msg, UnsignedInt len, sockaddr_in *from)
218+
{
219+
WsDatagram dg;
220+
if (!WsRelay::get().recv(myPort, dg))
221+
return 0; // no data pending (caller treats 0 as would-block)
222+
UnsignedInt n = (UnsignedInt)dg.data.size();
223+
if (n > len) n = len;
224+
if (n) memcpy(msg, &dg.data[0], n);
225+
if (from)
226+
{
227+
memset(from, 0, sizeof(*from));
228+
from->sin_family = AF_INET;
229+
from->sin_addr.s_addr = htonl(dg.srcIP);
230+
from->sin_port = htons(dg.srcPort);
231+
}
232+
return (Int)n;
233+
}
234+
235+
void UDP::ClearStatus() { m_lastError = 0; }
236+
UDP::sockStat UDP::GetStatus() { return OK; }
237+
Int UDP::SetInputBuffer(UnsignedInt /*bytes*/) { return TRUE; }
238+
Int UDP::SetOutputBuffer(UnsignedInt /*bytes*/) { return TRUE; }
239+
int UDP::GetInputBuffer() { return 0; }
240+
int UDP::GetOutputBuffer() { return 0; }
241+
Int UDP::AllowBroadcasts(Bool /*status*/) { return TRUE; }
242+
243+
#ifdef DEBUG_LOGGING
244+
AsciiString GetWSAErrorString( Int error ) { AsciiString s; s.format("err %d", error); return s; }
245+
#endif
246+
247+
#else // !__EMSCRIPTEN__
248+
45249
#ifdef DEBUG_LOGGING
46250

47251
#define CASE(x) case (x): return #x;
@@ -546,3 +750,5 @@ Int UDP::AllowBroadcasts(Bool status)
546750
else
547751
return FALSE;
548752
}
753+
754+
#endif // __EMSCRIPTEN__

GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/LanLobbyMenu.cpp

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,30 @@
6262
#include "GameNetwork/LANAPICallbacks.h"
6363
#include "GameNetwork/LANGameInfo.h"
6464

65+
#if defined(__EMSCRIPTEN__)
66+
// Defined in IPEnumeration.cpp: the optional ?ip=N (1..8) URL parameter.
67+
extern "C" int ggc_url_ip_index(void);
68+
#endif
69+
70+
// TheSuperHackers @feature githubawn 30/07/2026 Same-origin browser tabs share IndexedDB
71+
// and therefore the saved UserName, so two tabs would enter the LAN lobby under identical
72+
// names and collide. When ?ip=N has given this tab its own loopback identity (see
73+
// IPEnumeration), put the same N on the end of the name. Mirrors the instance-id scheme used
74+
// for multiple desktop clients. Does nothing off the web.
75+
static void suffixNameForWebInstance(UnicodeString &name)
76+
{
77+
#if defined(__EMSCRIPTEN__)
78+
const int ipN = ggc_url_ip_index();
79+
if (ipN >= 1 && ipN <= 8)
80+
{
81+
name.truncateTo(g_lanPlayerNameLength - 1); // leave room for the digit
82+
name.concat((WideChar)(L'0' + ipN));
83+
}
84+
#else
85+
(void)name;
86+
#endif
87+
}
88+
6589
Bool LANisShuttingDown = false;
6690
Bool LANbuttonPushed = false;
6791
Bool LANSocketErrorDetected = FALSE;
@@ -105,6 +129,7 @@ UnicodeString LANPreferences::getUserName()
105129
ret.trim();
106130
if (!ret.isEmpty())
107131
{
132+
suffixNameForWebInstance(ret);
108133
return ret;
109134
}
110135
}
@@ -120,6 +145,7 @@ UnicodeString LANPreferences::getUserName()
120145
// Use machine name as default user name.
121146
IPEnumeration IPs;
122147
ret.translate(IPs.getMachineName());
148+
suffixNameForWebInstance(ret);
123149
return ret;
124150
}
125151

GeneralsMD/Code/Main/CMakeLists.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,12 @@ if(EMSCRIPTEN)
153153
# IDBFS backs the options/save directory with IndexedDB so settings survive a
154154
# reload; the game mounts it in SDL3Main.
155155
"-lidbfs.js"
156+
# TheSuperHackers @build githubawn 30/07/2026 LAN multiplayer over WebSockets:
157+
# udp.cpp reimplements the UDP class on the emscripten_websocket_* API to tunnel the
158+
# game's datagrams to relay.py, and that API lives in the websocket.js JS library.
159+
# stringToUTF8 (exported above) is used from EM_ASM to build the relay URL from
160+
# location.hostname.
161+
"-lwebsocket.js"
156162
# The browser has no system fonts and no Fontconfig, so the font the text
157163
# renderer uses is embedded in the module (see cmake/webfont.cmake).
158164
# SHELL: keeps each flag with its argument: CMake de-duplicates repeated link

0 commit comments

Comments
 (0)