diff --git a/.gitignore b/.gitignore index 12a85c294f..04f217aed8 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ res/keeperfx_icon.ico .header_checksum /deps/enet /deps/enet6 +/deps/libcurl /deps/zlib /deps/spng /deps/astronomy diff --git a/Makefile b/Makefile index c7a3fd6d63..b2e73d2257 100644 --- a/Makefile +++ b/Makefile @@ -105,6 +105,7 @@ obj/bflib_datetm.o \ obj/bflib_dernc.o \ obj/bflib_enet.o \ obj/net_portforward.o \ +obj/net_holepunch.o \ obj/bflib_fileio.o \ obj/bflib_filelst.o \ obj/bflib_fmvids.o \ @@ -287,6 +288,7 @@ obj/net_input_lag.o \ obj/net_received_packets.o \ obj/net_redundant_packets.o \ obj/net_checksums.o \ +obj/net_matchmaking.o \ obj/packets.o \ obj/packets_cheats.o \ obj/packets_input.o \ @@ -377,6 +379,7 @@ LINKLIB = -mwindows \ -L"deps/enet6/lib" -lenet6 \ -L"deps/miniupnpc" -lminiupnpc \ -L"deps/libnatpmp" -lnatpmp -liphlpapi \ + -L"deps/libcurl/lib" -lcurl -lwldap32 -lcrypt32 -lsecur32 -liphlpapi \ -L"deps/spng" -lspng \ -L"deps/centijson" -ljson \ -L"deps/zlib" -lminizip -lz \ @@ -395,7 +398,8 @@ INCS = \ -I"deps/openal/include" \ -I"deps/luajit/include" \ -I"deps/miniupnpc/include" \ - -I"deps/libnatpmp/include" + -I"deps/libnatpmp/include" \ + -I"deps/libcurl/include" CXXINCS = $(INCS) STDOBJS = $(subst obj/,obj/std/,$(OBJS)) @@ -435,7 +439,7 @@ HVLOGFLAGS = -DBFDEBUG_LEVEL=10 WARNFLAGS = -Wall -W -Wshadow -Wno-sign-compare -Wno-unused-parameter -Wno-maybe-uninitialized -Wno-sign-compare -Wno-strict-aliasing -Wno-unknown-pragmas -Werror # disabled warnings: -Wextra -Wtype-limits CXXFLAGS = $(CXXINCS) -c -std=gnu++1y -fmessage-length=0 $(WARNFLAGS) $(DEPFLAGS) $(OPTFLAGS) $(DBGFLAGS) $(FTEST_DBGFLAGS) $(INCFLAGS) -CFLAGS = $(INCS) -c -std=gnu11 -fmessage-length=0 $(WARNFLAGS) -Werror=implicit $(DEPFLAGS) $(FTEST_DBGFLAGS) $(OPTFLAGS) $(DBGFLAGS) $(INCFLAGS) +CFLAGS = $(INCS) -c -std=gnu11 -fmessage-length=0 $(WARNFLAGS) -Werror=implicit $(DEPFLAGS) $(FTEST_DBGFLAGS) $(OPTFLAGS) $(DBGFLAGS) $(INCFLAGS) -DCURL_STATICLIB LDFLAGS = $(LINKLIB) $(OPTFLAGS) $(DBGFLAGS) $(FTEST_DBGFLAGS) $(LINKFLAGS) -Wl,-Map,"$(@:%.exe=%.map)" ifeq ($(USE_PRE_FILE), 1) @@ -651,10 +655,11 @@ libexterns: libexterns.mk clean-libexterns: libexterns.mk -$(MAKE) -f libexterns.mk clean-libexterns - -$(RM) -rf deps/enet6 deps/zlib deps/spng deps/astronomy deps/centijson deps/luajit deps/miniupnpc deps/libnatpmp + -$(RM) -rf deps/enet6 deps/zlib deps/spng deps/astronomy deps/centijson deps/luajit deps/miniupnpc deps/libnatpmp deps/libcurl + -$(RM) deps/libcurl-mingw32.tar.gz -$(RM) libexterns -deps/enet6 deps/zlib deps/spng deps/astronomy deps/centijson deps/ffmpeg deps/openal deps/luajit deps/miniupnpc deps/libnatpmp: +deps/enet6 deps/zlib deps/spng deps/astronomy deps/centijson deps/ffmpeg deps/openal deps/luajit deps/miniupnpc deps/libnatpmp deps/libcurl: $(MKDIR) $@ src/api.c: deps/centijson/include/json.h @@ -668,6 +673,7 @@ src/bflib_sndlib.cpp: deps/openal/include/AL/al.h src/net_resync.cpp: deps/zlib/include/zlib.h src/console_cmd.c: deps/luajit/include/lua.h src/net_portforward.cpp: deps/miniupnpc/include/miniupnpc/miniupnpc.h deps/libnatpmp/include/natpmp/natpmp.h +src/net_matchmaking.c: deps/libcurl/include/curl/curl.h deps/enet6-mingw32.tar.gz: curl -Lso $@ "https://github.com/dkfans/kfx-deps/releases/download/20260212/enet6-mingw32.tar.gz" @@ -731,6 +737,12 @@ deps/libnatpmp-mingw32.tar.gz: deps/libnatpmp/include/natpmp/natpmp.h: deps/libnatpmp-mingw32.tar.gz | deps/libnatpmp tar xzmf $< -C deps/libnatpmp +deps/libcurl-mingw32.tar.gz: + curl -Lso $@ "https://github.com/dkfans/kfx-deps/releases/download/20260310/libcurl-mingw32.tar.gz" + +deps/libcurl/include/curl/curl.h: deps/libcurl-mingw32.tar.gz | deps/libcurl + tar xzmf $< -C deps/libcurl + cppcheck: | src/ver_defs.h cppcheck: | deps/zlib/include/zlib.h cppcheck: | deps/spng/include/spng.h diff --git a/docs/multiplayer_readme.txt b/docs/multiplayer_readme.txt index fa10cf0116..c128c28e7b 100644 --- a/docs/multiplayer_readme.txt +++ b/docs/multiplayer_readme.txt @@ -16,15 +16,22 @@ you are over a couple hundred kilometer away from each other. Multiplayer over ENET/UDP ------------------------------ +KeeperFX includes an online matchmaking server. Hosted games are listed there +automatically, allowing others to browse and join without exchanging IP addresses. + To host a ENET/UDP game: Make sure the port 5556 is open for traffic and is forwarded to port 5556 on your computer. When you have started the game, click Multiplayer -> ENET/UDP -> -Create Game. +Create Game. Your game will appear in the matchmaking list for others to join. To join a ENET/UDP game: -Specify a command line option -sessions [ip_address]:5556 when starting game. -For instance, if the host's IP address is 55.83.54.187, the appropriate command -line option is -sessions 55.83.54.187:5556 +Click Multiplayer -> ENET/UDP to browse available games from the matchmaking +server, then select one and click Join Game. + +Alternatively, join directly by specifying a command line option +-sessions [ip_address]:5556 when starting game. For instance, if the host's IP +address is 55.83.54.187, the appropriate command line option is +-sessions 55.83.54.187:5556 The launcher can be used to set this. Several sessions can be added to command line by prepending a semicolon before diff --git a/linux.mk b/linux.mk index 74adfa966f..5ebd6c1bd0 100644 --- a/linux.mk +++ b/linux.mk @@ -211,6 +211,8 @@ src/map_utils.c \ src/moonphase.c \ src/net_checksums.c \ src/net_game.c \ +src/net_holepunch.c \ +src/net_matchmaking.c \ src/net_input_lag.c \ src/net_received_packets.c \ src/net_redundant_packets.c \ @@ -282,6 +284,7 @@ KFX_INCLUDES = \ -Ideps/centitoml \ -Ideps/astronomy/include \ -Ideps/enet6/include \ + -Ideps/libcurl/include \ $(shell pkg-config --cflags-only-I luajit) KFX_CFLAGS += -g -DDEBUG -DBFDEBUG_LEVEL=0 -O3 -march=x86-64 $(KFX_INCLUDES) -Wall -Wextra -Werror -Wno-unused-parameter -Wno-absolute-value -Wno-unknown-pragmas -Wno-format-truncation -Wno-sign-compare @@ -309,6 +312,7 @@ KFX_LDFLAGS += \ $(shell pkg-config --libs-only-l zlib) \ -lminiupnpc \ -lnatpmp \ + -Ldeps/libcurl/lib -lcurl -lssl -lcrypto -lzstd \ -ldl TOML_SOURCES = \ @@ -331,11 +335,12 @@ endif all: bin/keeperfx clean: - rm -rf obj bin src/ver_defs.h deps/astronomy deps/centijson deps/enet6 + rm -rf obj bin src/ver_defs.h deps/astronomy deps/centijson deps/enet6 deps/libcurl + rm -f deps/libcurl-lin64.tar.gz .PHONY: all clean -bin/keeperfx: $(KFX_OBJECTS) $(TOML_OBJECTS) | bin +bin/keeperfx: $(KFX_OBJECTS) $(TOML_OBJECTS) deps/libcurl/lib/libcurl.a | bin $(CXX) -o $@ $(KFX_OBJECTS) $(TOML_OBJECTS) $(KFX_LDFLAGS) $(KFX_C_OBJECTS): obj/%.o: src/%.c src/ver_defs.h | obj @@ -349,13 +354,15 @@ $(KFX_CXX_OBJECTS): obj/%.o: src/%.cpp src/ver_defs.h | obj $(TOML_OBJECTS): obj/centitoml/%.o: deps/centitoml/%.c | obj/centitoml $(CC) $(TOML_CFLAGS) -c $< -o $@ -bin obj deps/astronomy deps/centijson deps/enet6 obj/centitoml: +bin obj deps/astronomy deps/centijson deps/enet6 deps/libcurl obj/centitoml: $(MKDIR) $@ src/actionpt.c: deps/centijson/include/json.h src/api.c: deps/centijson/include/json.h src/bflib_enet.cpp: deps/enet6/include/enet6/enet.h src/moonphase.c: deps/astronomy/include/astronomy.h +src/net_holepunch.c: deps/enet6/include/enet6/enet.h +src/net_matchmaking.c: deps/libcurl/include/curl/curl.h deps/centitoml/toml_api.c: deps/centijson/include/json.h deps/centitoml/toml_conv.c: deps/centijson/include/json.h @@ -377,6 +384,14 @@ deps/enet6-lin64.tar.gz: deps/enet6/include/enet6/enet.h: deps/enet6-lin64.tar.gz | deps/enet6 tar xzmf $< -C deps/enet6 +deps/libcurl-lin64.tar.gz: + curl -Lso $@ "https://github.com/dkfans/kfx-deps/releases/download/20260310/libcurl-lin64.tar.gz" + +deps/libcurl/lib/libcurl.a: deps/libcurl-lin64.tar.gz | deps/libcurl + tar xzmf $< -C deps/libcurl + +deps/libcurl/include/curl/curl.h: deps/libcurl/lib/libcurl.a + src/ver_defs.h: version.mk $(ECHO) "#define VER_MAJOR $(VER_MAJOR)" > $@.swp $(ECHO) "#define VER_MINOR $(VER_MINOR)" >> $@.swp diff --git a/src/bflib_datetm.cpp b/src/bflib_datetm.cpp index 06c46f405d..32d0b32bfc 100644 --- a/src/bflib_datetm.cpp +++ b/src/bflib_datetm.cpp @@ -25,10 +25,7 @@ #include "bflib_basics.h" #include "game_legacy.h" -#if defined(_WIN32) -#define WIN32_LEAN_AND_MEAN -#include -#endif +#include #include "post_inc.h" #ifdef __cplusplus @@ -307,9 +304,7 @@ TbResult LbDateTimeDecode(const time_t *datetime,struct TbDate *curr_date,struct inline void LbDoMultitasking(void) { -#if defined(_WIN32) - Sleep(LARGE_DELAY_TIME>>1); // This switches to other tasks -#endif + SDL_Delay(LARGE_DELAY_TIME>>1); } TbBool LbSleepFor(TbClockMSec delay) diff --git a/src/bflib_enet.cpp b/src/bflib_enet.cpp index d1df7b8063..ab52586d47 100644 --- a/src/bflib_enet.cpp +++ b/src/bflib_enet.cpp @@ -14,15 +14,17 @@ #include "pre_inc.h" #include "bflib_enet.h" +#include "bflib_datetm.h" #include "bflib_network.h" #include "bflib_math.h" #include "net_portforward.h" +#include "net_holepunch.h" +#include "net_matchmaking.h" #include "game_legacy.h" #include "player_data.h" -#if defined(_WIN32) +#ifdef _WIN32 #define WIN32_LEAN_AND_MEAN -#include #endif #include #include @@ -31,7 +33,12 @@ #include "post_inc.h" #define NUM_CHANNELS 2 -#define DEFAULT_PORT 5556 +#define HOLEPUNCH_CONNECT_DELAY_MS 300 +#define PEER_TIMEOUT_MIN_MS 2000 +#define PEER_TIMEOUT_MAX_MS 5000 + +uint16_t external_port = 0; +char external_ip[EXTERNAL_IP_LEN] = {0}; namespace { @@ -99,64 +106,43 @@ namespace TbError bf_enet_host(const char *session, void *options) { ENetAddress address; - enet_address_build_any(&address, ENET_ADDRESS_TYPE_IPV6); - address.port = DEFAULT_PORT; + enet_address_build_any(&address, ENET_ADDRESS_TYPE_IPV4); + address.port = ENET_DEFAULT_PORT; if (!*session) return Lb_FAIL; - int port = atoi(session); + const char *port_string = session; + if (*port_string == ':') port_string++; + int port = atoi(port_string); if (port > 0) address.port = port; - host = enet_host_create(ENET_ADDRESS_TYPE_ANY, &address, 4, NUM_CHANNELS, 0, 0); + host = enet_host_create(ENET_ADDRESS_TYPE_IPV4, &address, 4, NUM_CHANNELS, 0, 0); if (!host) { return Lb_FAIL; } enet_host_compress_with_range_coder(host); port_forward_add_mapping(address.port); + external_port = holepunch_stun_query(host, external_ip, sizeof(external_ip)); return Lb_OK; } - int wait_for_connect(int timeout) - { - ENetEvent ev; - int ret; - while ( - (ret = enet_host_service(host, &ev, timeout)) > 0 - ) - { - if (ev.type == ENET_EVENT_TYPE_CONNECT) - { - return 0; - } - else - { - fprintf(stderr, "Unexpected event %d\n", ev.type); - } - } - if (ret < 0) - { - fprintf(stderr, "Unable to connect! %d\n", ret); - ERRORLOG("Unable to connect: %d", ret); - } - return 1; - } - enet_uint16 parse_session_address(const char *session, char *host_out, size_t host_size) + enet_uint16 parse_session_address(const char *session, char *output_hostname, size_t hostname_buffer_size) { - char *E; - enet_uint16 port = DEFAULT_PORT; + char *parse_end_ptr; + enet_uint16 port = ENET_DEFAULT_PORT; if (session[0] == '[') { const char *bracket_end = strchr(session, ']'); if (!bracket_end) { return 0; } - size_t addr_len = bracket_end - session - 1; - if (addr_len >= host_size) { + size_t address_length = bracket_end - session - 1; + if (address_length >= hostname_buffer_size) { return 0; } - strncpy(host_out, session + 1, addr_len); - host_out[addr_len] = '\0'; + strncpy(output_hostname, session + 1, address_length); + output_hostname[address_length] = '\0'; if (bracket_end[1] == ':') { - port = strtoul(bracket_end + 2, &E, 10); + port = strtoul(bracket_end + 2, &parse_end_ptr, 10); if (port == 0) { return 0; } @@ -165,22 +151,22 @@ namespace const char *first_colon = strchr(session, ':'); const char *last_colon = strrchr(session, ':'); if (first_colon && first_colon != last_colon) { - strncpy(host_out, session, host_size - 1); - host_out[host_size - 1] = '\0'; + strncpy(output_hostname, session, hostname_buffer_size - 1); + output_hostname[hostname_buffer_size - 1] = '\0'; } else if (first_colon) { - size_t addr_len = first_colon - session; - if (addr_len >= host_size) { + size_t address_length = first_colon - session; + if (address_length >= hostname_buffer_size) { return 0; } - strncpy(host_out, session, addr_len); - host_out[addr_len] = '\0'; - port = strtoul(first_colon + 1, &E, 10); + strncpy(output_hostname, session, address_length); + output_hostname[address_length] = '\0'; + port = strtoul(first_colon + 1, &parse_end_ptr, 10); if (port == 0) { return 0; } } else { - strncpy(host_out, session, host_size - 1); - host_out[host_size - 1] = '\0'; + strncpy(output_hostname, session, hostname_buffer_size - 1); + output_hostname[hostname_buffer_size - 1] = '\0'; } } return port; @@ -188,37 +174,85 @@ namespace TbError bf_enet_join(const char *session, void *options) { - char buf[128] = {0}; ENetAddress connect_address; - enet_uint16 port = parse_session_address(session, buf, sizeof(buf)); - if (port == 0) { - return Lb_FAIL; - } - if (buf[0] == '\0') { - return Lb_FAIL; - } - if (enet_address_set_host(&connect_address, ENET_ADDRESS_TYPE_ANY, buf) < 0) { - return Lb_FAIL; - } - connect_address.port = port; - host = enet_host_create(connect_address.type, NULL, 4, NUM_CHANNELS, 0, 0); - if (!host) - { - return Lb_FAIL; + if (join_lobby_id[0] != '\0') { + LbNetLog("Join: connecting via UDP hole punching\n"); + host = enet_host_create(ENET_ADDRESS_TYPE_IPV4, NULL, 4, NUM_CHANNELS, 0, 0); + if (!host) { + LbNetLog("Join: failed to create ENet host\n"); + return Lb_FAIL; + } + char external_ip[MATCHMAKING_IP_MAX] = {0}; + uint16_t external_port = holepunch_stun_query(host, external_ip, sizeof(external_ip)); + char peer_ip[MATCHMAKING_IP_MAX] = {0}; + int peer_port = 0; + if (matchmaking_punch(join_lobby_id, (int)external_port, external_ip, peer_ip, &peer_port) != 0) { + LbNetLog("Join: matchmaking_punch failed\n"); + host_destroy(); + return Lb_FAIL; + } + join_lobby_id[0] = '\0'; + if (enet_address_set_host(&connect_address, ENET_ADDRESS_TYPE_IPV4, peer_ip) < 0) { + LbNetLog("Join: failed to resolve peer address %s\n", peer_ip); + host_destroy(); + return Lb_FAIL; + } + connect_address.port = (enet_uint16)peer_port; + } else { + char address_string[128] = {0}; + enet_uint16 port = parse_session_address(session, address_string, sizeof(address_string)); + if (port == 0 || address_string[0] == '\0') { + return Lb_FAIL; + } + ENetAddressType address_type = ENET_ADDRESS_TYPE_IPV4; + if (strchr(address_string, ':') != NULL) { + address_type = ENET_ADDRESS_TYPE_IPV6; + } + if (enet_address_set_host(&connect_address, address_type, address_string) < 0) { + return Lb_FAIL; + } + connect_address.port = port; + host = enet_host_create(address_type, NULL, 4, NUM_CHANNELS, 0, 0); + if (!host) { + return Lb_FAIL; + } } enet_host_compress_with_range_coder(host); + holepunch_punch_to(host, &connect_address); client_peer = enet_host_connect(host, &connect_address, NUM_CHANNELS, 0); - if (!client_peer) - { + if (!client_peer) { + LbNetLog("Join: enet_host_connect returned NULL\n"); host_destroy(); return Lb_FAIL; } - if (wait_for_connect(TIMEOUT_ENET_CONNECT)) { - host_destroy(); - return Lb_FAIL; + ENetEvent enet_event; + TbClockMSec connection_deadline = LbTimerClock() + TIMEOUT_ENET_CONNECT; + while (LbTimerClock() < connection_deadline) { + TbClockMSec time_remaining = connection_deadline - LbTimerClock(); + enet_uint32 service_wait_ms = HOLEPUNCH_CONNECT_DELAY_MS; + if (time_remaining < service_wait_ms) { + service_wait_ms = (enet_uint32)time_remaining; + } + int service_result = enet_host_service(host, &enet_event, service_wait_ms); + if (service_result > 0 && enet_event.type == ENET_EVENT_TYPE_CONNECT) { + LbNetLog("Join: connected successfully\n"); + enet_peer_timeout(client_peer, 0, PEER_TIMEOUT_MIN_MS, PEER_TIMEOUT_MAX_MS); + return Lb_OK; + } + if (service_result > 0) { + LbNetLog("Join: unexpected event type=%d\n", (int)enet_event.type); + } else if (service_result < 0) { + LbNetLog("Join: enet_host_service error %d\n", service_result); + ERRORLOG("Unable to connect: %d", service_result); + break; + } + holepunch_punch_to(host, &connect_address); + } } - return Lb_OK; + LbNetLog("Join: connection timed out or failed\n"); + host_destroy(); + return Lb_FAIL; } /* @@ -243,6 +277,8 @@ namespace switch (ev.type) { case ENET_EVENT_TYPE_CONNECT: + LbNetLog("ENet: incoming connection accepted\n"); + enet_peer_timeout(ev.peer, 0, PEER_TIMEOUT_MIN_MS, PEER_TIMEOUT_MAX_MS); if (new_user(&user_id)) { ev.peer->data = reinterpret_cast(user_id); @@ -703,6 +739,21 @@ unsigned int GetClientReliableCommandsInFlight() { return ClampSizeToUInt(best_value); } +void enet_matchmaking_host_update(void) +{ + if (!host) + return; + char peer_ip[MATCHMAKING_IP_MAX]; + int peer_port = 0; + if (!matchmaking_poll_punch(peer_ip, &peer_port)) + return; + ENetAddress peer_address; + if (enet_address_set_host(&peer_address, ENET_ADDRESS_TYPE_IPV4, peer_ip) != 0) + return; + peer_address.port = (enet_uint16)peer_port; + holepunch_punch_to(host, &peer_address); +} + struct NetSP *InitEnetSP() { static struct NetSP ret = diff --git a/src/bflib_enet.h b/src/bflib_enet.h index cf3599f318..35500b6450 100644 --- a/src/bflib_enet.h +++ b/src/bflib_enet.h @@ -15,10 +15,15 @@ #ifndef GIT_BFLIB_ENET_H #define GIT_BFLIB_ENET_H +#include + #ifdef __cplusplus extern "C" { #endif +#define ENET_DEFAULT_PORT 5556 +#define EXTERNAL_IP_LEN 64 + enum { ENET_CHANNEL_RELIABLE = 0, ENET_CHANNEL_UNSEQUENCED = 1 @@ -35,6 +40,9 @@ unsigned int GetClientPacketsLost(); unsigned int GetClientOutgoingDataTotal(); unsigned int GetClientIncomingDataTotal(); unsigned int GetClientReliableCommandsInFlight(); +void enet_matchmaking_host_update(void); +extern uint16_t external_port; +extern char external_ip[EXTERNAL_IP_LEN]; #ifdef __cplusplus } diff --git a/src/bflib_netsession.h b/src/bflib_netsession.h index 234ed00b31..4cef779d6e 100644 --- a/src/bflib_netsession.h +++ b/src/bflib_netsession.h @@ -27,8 +27,9 @@ extern "C" { /******************************************************************************/ #define NETSP_PLAYERS_COUNT 32 #define SESSION_ENTRIES_COUNT 32 -#define SESSION_NAME_MAX_LEN 128 -#define NETSP_PLAYER_NAME_MAX_LEN 32 +#define SESSION_NAME_MAX_LEN 128 +#define SESSION_LOBBY_ID_MAX_LEN 64 +#define NETSP_PLAYER_NAME_MAX_LEN 32 enum NetMsgType { @@ -44,10 +45,11 @@ enum NetMsgType }; struct TbNetworkSessionNameEntry { - unsigned char joinable; //possibly active or selected is better name + unsigned char joinable; unsigned long id; unsigned long in_use; char text[SESSION_NAME_MAX_LEN]; + char lobby_id[SESSION_LOBBY_ID_MAX_LEN]; }; struct TbNetworkPlayerEntry { diff --git a/src/bflib_network.cpp b/src/bflib_network.cpp index 4d1b2f5f9e..23530c2ace 100644 --- a/src/bflib_network.cpp +++ b/src/bflib_network.cpp @@ -31,7 +31,11 @@ #include "front_landview.h" #include "front_network.h" #include "net_received_packets.h" +#include "net_matchmaking.h" +#include "bflib_enet.h" #include "keeperfx.hpp" +#include +#include #include "post_inc.h" #ifdef __cplusplus @@ -136,15 +140,27 @@ TbError LbNetwork_Create(char *nsname_str, char *plyr_name, uint32_t *plyr_num, ERRORLOG("No network SP selected"); return Lb_FAIL; } - const char *port = ":5555"; - char buf[16] = ""; + char default_port_buf[16]; + snprintf(default_port_buf, sizeof(default_port_buf), ":%u", (unsigned)ENET_DEFAULT_PORT); + const char *port = default_port_buf; + char port_string[16] = ""; if (ServerPort != 0) { - snprintf(buf, sizeof(buf), "%d", ServerPort); - port = buf; + snprintf(port_string, sizeof(port_string), "%d", ServerPort); + port = port_string; } if (netstate.sp->host(port, optns) == Lb_FAIL) { return Lb_FAIL; } + std::string host_name(plyr_name); + uint16_t resolved_port = external_port; + if (resolved_port == 0 && ServerPort > 0) + resolved_port = (uint16_t)ServerPort; + if (resolved_port == 0) + resolved_port = (uint16_t)ENET_DEFAULT_PORT; + std::thread([resolved_port, host_name]() { + if (matchmaking_connect() == 0) + matchmaking_create(host_name.c_str(), (int)resolved_port, external_ip); + }).detach(); netstate.my_id = SERVER_ID; snprintf(netstate.users[netstate.my_id].name, sizeof(netstate.users[netstate.my_id].name), "%s", plyr_name); netstate.users[netstate.my_id].progress = USER_LOGGEDIN; @@ -189,6 +205,7 @@ TbError LbNetwork_EnableNewPlayers(TbBool allow) { } TbError LbNetwork_Stop(void) { + matchmaking_disconnect(); if (netstate.sp) { netstate.sp->exit(); } diff --git a/src/bflib_network.h b/src/bflib_network.h index b62dc54e26..f293d39760 100644 --- a/src/bflib_network.h +++ b/src/bflib_network.h @@ -30,7 +30,7 @@ extern "C" { #define CLIENT_TABLE_LEN 32 -#define TIMEOUT_ENET_CONNECT 2000 +#define TIMEOUT_ENET_CONNECT 5000 #define TIMEOUT_JOIN_LOBBY 2000 #define TIMEOUT_LOBBY_EXCHANGE 3000 #define TIMEOUT_GAMEPLAY_MISSING_PACKET 8000 diff --git a/src/front_landview.c b/src/front_landview.c index d3997b9328..4313299267 100644 --- a/src/front_landview.c +++ b/src/front_landview.c @@ -55,6 +55,7 @@ #include "vidfade.h" #include "game_legacy.h" #include "front_input.h" +#include "net_game.h" #include "keeperfx.hpp" #include "post_inc.h" @@ -1743,6 +1744,12 @@ TbBool frontnetmap_update_players(struct NetMapPlayersState * nmps) struct ScreenPacket* nspck = &net_screen_packet[i]; if ((nspck->networkstatus_flags & 0x01) == 0) continue; + if (i != my_player_number && !network_player_active(i)) + { + LbNetwork_EnableNewPlayers(1); + frontend_set_state(FeSt_NET_START); + return false; + } if (nspck->param1 == LEVELNUMBER_ERROR) { if (fe_network_active) diff --git a/src/front_network.c b/src/front_network.c index 99298b83a2..bc14996759 100644 --- a/src/front_network.c +++ b/src/front_network.c @@ -43,6 +43,8 @@ #include "config_strings.h" #include "game_merge.h" #include "game_legacy.h" +#include "net_matchmaking.h" +#include "bflib_enet.h" #include "post_inc.h" #ifdef __cplusplus @@ -242,6 +244,9 @@ void frontnet_session_update(void) memset(net_session, 0, sizeof(net_session)); if ( LbNetwork_EnumerateSessions(enum_sessions_callback, 0) ) ERRORLOG("LbNetwork_EnumerateSessions() failed"); + matchmaking_refresh_sessions(); + for (int i = 0; i < matchmaking_session_count && net_number_of_sessions < SESSION_ENTRIES_COUNT; i++) + net_session[net_number_of_sessions++] = &matchmaking_sessions[i]; last_enum_sessions = LbTimerClock(); if (net_number_of_sessions == 0) @@ -416,6 +421,7 @@ void frontnet_start_update(void) frontnet_rewite_net_messages(); LbNetwork_UpdateInputLagIfHost(); + enet_matchmaking_host_update(); } void display_attempting_to_join_message(void) @@ -487,6 +493,7 @@ void frontnet_session_setup(void) fe_computer_players = 2; lbInkey = 0; net_session_index_active_id = -1; + matchmaking_connect_async(); } void frontnet_start_setup(void) diff --git a/src/net_game.c b/src/net_game.c index 5f2825c3d5..940133ba57 100644 --- a/src/net_game.c +++ b/src/net_game.c @@ -17,6 +17,7 @@ */ /******************************************************************************/ #include "pre_inc.h" +#include "net_matchmaking.h" #include "net_game.h" #include "globals.h" @@ -189,8 +190,10 @@ long network_session_join(void) { int32_t plyr_num; display_attempting_to_join_message(); + snprintf(join_lobby_id, sizeof(join_lobby_id), "%s", net_session[net_session_index_active]->lobby_id); if ( LbNetwork_Join(net_session[net_session_index_active], net_player_name, &plyr_num, NULL) ) { + join_lobby_id[0] = '\0'; process_network_error(-802); return -1; } diff --git a/src/net_holepunch.c b/src/net_holepunch.c new file mode 100644 index 0000000000..5e9b23da97 --- /dev/null +++ b/src/net_holepunch.c @@ -0,0 +1,167 @@ +/******************************************************************************/ +// Free implementation of Bullfrog's Dungeon Keeper strategy game. +/******************************************************************************/ +/** + * @file net_holepunch.cpp + * UDP hole punching via STUN. + * @par Purpose: + * holepunch_stun_query() sends a STUN Binding Request (RFC 5389) from the + * ENet host's own socket. This serves two purposes: + * 1. Creates a NAT table entry so incoming ENet connections reach us even + * when UPnP/NAT-PMP is unavailable. + * 2. Logs our external IP:port so the host can share it with others. + * + * holepunch_punch_to() sends a burst of small UDP datagrams to the server + * before enet_host_connect() is called. This opens a mapping in the + * client's cone NAT and primes port-restricted cone NATs on the server. + * @author KeeperFX Team + * @date 06 Mar 2026 + * @par Copying and copyrights: + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + */ +/******************************************************************************/ +#include "pre_inc.h" +#include "net_holepunch.h" +#include "bflib_basics.h" + +#include +#include +#include +#include +#include + +#include "post_inc.h" + +#define STUN_SERVER "stun.l.google.com" +#define STUN_PORT 19302 +#define STUN_TIMEOUT_MS 500 +#define STUN_MAGIC_COOKIE 0x2112A442U +#define STUN_BINDING_REQUEST 0x0001U +#define STUN_BINDING_SUCCESS 0x0101U +#define STUN_ATTRIBUTE_XOR_MAPPED 0x0020U +#define STUN_RESPONSE_BUFFER_SIZE 512 +#define HOLE_PUNCH_COUNT 5 +#define HOLE_PUNCH_PAYLOAD_SIZE 8 + +#pragma pack(push, 1) +struct StunHeader { + uint16_t type; + uint16_t length; + uint32_t magic; + uint8_t transaction_id[12]; +}; + +struct StunAttrHeader { + uint16_t type; + uint16_t length; +}; +#pragma pack(pop) + +uint16_t holepunch_stun_query(ENetHost *host, char *output_ip, size_t output_ip_buffer_size) +{ + ENetAddress stun_server_address; + if (enet_address_set_host(&stun_server_address, ENET_ADDRESS_TYPE_IPV4, STUN_SERVER) < 0) { + LbNetLog("STUN: failed to resolve %s\n", STUN_SERVER); + return 0; + } + stun_server_address.port = STUN_PORT; + + static unsigned s_transaction_counter = 0; + s_transaction_counter++; + struct StunHeader stun_request = {htons(STUN_BINDING_REQUEST), htons(0), htonl(STUN_MAGIC_COOKIE), {0}}; + memcpy(stun_request.transaction_id, &s_transaction_counter, sizeof(s_transaction_counter)); + ENetBuffer send_buffer = {.data = &stun_request, .dataLength = sizeof(stun_request)}; + ENetSocket send_socket = host->socket; + ENetSocket fallback_socket = ENET_SOCKET_NULL; + if (enet_socket_send(send_socket, &stun_server_address, &send_buffer, 1) < 0) { + LbNetLog("STUN: host socket send failed, falling back to fresh IPv4 socket\n"); + fallback_socket = enet_socket_create(ENET_ADDRESS_TYPE_IPV4, ENET_SOCKET_TYPE_DATAGRAM); + if (fallback_socket == ENET_SOCKET_NULL) { + LbNetLog("STUN: failed to create fallback IPv4 socket\n"); + return 0; + } + if (enet_socket_send(fallback_socket, &stun_server_address, &send_buffer, 1) < 0) { + LbNetLog("STUN: fallback IPv4 socket send failed\n"); + enet_socket_destroy(fallback_socket); + return 0; + } + send_socket = fallback_socket; + } + + Uint32 timeout_deadline = SDL_GetTicks() + STUN_TIMEOUT_MS; + uint16_t external_port_result = 0; + for (;;) { + Uint32 now = SDL_GetTicks(); + if (now >= timeout_deadline) + break; + enet_uint32 socket_wait_flags = ENET_SOCKET_WAIT_RECEIVE; + if (enet_socket_wait(send_socket, &socket_wait_flags, timeout_deadline - now) < 0 + || !(socket_wait_flags & ENET_SOCKET_WAIT_RECEIVE)) + break; + uint8_t response_buffer[STUN_RESPONSE_BUFFER_SIZE]; + ENetAddress sender_address; + ENetBuffer receive_buffer = {.data = response_buffer, .dataLength = sizeof(response_buffer)}; + int bytes_received = enet_socket_receive(send_socket, &sender_address, &receive_buffer, 1); + if (bytes_received <= 0) + continue; + if (bytes_received < (int)sizeof(struct StunHeader)) + break; + const struct StunHeader *stun_response_header = (const struct StunHeader *)response_buffer; + if (ntohs(stun_response_header->type) != STUN_BINDING_SUCCESS + || ntohl(stun_response_header->magic) != STUN_MAGIC_COOKIE + || memcmp(stun_response_header->transaction_id, stun_request.transaction_id, sizeof(stun_response_header->transaction_id)) != 0) + continue; + int attribute_offset = (int)sizeof(struct StunHeader); + int attributes_end = attribute_offset + (int)ntohs(stun_response_header->length); + if (attributes_end > bytes_received) attributes_end = bytes_received; + char mapped_ip[64] = {0}; + uint16_t external_port = 0; + while (attribute_offset + 4 <= attributes_end) { + const struct StunAttrHeader *stun_attribute = (const struct StunAttrHeader *)(response_buffer + attribute_offset); + uint16_t attribute_type = ntohs(stun_attribute->type); + uint16_t attribute_length = ntohs(stun_attribute->length); + attribute_offset += 4; + if (attribute_type == STUN_ATTRIBUTE_XOR_MAPPED && attribute_length >= 8 + && attribute_offset + attribute_length <= attributes_end && response_buffer[attribute_offset + 1] == 0x01) { + uint16_t xor_encoded_port = ((uint16_t)response_buffer[attribute_offset + 2] << 8) | response_buffer[attribute_offset + 3]; + external_port = xor_encoded_port ^ (uint16_t)(STUN_MAGIC_COOKIE >> 16); + uint32_t xor_encoded_address; + memcpy(&xor_encoded_address, response_buffer + attribute_offset + 4, 4); + uint32_t decoded_address = ntohl(xor_encoded_address) ^ STUN_MAGIC_COOKIE; + snprintf(mapped_ip, sizeof(mapped_ip), "%u.%u.%u.%u", + (decoded_address >> 24) & 0xFFu, (decoded_address >> 16) & 0xFFu, + (decoded_address >> 8) & 0xFFu, decoded_address & 0xFFu); + break; + } + attribute_offset += (attribute_length + 3) & ~3; + } + if (!external_port) + continue; + if (fallback_socket != ENET_SOCKET_NULL) + external_port = host->address.port; + LbNetLog("STUN: external address %s:%u\n", mapped_ip, (unsigned)external_port); + if (output_ip && output_ip_buffer_size > 0) + snprintf(output_ip, output_ip_buffer_size, "%s", mapped_ip); + external_port_result = external_port; + break; + } + if (!external_port_result) + LbNetLog("STUN: failed to obtain mapped address\n"); + if (fallback_socket != ENET_SOCKET_NULL) + enet_socket_destroy(fallback_socket); + return external_port_result; +} + +void holepunch_punch_to(ENetHost *host, const ENetAddress *target) +{ + static const uint8_t punch_payload[HOLE_PUNCH_PAYLOAD_SIZE] = {0}; + ENetBuffer send_buffer = {.data = (void *)punch_payload, .dataLength = sizeof(punch_payload)}; + int send_result = 0; + for (int i = 0; i < HOLE_PUNCH_COUNT; i++) + send_result = enet_socket_send(host->socket, target, &send_buffer, 1); + if (send_result < 0) + LbNetLog("Holepunch: send failed (result=%d)\n", send_result); +} diff --git a/src/net_holepunch.h b/src/net_holepunch.h new file mode 100644 index 0000000000..8b048113ec --- /dev/null +++ b/src/net_holepunch.h @@ -0,0 +1,40 @@ +/******************************************************************************/ +// Free implementation of Bullfrog's Dungeon Keeper strategy game. +/******************************************************************************/ +/** + * @file net_holepunch.h + * UDP hole punching via STUN. + * @par Purpose: + * Sends a STUN Binding Request from the ENet host socket to discover the + * external address and create a NAT mapping. Also provides a helper to + * send pre-connect punch packets when joining. + * @author KeeperFX Team + * @date 06 Mar 2026 + * @par Copying and copyrights: + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + */ +/******************************************************************************/ +#ifndef NET_HOLEPUNCH_H +#define NET_HOLEPUNCH_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +struct _ENetHost; +struct _ENetAddress; + +uint16_t holepunch_stun_query(struct _ENetHost *host, char *output_ip, size_t output_ip_buffer_size); +void holepunch_punch_to(struct _ENetHost *host, const struct _ENetAddress *target); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/net_matchmaking.c b/src/net_matchmaking.c new file mode 100644 index 0000000000..ef37000bbc --- /dev/null +++ b/src/net_matchmaking.c @@ -0,0 +1,360 @@ +/******************************************************************************/ +// Free implementation of Bullfrog's Dungeon Keeper strategy game. +/******************************************************************************/ +/** + * @file net_matchmaking.c + * Matchmaking client for the KeeperFX lobby server. + * @par Purpose: + * Manages a WebSocket connection to the matchmaking server. + * Hosts register their lobby; clients list and join via hole-punch relay. + * @author KeeperFX Team + * @date 06 Mar 2026 + * @par Copying and copyrights: + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + */ +/******************************************************************************/ +#include "pre_inc.h" +#include "net_matchmaking.h" +#include "bflib_basics.h" +#include "ver_defs.h" + +#include +#ifndef _WIN32 +#include +#endif +#include +#include +#include +#include +#include +#include "post_inc.h" + +#define STR_(x) #x +#define STR(x) STR_(x) +#define MATCHMAKING_VERSION STR(VER_MAJOR) "." STR(VER_MINOR) "." STR(VER_RELEASE) + +#define WEBSOCKET_BUFFER_SIZE 8192 +#define WEBSOCKET_RECEIVE_TIMEOUT_MS 3000 +#define SEND_BUFFER_SIZE 512 +#define IP_JSON_FIELD_SIZE 80 +#define CONNECT_TIMEOUT_MS 5000 + +static CURL *curl_handle = NULL; +static char hosted_lobby_id[MATCHMAKING_ID_MAX] = {0}; +static Uint32 last_refresh_tick = 0; +char join_lobby_id[MATCHMAKING_ID_MAX] = {0}; +static SDL_mutex *mutex = NULL; + +struct TbNetworkSessionNameEntry matchmaking_sessions[MATCHMAKING_SESSIONS_MAX]; +int matchmaking_session_count = 0; + +static void websocket_cleanup(void) +{ + LbNetLog("Matchmaking: websocket_cleanup\n"); + curl_easy_cleanup(curl_handle); + curl_handle = NULL; + hosted_lobby_id[0] = '\0'; + matchmaking_session_count = 0; + last_refresh_tick = 0; +} + +static int websocket_send(const char *request) +{ + size_t bytes_sent = 0; + CURLcode curl_result = curl_ws_send(curl_handle, request, strlen(request), &bytes_sent, 0, CURLWS_TEXT); + if (curl_result != CURLE_OK) { + LbNetLog("Matchmaking: websocket_send failed (%s)\n", curl_easy_strerror(curl_result)); + websocket_cleanup(); + return -1; + } + return 0; +} + +static int websocket_receive(char *response_buffer, size_t buffer_size, int timeout_ms) +{ + curl_socket_t raw_socket = CURL_SOCKET_BAD; + if (curl_easy_getinfo(curl_handle, CURLINFO_ACTIVESOCKET, &raw_socket) != CURLE_OK || raw_socket == CURL_SOCKET_BAD) { + LbNetLog("Matchmaking: websocket_receive failed to get active socket\n"); + return -1; + } + + fd_set readable_sockets; + FD_ZERO(&readable_sockets); + FD_SET(raw_socket, &readable_sockets); + struct timeval timeout_value = { timeout_ms / 1000, (timeout_ms % 1000) * 1000 }; + if (select((int)raw_socket + 1, &readable_sockets, NULL, NULL, &timeout_value) <= 0) + return 0; + + size_t bytes_received = 0; + const struct curl_ws_frame *websocket_frame = NULL; + CURLcode curl_result = curl_ws_recv(curl_handle, response_buffer, buffer_size - 1, &bytes_received, &websocket_frame); + if (curl_result == CURLE_AGAIN) + return 0; + if (curl_result != CURLE_OK) { + LbNetLog("Matchmaking: websocket_receive failed (%s)\n", curl_easy_strerror(curl_result)); + websocket_cleanup(); + return -1; + } + response_buffer[bytes_received] = '\0'; + return (int)bytes_received; +} + +static int websocket_exchange(const char *request, char *response_buffer, size_t buffer_size) +{ + if (!curl_handle) return -1; + if (websocket_send(request) != 0) return -1; + return websocket_receive(response_buffer, buffer_size, WEBSOCKET_RECEIVE_TIMEOUT_MS); +} + +static const char *json_parse_string(const char *json, const char *key, char *output, size_t output_buffer_size) +{ + char key_pattern[128]; + snprintf(key_pattern, sizeof(key_pattern), "\"%s\":\"", key); + const char *json_cursor = strstr(json, key_pattern); + if (!json_cursor) + return NULL; + json_cursor += strlen(key_pattern); + size_t output_length = 0; + while (*json_cursor && *json_cursor != '"' && output_length < output_buffer_size - 1) + output[output_length++] = *json_cursor++; + output[output_length] = '\0'; + if (*json_cursor != '"') + return NULL; + return json_cursor + 1; +} + +static int json_parse_int(const char *json, const char *key, int *output) +{ + char key_pattern[128]; + snprintf(key_pattern, sizeof(key_pattern), "\"%s\":", key); + const char *json_cursor = strstr(json, key_pattern); + if (!json_cursor) + return 0; + json_cursor += strlen(key_pattern); + *output = atoi(json_cursor); + return 1; +} + +void matchmaking_init(void) +{ + static int s_initialized = 0; + if (s_initialized) + return; + s_initialized = 1; + mutex = SDL_CreateMutex(); + curl_global_init(CURL_GLOBAL_DEFAULT); +} + +static int matchmaking_connect_thread(void *arg) +{ + matchmaking_connect(); + return 0; +} + +void matchmaking_connect_async(void) +{ + matchmaking_init(); + SDL_Thread *thread = SDL_CreateThread(matchmaking_connect_thread, "matchmaking", NULL); + if (thread) + SDL_DetachThread(thread); +} + +int matchmaking_connect(void) +{ + SDL_LockMutex(mutex); + if (curl_handle) { + SDL_UnlockMutex(mutex); + return 0; + } + curl_handle = curl_easy_init(); + if (!curl_handle) { + SDL_UnlockMutex(mutex); + return -1; + } + LbNetLog("Matchmaking: connecting to %s\n", MATCHMAKING_URL); + curl_easy_setopt(curl_handle, CURLOPT_URL, MATCHMAKING_URL); + curl_easy_setopt(curl_handle, CURLOPT_CONNECT_ONLY, 2L); + curl_easy_setopt(curl_handle, CURLOPT_CONNECTTIMEOUT_MS, (long)CONNECT_TIMEOUT_MS); + CURLcode curl_result = curl_easy_perform(curl_handle); + if (curl_result != CURLE_OK) { + LbNetLog("Matchmaking: connect to %s failed: %s\n", MATCHMAKING_URL, curl_easy_strerror(curl_result)); + websocket_cleanup(); + SDL_UnlockMutex(mutex); + return -1; + } + LbNetLog("Matchmaking: connected\n"); + SDL_UnlockMutex(mutex); + return 0; +} + +void matchmaking_disconnect(void) +{ + SDL_LockMutex(mutex); + if (curl_handle) { + if (hosted_lobby_id[0] != '\0') { + char delete_message[SEND_BUFFER_SIZE]; + snprintf(delete_message, sizeof(delete_message), "{\"action\":\"delete\",\"id\":\"%s\"}", hosted_lobby_id); + websocket_send(delete_message); + } + websocket_cleanup(); + LbNetLog("Matchmaking: disconnected\n"); + } + SDL_UnlockMutex(mutex); +} + +void matchmaking_refresh_sessions(void) +{ + SDL_LockMutex(mutex); + if (!curl_handle) { + SDL_UnlockMutex(mutex); + return; + } + char response_buffer[WEBSOCKET_BUFFER_SIZE]; + int bytes_received; + if (last_refresh_tick == 0) { + bytes_received = websocket_exchange("{\"action\":\"list\",\"version\":\"" MATCHMAKING_VERSION "\"}", response_buffer, sizeof(response_buffer)); + last_refresh_tick = SDL_GetTicks(); + } else { + bytes_received = websocket_receive(response_buffer, sizeof(response_buffer), 0); + } + if (bytes_received > 0) + LbNetLog("Matchmaking: list response (%d bytes): %s\n", bytes_received, response_buffer); + if (bytes_received > 0 && strstr(response_buffer, "\"lobbies\"")) { + int count = 0; + const char *json_cursor = response_buffer; + while (count < MATCHMAKING_SESSIONS_MAX) { + char id[MATCHMAKING_ID_MAX]; + char name[MATCHMAKING_NAME_MAX]; + json_cursor = json_parse_string(json_cursor, "id", id, sizeof(id)); + if (!json_cursor) break; + json_cursor = json_parse_string(json_cursor, "name", name, sizeof(name)); + if (!json_cursor) break; + struct TbNetworkSessionNameEntry *session = &matchmaking_sessions[count++]; + memset(session, 0, sizeof(*session)); + session->joinable = 1; + session->in_use = 1; + session->id = (unsigned long)count; + snprintf(session->text, SESSION_NAME_MAX_LEN, "%s", name); + snprintf(session->lobby_id, SESSION_LOBBY_ID_MAX_LEN, "%s", id); + } + matchmaking_session_count = count; + LbNetLog("Matchmaking: parsed %d session(s)\n", count); + } + SDL_UnlockMutex(mutex); +} + +int matchmaking_create(const char *name, int udp_port, const char *ip) +{ + char escaped_lobby_name[MATCHMAKING_NAME_MAX * 2]; + char ip_json_field[IP_JSON_FIELD_SIZE]; + char request_message[SEND_BUFFER_SIZE]; + char response_buffer[WEBSOCKET_BUFFER_SIZE]; + SDL_LockMutex(mutex); + if (!curl_handle) { + LbNetLog("Matchmaking: not connected to server, lobby won't be listed online\n"); + SDL_UnlockMutex(mutex); + return -1; + } + int write_pos = 0; + for (int i = 0; name[i] && write_pos < (int)sizeof(escaped_lobby_name) - 2; i++) { + if (name[i] == '"' || name[i] == '\\') + escaped_lobby_name[write_pos++] = '\\'; + escaped_lobby_name[write_pos++] = name[i]; + } + escaped_lobby_name[write_pos] = '\0'; + ip_json_field[0] = '\0'; + if (ip && ip[0]) + snprintf(ip_json_field, sizeof(ip_json_field), ",\"ip\":\"%s\"", ip); + snprintf(request_message, sizeof(request_message), + "{\"action\":\"create\",\"name\":\"%s\",\"port\":%d%s,\"version\":\"%s\"}", + escaped_lobby_name, udp_port, ip_json_field, MATCHMAKING_VERSION); + int bytes_received = websocket_exchange(request_message, response_buffer, sizeof(response_buffer)); + if (bytes_received > 0) + LbNetLog("Matchmaking: create response (%d bytes): %s\n", bytes_received, response_buffer); + if (bytes_received <= 0) { + SDL_UnlockMutex(mutex); + return -1; + } + if (!strstr(response_buffer, "\"created\"") || !json_parse_string(response_buffer, "id", hosted_lobby_id, MATCHMAKING_ID_MAX)) { + LbNetLog("Matchmaking: create failed - unexpected response\n"); + SDL_UnlockMutex(mutex); + return -1; + } + LbNetLog("Matchmaking: created lobby id=%s\n", hosted_lobby_id); + SDL_UnlockMutex(mutex); + return 0; +} + +int matchmaking_punch(const char *lobby_id, int udp_port, const char *udp_ip, char *output_ip, int *output_port) +{ + char request_message[SEND_BUFFER_SIZE]; + char response_buffer[WEBSOCKET_BUFFER_SIZE]; + SDL_LockMutex(mutex); + if (!curl_handle) { + LbNetLog("Matchmaking: not connected to server, UDP hole punching unavailable\n"); + SDL_UnlockMutex(mutex); + return -1; + } + char ip_json_field[IP_JSON_FIELD_SIZE] = ""; + if (udp_ip && udp_ip[0]) + snprintf(ip_json_field, sizeof(ip_json_field), ",\"udpIp\":\"%s\"", udp_ip); + snprintf(request_message, sizeof(request_message), + "{\"action\":\"punch\",\"lobbyId\":\"%s\",\"udpPort\":%d%s}", + lobby_id, udp_port, ip_json_field); + if (websocket_send(request_message) != 0) { + SDL_UnlockMutex(mutex); + return -1; + } + int bytes_received; + Uint32 timeout_deadline = SDL_GetTicks() + WEBSOCKET_RECEIVE_TIMEOUT_MS; + for (;;) { + int time_remaining = (int)(timeout_deadline - SDL_GetTicks()); + if (time_remaining <= 0) { + LbNetLog("Matchmaking: punch failed - timeout\n"); + SDL_UnlockMutex(mutex); + return -1; + } + bytes_received = websocket_receive(response_buffer, sizeof(response_buffer), time_remaining); + if (bytes_received > 0) + LbNetLog("Matchmaking: punch response (%d bytes): %s\n", bytes_received, response_buffer); + if (bytes_received <= 0) { + SDL_UnlockMutex(mutex); + return -1; + } + if (strstr(response_buffer, "\"lobbies\"")) + continue; + break; + } + if (!strstr(response_buffer, "\"punch\"") + || !json_parse_string(response_buffer, "peerIp", output_ip, MATCHMAKING_IP_MAX) + || !json_parse_int(response_buffer, "peerPort", output_port)) { + LbNetLog("Matchmaking: punch failed - unexpected response\n"); + SDL_UnlockMutex(mutex); + return -1; + } + LbNetLog("Matchmaking: punch relay -> %s:%d\n", output_ip, *output_port); + SDL_UnlockMutex(mutex); + return 0; +} + +int matchmaking_poll_punch(char *output_ip, int *output_port) +{ + if (!curl_handle || !mutex || SDL_TryLockMutex(mutex) != 0) + return 0; + char response_buffer[WEBSOCKET_BUFFER_SIZE]; + int bytes_received = websocket_receive(response_buffer, sizeof(response_buffer), 0); + int punch_was_received = 0; + if (bytes_received > 0 && strstr(response_buffer, "\"punch\"")) { + punch_was_received = json_parse_string(response_buffer, "peerIp", output_ip, MATCHMAKING_IP_MAX) + && json_parse_int(response_buffer, "peerPort", output_port); + if (punch_was_received) + LbNetLog("Matchmaking: poll_punch -> %s:%d\n", output_ip, *output_port); + else + LbNetLog("Matchmaking: poll_punch parse failed\n"); + } + SDL_UnlockMutex(mutex); + return punch_was_received; +} diff --git a/src/net_matchmaking.h b/src/net_matchmaking.h new file mode 100644 index 0000000000..0bf8cf5c3c --- /dev/null +++ b/src/net_matchmaking.h @@ -0,0 +1,52 @@ +/******************************************************************************/ +// Free implementation of Bullfrog's Dungeon Keeper strategy game. +/******************************************************************************/ +/** + * @file net_matchmaking.h + * Matchmaking client for the KeeperFX lobby server. + * @par Purpose: + * Connects to the WebSocket-based matchmaking server to list, host, and + * join game sessions. Provides the hole-punch relay flow required for + * NAT traversal when direct port-forwarding is unavailable. + * @author KeeperFX Team + * @date 06 Mar 2026 + * @par Copying and copyrights: + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + */ +/******************************************************************************/ +#ifndef NET_MATCHMAKING_H +#define NET_MATCHMAKING_H + +#include "bflib_netsession.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define MATCHMAKING_URL "wss://matchmaking.keeperfx.workers.dev/ws" +#define MATCHMAKING_ID_MAX 64 +#define MATCHMAKING_IP_MAX 64 +#define MATCHMAKING_NAME_MAX SESSION_NAME_MAX_LEN +#define MATCHMAKING_SESSIONS_MAX 32 + +extern struct TbNetworkSessionNameEntry matchmaking_sessions[MATCHMAKING_SESSIONS_MAX]; +extern int matchmaking_session_count; +extern char join_lobby_id[MATCHMAKING_ID_MAX]; + +void matchmaking_init(void); +void matchmaking_connect_async(void); +int matchmaking_connect(void); +void matchmaking_disconnect(void); +void matchmaking_refresh_sessions(void); +int matchmaking_create(const char *name, int udp_port, const char *ip); +int matchmaking_punch(const char *lobby_id, int udp_port, const char *udp_ip, char *output_ip, int *output_port); +int matchmaking_poll_punch(char *output_ip, int *output_port); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/net_portforward.cpp b/src/net_portforward.cpp index 556628a86b..68ff16ed42 100644 --- a/src/net_portforward.cpp +++ b/src/net_portforward.cpp @@ -60,6 +60,9 @@ enum PortForwardMethod { static enum PortForwardMethod active_method = PORT_FORWARD_NONE; static uint16_t mapped_port = 0; +int upnp_enabled = 1; +int natpmp_enabled = 1; + static struct UPNPUrls upnp_urls; static struct IGDdatas upnp_data; static char upnp_lanaddr[64]; @@ -186,7 +189,7 @@ static int natpmp_add_port_mapping(uint16_t port) { return 0; } } - LbNetLog("NAT-PMP: Mapped port %u\n", port); + LbNetLog("NAT-PMP: port forwarding active on port %u\n", port); mapped_port = port; active_method = PORT_FORWARD_NATPMP; closenatpmp(&natpmp); @@ -201,13 +204,17 @@ static void port_forward_add_mapping_internal(uint16_t port) { if (active_method != PORT_FORWARD_NONE) { port_forward_remove_mapping(); } - if (natpmp_add_port_mapping(port)) { + if (natpmp_enabled && natpmp_add_port_mapping(port)) { + return; + } + if (!upnp_enabled) { + LbNetLog("Port forwarding unavailable (NAT-PMP failed or disabled, UPnP disabled), UDP hole punching will be used\n"); return; } int error = 0; struct UPNPDev *device_list = upnpDiscover(UPNP_TIMEOUT_MS, NULL, NULL, 0, 0, 2, &error); if (!device_list) { - LbNetLog("UPnP: No devices found\n"); + LbNetLog("UPnP: no devices found, port forwarding unavailable, UDP hole punching will be used\n"); return; } #if (MINIUPNPC_API_VERSION >= 18) @@ -217,24 +224,25 @@ static void port_forward_add_mapping_internal(uint16_t port) { #endif freeUPNPDevlist(device_list); if (internet_gateway_device_result == 0) { - LbNetLog("UPnP: Failed to get valid IGD\n"); + LbNetLog("UPnP: router found but no valid IGD, port forwarding unavailable, UDP hole punching will be used\n"); FreeUPNPUrls(&upnp_urls); return; } char port_string[16]; snprintf(port_string, sizeof(port_string), "%u", port); UPNP_DeletePortMapping(upnp_urls.controlURL, upnp_data.first.servicetype, port_string, "UDP", ""); + LbNetLog("UPnP: lanaddr=%s\n", upnp_lanaddr); int result = UPNP_AddPortMapping(upnp_urls.controlURL, upnp_data.first.servicetype, port_string, port_string, upnp_lanaddr, "KeeperFX", "UDP", "", "0"); if (result != UPNPCOMMAND_SUCCESS) { - LbNetLog("UPnP: Permanent lease rejected, trying timed lease\n"); + LbNetLog("UPnP: permanent lease rejected (error %d), trying timed lease\n", result); result = UPNP_AddPortMapping(upnp_urls.controlURL, upnp_data.first.servicetype, port_string, port_string, upnp_lanaddr, "KeeperFX", "UDP", "", "3600"); if (result != UPNPCOMMAND_SUCCESS) { - LbNetLog("UPnP: Failed to add port mapping\n"); + LbNetLog("UPnP: failed to add port mapping (error %d), UDP hole punching will be used\n", result); FreeUPNPUrls(&upnp_urls); return; } } - LbNetLog("UPnP: Mapped port %u\n", port); + LbNetLog("UPnP: port forwarding active on port %u\n", port); mapped_port = port; active_method = PORT_FORWARD_UPNP; } diff --git a/src/net_portforward.h b/src/net_portforward.h index cd6115894d..87453e0062 100644 --- a/src/net_portforward.h +++ b/src/net_portforward.h @@ -25,6 +25,9 @@ extern "C" { #endif +extern int upnp_enabled; +extern int natpmp_enabled; + int port_forward_add_mapping(uint16_t port); void port_forward_remove_mapping(void);