diff --git a/CMakeLists.txt b/CMakeLists.txt index dd3cfb6..e764d6a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -98,6 +98,8 @@ endif() if(EMSCRIPTEN) set(SDL2_MIXER_FOUND TRUE) set(HAVE_LIBSDL2_MIXER TRUE) + set(HAVE_NET TRUE) + set(HAVE_WEBSOCKET_NET TRUE) else() find_package(SDL2 2.0.7 REQUIRED) @@ -258,6 +260,7 @@ else() set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} \ ${SHARED_EMSCRIPTEN_FLAGS} \ + -sASYNCIFY \ -sEMULATE_FUNCTION_POINTER_CASTS=1 \ -sFULL_ES2=1 \ -sINITIAL_MEMORY=64MB \ diff --git a/README.md b/README.md index 08fd9ea..17c9c70 100644 --- a/README.md +++ b/README.md @@ -131,9 +131,26 @@ Note that the choice of features you can bind (link) buttons to is currently lim Networking Support ------------------ -WebSockets support for multiplayer has not yet been added. +Browser multiplayer now uses a WebSocket relay transport. -It should be possible to connect to a WebSockets proxy to enable online play, but Dwasm will need rebuilding with the appropriate proxy configuration. +The game client still speaks the existing PrBoom packet protocol from `protocol.h`. The only transport change is that browser builds send and receive those packet bytes inside binary WebSocket frames instead of UDP datagrams. + +The relay is expected to sit between the browser and the normal Doom server path: + +- Browser client: binary WebSocket frames carrying raw Doom packet bytes. +- Relay/proxy: unwraps each frame and forwards the payload to the existing UDP server, and wraps UDP replies back into binary WebSocket frames. +- Native client/server: unchanged and still use the existing SDL_net UDP path. + +To join a relay-backed server from the browser, pass the usual `-net` argument with a WebSocket URL or host:port pair. Examples: + + https://127.0.0.1/?-net&wss%3A%2F%2Frelay.example%2Fdoom + http://127.0.0.1/?-net&ws://127.0.0.1:8000/doom + +When the page is served over `https://`, the relay must also be reachable over `wss://` or the browser will block the connection as mixed content. Use `ws://` only from an `http://` page, localhost-style development setup, or another browser context that explicitly permits it. + +If you use a bare `host:port`, Dwasm will automatically choose `ws://` or `wss://` to match the page, so an `https://` page will prefer a secure relay automatically. + +Browser multiplayer keeps the network loop active even while the window is unfocused. This avoids browser clients falling behind and dropping from a live match just because the player switched to another window or browser during play. Cheat Codes ----------- @@ -220,6 +237,8 @@ Many soundfont compilations on the Internet can sound great sometimes, but terri To build the main project, place `prboomx.wad` and other files (such as an IWAD) that you would like to include into the `wasm/fs` folder. All filenames must be in **lowercase**. +WebAssembly builds now enable the Doom client netcode by default and use Asyncify so the existing packet wait loops can yield safely while waiting for WebSocket traffic. + Next, run these commands in the Dwasm folder. Replace `/tmp/gl4es` with your GL4ES build, if applicable. If you decided not to include WebGL support, *completely* remove the option `-DGL4ES_PATH=/tmp/gl4es`. mkdir build @@ -236,6 +255,8 @@ The process will output the following into the `build` folder: These files can then be placed on a web server. To reduce bandwidth and download time, compress all the files using GZip (or better, Brotli) compression, host the files statically, and verify the web browser is doing the decompression for each file. +For multiplayer, deploy a relay endpoint that accepts binary WebSocket frames and forwards the raw payload bytes to the existing Doom UDP server. The browser client does not speak JSON and does not change the packet layout. + A GZ-compressed build should be as little as (approximately) 1 megabyte, not including resources you add. ### Standalone / Single-File Version diff --git a/cmake/config.h.cin b/cmake/config.h.cin index e58aaec..f86b6e5 100644 --- a/cmake/config.h.cin +++ b/cmake/config.h.cin @@ -26,6 +26,7 @@ #cmakedefine HAVE_LIBSDL2_IMAGE #cmakedefine HAVE_LIBSDL2_MIXER #cmakedefine HAVE_NET +#cmakedefine HAVE_WEBSOCKET_NET #cmakedefine USE_SDL_NET #cmakedefine HAVE_LIBPCREPOSIX diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 25b08de..e5a9121 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -202,13 +202,22 @@ set(MUS2MID_SRC set(SDLDOOM_SOURCES SDL/i_joy.c SDL/i_main.c - SDL/i_network.c SDL/i_sound.c SDL/i_sshot.c SDL/i_system.c SDL/i_video.c ) +if(EMSCRIPTEN) + set(NET_TRANSPORT_SOURCE + WASM/wasm_network.c + ) +else() + set(NET_TRANSPORT_SOURCE + SDL/i_network.c + ) +endif() + set(PCSOUND_SOURCES PCSOUND/pcsound.c PCSOUND/pcsound.h @@ -266,6 +275,7 @@ set(PRBOOM_PLUS_SOURCES ${WAD_SRC} ${MUS2MID_SRC} ${SDLDOOM_SOURCES} + ${NET_TRANSPORT_SOURCE} ${PCSOUND_SOURCES} ${TEXTSCREEN_SOURCES} ${DOOMMUSIC_SOURCES} diff --git a/src/SDL/i_network.c b/src/SDL/i_network.c index 835ea27..8bfca97 100644 --- a/src/SDL/i_network.c +++ b/src/SDL/i_network.c @@ -182,7 +182,7 @@ void I_Disconnect(void) * Sets the given socket non-blocking, binds to the given port, or first * available if none is given */ -UDP_SOCKET I_Socket(Uint16 port) +UDP_SOCKET I_Socket(unsigned short port) { if(port) return (SDLNet_UDP_Open(port)); diff --git a/src/SDL/i_system.c b/src/SDL/i_system.c index 011307f..6361046 100644 --- a/src/SDL/i_system.c +++ b/src/SDL/i_system.c @@ -78,6 +78,9 @@ #include "lprintf.h" #include "doomtype.h" #include "doomdef.h" +#ifdef __EMSCRIPTEN__ +#include +#endif #ifndef PRBOOM_SERVER #include "d_player.h" #include "m_fixed.h" @@ -101,9 +104,11 @@ void I_uSleep(unsigned long usecs) { -#ifndef __EMSCRIPTEN__ +#ifdef __EMSCRIPTEN__ + emscripten_sleep((unsigned int)(usecs / 1000)); +#else SDL_Delay(usecs/1000); -#endif // !__EMSCRIPTEN__ +#endif } #ifndef PRBOOM_SERVER diff --git a/src/WASM/wasm_io.c b/src/WASM/wasm_io.c index 94d7d95..3184576 100644 --- a/src/WASM/wasm_io.c +++ b/src/WASM/wasm_io.c @@ -38,6 +38,237 @@ #include +enum +{ + WASM_NET_DISCONNECTED = 0, + WASM_NET_CONNECTING = 1, + WASM_NET_OPEN = 2, + WASM_NET_FAILED = 3 +}; + +EM_JS(int, wasm_net_js_connect, (const char *endpoint), { + const MAX_QUEUED_BYTES = 1024 * 1024; + const MAX_PACKET_BYTES = 64 * 1024; + if (!Module.dwasmNet) { + Module.dwasmNet = { + socket: null, + queue: [], + queuedBytes: 0, + state: 0 + }; + } + + const net = Module.dwasmNet; + if (typeof net.queuedBytes !== 'number') { + net.queuedBytes = 0; + } + const failSocket = (socket, reason, detail) => { + if (net.socket !== socket) { + return; + } + + const messageDetail = (detail === undefined || detail === null) ? "" : detail; + console.warn(reason, messageDetail); + net.socket = null; + net.state = 3; + if (socket.readyState < WebSocket.CLOSING) { + socket.close(); + } + }; + const enqueuePacket = (socket, packet) => { + if (net.socket !== socket) { + return; + } + + if (packet.length > MAX_PACKET_BYTES) { + failSocket(socket, 'Multiplayer relay packet exceeded limit:', packet.length); + return; + } + + // Packet counts climb quickly when a browser window is unfocused. Keep a + // byte cap so memory stays bounded, but do not tear the socket down just + // because many small packets arrived while the client was throttled. + if (net.queuedBytes + packet.length > MAX_QUEUED_BYTES) { + failSocket(socket, 'Multiplayer relay receive queue exceeded limit:', net.queuedBytes + packet.length); + return; + } + + net.queue.push(packet); + net.queuedBytes += packet.length; + }; + const address = UTF8ToString(endpoint); + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + let url = address; + + if (new RegExp('^[a-zA-Z][a-zA-Z0-9+.-]*://').test(url)) { + } else if (url.startsWith('//')) { + url = protocol + url; + } else { + url = protocol + '//' + url; + } + + if (net.socket) { + const staleSocket = net.socket; + net.socket = null; + staleSocket.close(); + } + + net.queue = []; + net.queuedBytes = 0; + net.state = 1; + + try { + const socket = new WebSocket(url); + net.socket = socket; + socket.binaryType = 'arraybuffer'; + socket.onopen = () => { + if (net.socket !== socket) { + return; + } + + net.state = 2; + console.info('Connected multiplayer relay:', url); + }; + socket.onmessage = (event) => { + if (net.socket !== socket) { + return; + } + + if (event.data instanceof ArrayBuffer) { + enqueuePacket(socket, new Uint8Array(event.data)); + } else if (ArrayBuffer.isView(event.data)) { + enqueuePacket(socket, new Uint8Array(event.data.buffer.slice(event.data.byteOffset, event.data.byteOffset + event.data.byteLength))); + } else if (event.data && typeof event.data.arrayBuffer === 'function') { + event.data.arrayBuffer().then((buffer) => { + if (net.socket !== socket) { + return; + } + + enqueuePacket(socket, new Uint8Array(buffer)); + }); + } else { + console.warn('Ignoring non-binary multiplayer frame.'); + } + }; + socket.onerror = () => { + if (net.socket !== socket) { + return; + } + + if (net.state !== 2) { + net.state = 3; + } + console.warn('Multiplayer relay socket error.'); + }; + socket.onclose = () => { + if (net.socket !== socket) { + return; + } + + net.socket = null; + net.state = 3; + console.info('Multiplayer relay disconnected.'); + }; + } catch (error) { + net.socket = null; + net.queue = []; + net.state = 3; + console.warn('Failed to create multiplayer relay socket:', error); + return -1; + } + + return 0; +}); + +EM_JS(int, wasm_net_js_state, (), { + return Module.dwasmNet ? Module.dwasmNet.state : 0; +}); + +EM_JS(int, wasm_net_js_send, (const void *data, int len), { + const MAX_BUFFERED_BYTES = 1024 * 1024; + const net = Module.dwasmNet; + if (!net || !net.socket || net.state !== 2) { + return -1; + } + + if (net.socket.readyState !== WebSocket.OPEN) { + const socket = net.socket; + net.socket = null; + net.state = 3; + if (socket && socket.readyState < WebSocket.CLOSING) { + socket.close(); + } + return -1; + } + + if (net.socket.bufferedAmount > MAX_BUFFERED_BYTES) { + const socket = net.socket; + console.warn('Multiplayer relay send backlog exceeded limit:', net.socket.bufferedAmount); + net.socket = null; + net.state = 3; + socket.close(); + return -1; + } + + try { + net.socket.send(HEAPU8.slice(data, data + len)); + return len; + } catch (error) { + const socket = net.socket; + console.warn('Multiplayer relay send failed:', error); + if (socket && socket.readyState === WebSocket.OPEN) { + net.state = 3; + net.socket = null; + socket.close(); + } else { + net.socket = null; + net.state = 0; + } + return -1; + } +}); + +EM_JS(int, wasm_net_js_packet_len, (), { + const net = Module.dwasmNet; + if (!net || !net.queue.length) { + return 0; + } + + return net.queue[0].length; +}); + +EM_JS(int, wasm_net_js_receive, (void *buffer, int buflen), { + const net = Module.dwasmNet; + if (!net || !net.queue.length) { + return 0; + } + + const packet = net.queue.shift(); + net.queuedBytes -= packet.length; + const len = Math.min(packet.length, buflen); + HEAPU8.set(packet.subarray(0, len), buffer); + return len; +}); + +EM_JS(void, wasm_net_js_close, (), { + const net = Module.dwasmNet; + if (!net || !net.socket) { + if (net) { + net.queue = []; + net.queuedBytes = 0; + net.state = 0; + } + return; + } + + const socket = net.socket; + net.socket = null; + net.queue = []; + net.queuedBytes = 0; + net.state = 0; + socket.close(); +}); + static int soft_exit_code; void wasm_init_fs(void) @@ -121,6 +352,41 @@ void wasm_capture_mouse(void) ); } +void wasm_sleep(unsigned int ms) +{ + emscripten_sleep((int)ms); +} + +int wasm_net_connect(const char *endpoint) +{ + return wasm_net_js_connect(endpoint); +} + +int wasm_net_state(void) +{ + return wasm_net_js_state(); +} + +int wasm_net_send(const void *data, int len) +{ + return wasm_net_js_send(data, len); +} + +int wasm_net_packet_len(void) +{ + return wasm_net_js_packet_len(); +} + +int wasm_net_receive(void *buffer, int buflen) +{ + return wasm_net_js_receive(buffer, buflen); +} + +void wasm_net_close(void) +{ + wasm_net_js_close(); +} + void wasm_soft_exit(int exit_code) { soft_exit_code = exit_code; diff --git a/src/WASM/wasm_io.h b/src/WASM/wasm_io.h index 57bd21f..67b5d7c 100644 --- a/src/WASM/wasm_io.h +++ b/src/WASM/wasm_io.h @@ -44,6 +44,13 @@ void wasm_hide_console(void); void wasm_show_console(void); void wasm_vid_resize(void); void wasm_capture_mouse(void); +void wasm_sleep(unsigned int ms); +int wasm_net_connect(const char *endpoint); +int wasm_net_state(void); +int wasm_net_send(const void *data, int len); +int wasm_net_packet_len(void); +int wasm_net_receive(void *buffer, int buflen); +void wasm_net_close(void); void wasm_soft_exit(int exit_code); void wasm_soft_exit_fs_check(void); diff --git a/src/WASM/wasm_network.c b/src/WASM/wasm_network.c new file mode 100644 index 0000000..122b9f8 --- /dev/null +++ b/src/WASM/wasm_network.c @@ -0,0 +1,154 @@ +/* Emacs style mode select -*- C++ -*- + *----------------------------------------------------------------------------- + * + * WASM multiplayer transport over WebSocket. + *----------------------------------------------------------------------------*/ + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#ifdef HAVE_WEBSOCKET_NET + +#include "SDL.h" + +#include "protocol.h" +#include "i_network.h" +#include "i_system.h" +#include "wasm_io.h" + +enum +{ + WASM_NET_DISCONNECTED = 0, + WASM_NET_CONNECTING = 1, + WASM_NET_OPEN = 2, + WASM_NET_FAILED = 3 +}; + +#define WASM_NET_POLL_MS 5 + +UDP_SOCKET udp_socket; +size_t sentbytes, recvdbytes; + +static byte ChecksumPacket(const packet_header_t *buffer, size_t len) +{ + const byte *p = (const byte *)buffer; + byte sum = 0; + + if (len == 0) + return 0; + + while (p++, --len) + sum += *p; + + return sum; +} + +static void I_ShutdownNetwork(void) +{ + wasm_net_close(); + udp_socket = 0; +} + +void I_InitNetwork(void) +{ + static int initialized; + + if (initialized) + return; + + initialized = 1; + I_AtExit(I_ShutdownNetwork, true); +} + +UDP_SOCKET I_Socket(unsigned short port) +{ + (void)port; + udp_socket = 1; + return udp_socket; +} + +int I_ConnectToServer(const char *serv) +{ + Uint32 started; + + if (wasm_net_connect(serv) < 0) + { + wasm_net_close(); + return -1; + } + + started = SDL_GetTicks(); + while (1) + { + int state = wasm_net_state(); + Uint32 elapsed = SDL_GetTicks() - started; + + if (state == WASM_NET_OPEN) + return 0; + + if (state == WASM_NET_DISCONNECTED || state == WASM_NET_FAILED) + { + wasm_net_close(); + return -1; + } + + if (elapsed >= 5000) + { + wasm_net_close(); + return -1; + } + + wasm_sleep((5000 - elapsed) < WASM_NET_POLL_MS ? (5000 - elapsed) : WASM_NET_POLL_MS); + } +} + +size_t I_GetPacket(packet_header_t *buffer, size_t buflen) +{ + int checksum; + int len; + + len = wasm_net_receive(buffer, (int)buflen); + if (len <= 0) + return 0; + + checksum = buffer->checksum; + buffer->checksum = 0; + if (ChecksumPacket(buffer, (size_t)len) != checksum) + return 0; + + recvdbytes += (size_t)len; + return (size_t)len; +} + +void I_SendPacket(packet_header_t *packet, size_t len) +{ + packet->checksum = ChecksumPacket(packet, len); + if (wasm_net_send(packet, (int)len) > 0) + sentbytes += len; +} + +void I_WaitForPacket(int ms) +{ + Uint32 started = SDL_GetTicks(); + + while (wasm_net_packet_len() == 0) + { + int state = wasm_net_state(); + Uint32 elapsed = SDL_GetTicks() - started; + Uint32 remaining = WASM_NET_POLL_MS; + + if (ms >= 0 && elapsed >= (Uint32)ms) + break; + + if (ms >= 0) + remaining = (Uint32)ms - elapsed; + + if (state == WASM_NET_DISCONNECTED || state == WASM_NET_FAILED) + break; + + wasm_sleep(remaining < WASM_NET_POLL_MS ? remaining : WASM_NET_POLL_MS); + } +} + +#endif diff --git a/src/d_client.c b/src/d_client.c index 4abd9fc..1a56892 100644 --- a/src/d_client.c +++ b/src/d_client.c @@ -116,8 +116,9 @@ void D_InitNetGame (void) struct { packet_header_t head; short pn; } PACKEDATTR initpacket; I_InitNetwork(); - udp_socket = I_Socket(0); - I_ConnectToServer(myargv[i]); + udp_socket = I_Socket(0); + if (I_ConnectToServer(myargv[i]) < 0) + I_Error("Failed to connect to server: %s", myargv[i]); do { @@ -158,6 +159,7 @@ void D_InitNetGame (void) } } Z_Free(packet); + } localcmds = netcmds[displayplayer = consoleplayer]; for (i=0; i 1 ? window.location.search.substr(1).split('&') : [] + arguments: window.location.search.length > 1 ? window.location.search.substr(1).split('&').map(decodeURIComponent) : [] }; Module.setStatus('Downloading...'); window.onerror = () => {