--- a/Engine/CMakeLists.txt +++ b/Engine/CMakeLists.txt @@ -27,6 +27,12 @@ src/win32_compat.h ) +# Browser rooms use reliable WebSocket messages, not native UDP sockets. +if (EMSCRIPTEN) + list(REMOVE_ITEM src_files src/mmulti.c) + list(APPEND src_files src/web_multi.c) +endif() + include_directories(${SDL2_INCLUDE_DIR}) add_library(${project_name} STATIC ${src_files}) target_link_libraries(${project_name} PUBLIC ${SDL2_ALL_LIBRARIES}) --- a/Engine/src/mmulti.h +++ b/Engine/src/mmulti.h @@ -4,6 +4,11 @@ #include void initmultiplayers(); +#ifdef __EMSCRIPTEN__ +void web_multiplayer_playing(void); +void web_multiplayer_desync(void); +void web_multiplayer_invalid_slot(void); +#endif void sendpacket(int32_t other, const uint8_t* bufptr, int32_t messleng); void setpackettimeout(int32_t datimeoutcount, int32_t daresendagaincount); void uninitmultiplayers(void); --- a/Game/src/game.c +++ b/Game/src/game.c @@ -1183,6 +1183,9 @@ if (syncstat) { +#ifdef __EMSCRIPTEN__ + web_multiplayer_desync(); +#endif minitext(21,30+35+30, "Out Of Sync - Please restart game", COLOR_ON,2+8+16); // FIX_00090: Removed info key. FPS were shown after CRC msg. CRC not always removed. (Turrican) for(i=connecthead;i>=0;i=connectpoint2[i]) @@ -2558,14 +2561,14 @@ { for(ch=connecthead;ch >= 0;ch=connectpoint2[ch]) if (ch != myconnectindex) - sendpacket(ch,(uint8_t*)tempbuf,j+1); + sendpacket(ch,(uint8_t*)text,j+2); /* Include the terminating NUL. */ adduserquote(recbuf); quotebot += 8; quotebotgoal = quotebot; } else if(sendmessagecommand >= 0) - sendpacket(sendmessagecommand,(uint8_t*)tempbuf,j+1); + sendpacket(sendmessagecommand,(uint8_t*)text,j+2); sendmessagecommand = -1; ps[myconnectindex].gm &= ~(MODE_TYPE|MODE_SENDTOWHOM); @@ -6735,7 +6738,7 @@ tempbuf[ch+1] = 0; strcat((char*)tempbuf+1,ud.ridecule[i-1]); - i = 1+strlen(ud.ridecule[i-1]); + i = 2+strlen(ud.ridecule[i-1]); /* Include the terminating NUL. */ if(ud.multimode > 1) for(ch=connecthead;ch>=0;ch=connectpoint2[ch]) @@ -9453,6 +9456,10 @@ multiflag = 2; multiwhat = (sync[i].bits>>18)&1; multipos = (uint32_t) (sync[i].bits>>19)&15; +#ifdef __EMSCRIPTEN__ + /* A remote input can encode 16 slots; the save table has only ten. */ + if (multipos >= 10) web_multiplayer_invalid_slot(); +#endif multiwho = i; if( multiwhat ) --- a/Game/src/premap.c +++ b/Game/src/premap.c @@ -1669,6 +1669,9 @@ } ud.mapCRC[myconnectindex] = mapCRC; +#ifdef __EMSCRIPTEN__ + web_multiplayer_playing(); +#endif } --- /dev/null +++ b/Engine/src/web_multi.c @@ -0,0 +1,318 @@ +/* + * Luck4Luck browser transport for Belgian Chocolate Duke Nukem 3D. + * Copyright (C) 2026 Luck4Luck contributors + * SPDX-License-Identifier: GPL-2.0-or-later + * + * This program is free software: you may redistribute it and/or modify it + * under the GNU General Public License, version 2 or any later version. + * It is provided without warranty; see GNU.txt distributed with this source. + * + * Reproduction: extract engine-source.tar.gz, then apply network-source.patch + * with `patch -p1` in the extracted source root. The patch installs this exact + * file as Engine/src/web_multi.c and selects it only for Emscripten builds. + * Apply respawn-source.patch and save-names-source.patch separately and retain + * the upstream -sASYNCIFY link option. Native builds retain mmulti.c. + * + * The shell opens a reliable, ordered, room-isolated WebSocket before main(), + * then supplies Module['dukeNet'] with immutable player (0..count-1), count + * (2..4), send(destination, Uint8Array), and receive() -> {from, data} | null. + * Optional close(), onPlaying(), onError(message) callbacks manage the shell; + * setting closed=true terminates a waiting/running match on its next poll. + * The shell + * translates its room mode into identical /c1 (Dukematch) or /c2 (coop), /i1, + * /f1, /v1, /l1, /s2 arguments for every peer and handles disconnection/timeouts. + * No dukeNet means ordinary single-player. + * + * Packets are the game-level sendpacket/getpacket payloads, without mmulti.c's + * UDP retransmission/CRC framing. Keep every packet in FIFO order per sender; + * dropping, coalescing, or replaying input deltas corrupts lockstep. Queue early + * packets while another peer loads. Receive is deliberately nonblocking: the + * existing waitforeverybody() yields via _idle()/SDL_Delay(1), and gameplay + * yields through SDL with Asyncify. Do not call C from WebSocket callbacks. + */ + +#include +#include +#include +#include +#include "mmulti.h" + +#define WEB_MAX_PLAYERS 4 +#define ENGINE_MAX_PLAYERS 16 +/* Game/src/global.c owns packbuf[576], smaller than the old UDP buffer. */ +#define WEB_MAX_PACKET 576 + +extern void Error(int errorType, const char *error, ...); + +short myconnectindex = 0, numplayers = 1; +short connecthead = 0, connectpoint2[ENGINE_MAX_PLAYERS]; + +/* Native lockstep counters determine whether an input packet has lag bytes. + * Inspect the engine's counters instead of keeping a second sequence: native + * save/load and level changes restore/reset these counters themselves. */ +extern int32_t movefifoend[ENGINE_MAX_PLAYERS], movefifosendplc; +extern uint8_t networkmode, playerquitflag[ENGINE_MAX_PLAYERS]; + +static int input_delta_bytes(uint8_t mask) +{ + return ((mask & 1) ? 2 : 0) + ((mask & 2) ? 2 : 0) + + ((mask & 4) ? 1 : 0) + ((mask & 8) ? 1 : 0) + + ((mask & 16) ? 1 : 0) + ((mask & 32) ? 1 : 0) + + ((mask & 64) ? 1 : 0) + ((mask & 128) ? 1 : 0); +} + +/* Shapes correspond to Game/src/game.c:getpackets in the pinned source. + * In particular, a truncated input delta would make the legacy checksum loop + * walk past the end of packbuf, and oversized strings/weapon lists would write + * past their destination. Reject an invalid packet; never repair input deltas. */ +static int valid_packet(int sender, const uint8_t *data, int length, int outgoing) +{ + int i, offset, masks, required; + int32_t tick = movefifoend[sender] - (outgoing ? 1 : 0); + if (length < 1 || length > WEB_MAX_PACKET) return 0; + switch (data[0]) { + case 0: /* Master/slave mode: packed inputs for all active players. */ + if (networkmode != 0 || sender != connecthead) return 0; + /* The legacy master clears quit flags between recipients of one + * broadcast. Only the receiver has the intact pre-packet roster; + * validate that layout there, not against a partially changed one. */ + if (outgoing) return 1; + offset = 1; + if ((tick & 31) == 0) + for (i = connectpoint2[connecthead]; i >= 0; i = connectpoint2[i]) + if (playerquitflag[i]) ++offset; + masks = 0; + for (i = connecthead; i >= 0; i = connectpoint2[i]) + if (playerquitflag[i]) ++masks; + if (length < offset + masks) return 0; + required = offset + masks; + for (i = 0; i < masks; ++i) required += input_delta_bytes(data[offset + i]); + return length >= required; + case 1: /* Slave input. */ + return networkmode == 0 && sender != connecthead && length >= 2 && + length >= 2 + input_delta_bytes(data[1]); + case 16: /* First input after clearfifo; same layout as type 17. */ + case 17: + if (networkmode != 1) return 0; + if (data[0] == 16) tick = 0; + offset = 1; + if (sender == connecthead && (tick & 31) == 0) + for (i = connectpoint2[connecthead]; i >= 0; i = connectpoint2[i]) ++offset; + return length > offset && length >= offset + 1 + input_delta_bytes(data[offset]); + case 4: /* recbuf[80]; the receiver also writes recbuf[length-1]. */ + return length >= 2 && length <= 80 && memchr(data + 1, 0, length - 1) != NULL; + case 5: /* Standard new game: episode, level, skill and six options. */ + case 8: /* User map: the receiver copies length-11 into boardfilename[128]. */ + if ((data[0] == 5 && length != 11) || + (data[0] == 8 && (length < 11 || length > 138))) return 0; + if (data[1] > 10 || data[2] > 3 || data[3] > 4 || data[8] > 2) return 0; + /* Legacy user-map names start at byte 10, occupying the location + * used by the friendly-fire flag in the standard type-5 packet. */ + for (i = 4; i <= (data[0] == 5 ? 10 : 9); ++i) + if (i != 8 && data[i] > 1) return 0; + return 1; + case 6: /* Version byte plus at most ten name bytes and NUL. */ + return length >= 3 && length <= 13 && memchr(data + 2, 0, length - 2) != NULL; + case 7: /* Ten native RTS taunts. */ + return length == 2 && data[1] >= 1 && data[1] <= 10; + case 9: /* Ten weapon preferences, each within MAX_WEAPONS (12). */ + if (length != 11) return 0; + for (i = 1; i < length; ++i) if (data[i] >= 12) return 0; + return 1; + case 10: /* Legacy aim-mode announcement sent during getnames. */ + return length == 2 && data[1] <= 1; + case 125: case 127: case 250: + return length == 1; + case 126: + return length == 2 && data[1] < 10; + case 131: + return length == 10; + case 132: + return length == 3; + case 133: /* getnames reuses its name-packet length for this flag. */ + return length >= 1 && length <= 13; + case 134: /* 4 GRP CRC32s + uint16 CON size + CON/EXE CRC32s. */ + return length == 27; + case 255: + return length == 2 && data[1] == sender; + default: + return 0; + } +} + +EM_JS(int, web_net_config, (void), { + var net = Module['dukeNet']; + if (net == null) return 0; + var count = net['count'], player = net['player']; + if (!Number.isInteger(count) || count < 2 || count > 4 || + !Number.isInteger(player) || player < 0 || player >= count || + typeof net['send'] !== 'function' || typeof net['receive'] !== 'function') { + return -1; + } + return (count << 8) | player; +}); + +EM_JS(int, web_net_send, (int destination, const uint8_t *data, int length), { + var net = Module['dukeNet']; + if (!net || net['closed'] || typeof net['send'] !== 'function') return -1; + // Copy: the engine reuses packbuf immediately and its memory may grow. + return net['send'](destination, HEAPU8.slice(data, data + length)) === false ? -1 : 0; +}); + +EM_JS(int, web_net_receive, (short *other, uint8_t *buffer, int capacity, int player, int count), { + var net = Module['dukeNet']; + if (!net || typeof net['receive'] !== 'function') return -1; + if (net['closed']) return -2; + var packet = net['receive'](); + if (packet == null) return 0; + var from = packet['from'], data = packet['data']; + if (!Number.isInteger(from) || from < 0 || from >= count || from === player || + !(data instanceof Uint8Array) || data.length < 1 || data.length > capacity) { + return -1; + } + HEAPU8.set(data, buffer); + HEAP16[other >> 1] = from; + return data.length; +}); + +EM_JS(void, web_net_close, (void), { + var net = Module['dukeNet']; + if (net && typeof net['close'] === 'function') net['close'](); +}); + +EM_JS(void, web_net_playing, (void), { + var net = Module['dukeNet']; + if (net && !net['closed'] && typeof net['onPlaying'] === 'function') net['onPlaying'](); +}); + +EM_JS(void, web_net_error, (const char *message), { + var net = Module['dukeNet']; + if (net && typeof net['onError'] === 'function') net['onError'](UTF8ToString(message)); +}); + +static void network_error(const char *message) +{ + web_net_error(message); + web_net_close(); + Error(EXIT_FAILURE, "%s\n", message); +} + +void web_multiplayer_playing(void) +{ + if (numplayers > 1) web_net_playing(); +} + +void web_multiplayer_desync(void) +{ + if (numplayers > 1) network_error("Multiplayer simulation is out of sync. Restart the match."); +} + +void web_multiplayer_invalid_slot(void) +{ + if (numplayers > 1) network_error("Invalid multiplayer save slot."); +} + +static void clear_connections(void) +{ + int i; + numplayers = 1; + myconnectindex = connecthead = 0; + for (i = 0; i < ENGINE_MAX_PLAYERS; ++i) connectpoint2[i] = -1; +} + +void initmultiplayers(void) +{ + int i, config; + clear_connections(); + config = web_net_config(); + if (config < 0) { + network_error("Invalid browser multiplayer configuration."); + return; + } + if (config == 0) return; + numplayers = (short)(config >> 8); + myconnectindex = (short)(config & 255); + if (numplayers < 2 || numplayers > WEB_MAX_PLAYERS || myconnectindex >= numplayers) { + network_error("Invalid browser multiplayer player assignment."); + return; + } + for (i = 0; i < numplayers - 1; ++i) connectpoint2[i] = (short)(i + 1); +} + +void sendpacket(int32_t other, const uint8_t *bufptr, int32_t messleng) +{ + if (numplayers < 2) return; + if (other < 0 || other >= numplayers || other == myconnectindex || + !bufptr || !valid_packet(myconnectindex, bufptr, messleng, 1)) { + network_error("Invalid outgoing browser multiplayer packet."); + return; + } + if (web_net_send(other, bufptr, messleng) < 0) + network_error("Browser multiplayer connection closed."); +} + +short getpacket(short *other, uint8_t *bufptr) +{ + int length; + if (other) *other = -1; + if (numplayers < 2) return 0; + if (!other || !bufptr) { + network_error("Invalid browser multiplayer receive buffer."); + return 0; + } + length = web_net_receive(other, bufptr, WEB_MAX_PACKET, myconnectindex, numplayers); + if (length < 0) { + network_error(length == -2 ? "Browser multiplayer connection closed." + : "Invalid incoming browser multiplayer packet."); + return 0; + } + if (length > 0 && !valid_packet(*other, bufptr, length, 0)) { + network_error("Invalid incoming browser multiplayer packet shape."); + return 0; + } + return (short)length; +} + +void uninitmultiplayers(void) +{ + if (numplayers > 1) web_net_close(); + clear_connections(); +} + +void sendlogon(void) {} + +void sendlogoff(void) +{ + int i; + uint8_t packet[2] = { 255, (uint8_t)myconnectindex }; + for (i = connecthead; i >= 0; i = connectpoint2[i]) + if (i != myconnectindex) sendpacket(i, packet, sizeof(packet)); +} + +void setpackettimeout(int32_t timeout, int32_t resend) +{ + (void)timeout; + (void)resend; + /* Reliable WebSocket transport; the shell owns connection deadlines. */ +} + +int32_t getoutputcirclesize(void) { return 0; } + +void flushpackets(void) +{ + /* Upstream mmulti.c intentionally makes this a no-op. In particular, + * never discard readiness packets while a slower peer enters a level. */ +} + +void genericmultifunction(int32_t other, const uint8_t *bufptr, + int32_t messleng, int32_t command) +{ + int i; + if (numplayers < 2 || command == 5) return; /* Obsolete score transport. */ + if (command == 1) { + sendpacket(other, bufptr, messleng); + } else if (command == 3 || command == 4) { + for (i = connecthead; i >= 0; i = connectpoint2[i]) + if (i != myconnectindex) sendpacket(i, bufptr, messleng); + } +}