Add LAN discovery and Local servers UI
Adds LAN server discovery and a Local tab in the multiplayer UI so clients can find relays on the same LAN. Introduces a new F4T::LanDiscovery module (plugin/include/F4TLanDiscovery.h, plugin/src/F4TLanDiscovery.cpp) that probes LAN hosts via UDP discover (port 7778) and TCP welcome fallback (port 7777). Server-side support includes a UDP responder (server/lan_discovery.py) and integration in server_core.py to start/stop discovery. Plugin changes (F4TServerBrowserBridge/Data) add background scanning, dispatch results to the game thread, new local-server storage and events (scanLocalServers, localServersUpdated, localScanStarted/localScanFinished), and small join/recent handling updates. UI changes (ui/.../app.js and components) add localServers state, scanning UX, Recent persistence (localStorage), and join logic for local/direct entries. Docs updated (docs/protocol.md, docs/dev-log.md, server/README.md) and build script links iphlpapi. Also removes two unused exported sprite PNGs.
This commit is contained in:
@@ -0,0 +1,584 @@
|
||||
#include "pch.h"
|
||||
|
||||
#include "F4TLanDiscovery.h"
|
||||
|
||||
#include <WinSock2.h>
|
||||
#include <WS2tcpip.h>
|
||||
#include <iphlpapi.h>
|
||||
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <format>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr auto kProtocolName = "commonwealth-online";
|
||||
constexpr auto kDiscoverPort = 7778;
|
||||
constexpr auto kDefaultGamePort = 7777;
|
||||
constexpr auto kUdpListenDuration = 3500ms;
|
||||
constexpr auto kTcpBatchTimeout = 120ms;
|
||||
constexpr auto kTcpBatchSize = 32;
|
||||
constexpr auto kMaxHostsPerSubnet = 1024;
|
||||
|
||||
bool g_winsockInitialized = false;
|
||||
|
||||
struct LanSubnet
|
||||
{
|
||||
std::string localIp;
|
||||
std::uint32_t networkHostOrder{ 0 };
|
||||
std::uint32_t broadcastHostOrder{ 0 };
|
||||
};
|
||||
|
||||
struct TcpProbe
|
||||
{
|
||||
SOCKET socket{ INVALID_SOCKET };
|
||||
std::string host;
|
||||
std::uint16_t port{ kDefaultGamePort };
|
||||
};
|
||||
|
||||
bool EnsureWinsockInitialized()
|
||||
{
|
||||
if (g_winsockInitialized) {
|
||||
return true;
|
||||
}
|
||||
|
||||
WSADATA winsockData{};
|
||||
if (WSAStartup(MAKEWORD(2, 2), std::addressof(winsockData)) != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
g_winsockInitialized = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::optional<std::string> IPv4ToString(const IN_ADDR& a_addr)
|
||||
{
|
||||
std::array<char, INET_ADDRSTRLEN> buffer{};
|
||||
if (!InetNtopA(AF_INET, std::addressof(a_addr), buffer.data(), static_cast<DWORD>(buffer.size()))) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
return std::string{ buffer.data() };
|
||||
}
|
||||
|
||||
std::optional<std::uint32_t> IPv4ToHostOrder(std::string_view a_ipv4)
|
||||
{
|
||||
in_addr address{};
|
||||
if (inet_pton(AF_INET, std::string{ a_ipv4 }.c_str(), std::addressof(address)) != 1) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
return ntohl(address.S_un.S_addr);
|
||||
}
|
||||
|
||||
std::string HostOrderToIPv4(std::uint32_t a_host)
|
||||
{
|
||||
in_addr address{};
|
||||
address.S_un.S_addr = htonl(a_host);
|
||||
return IPv4ToString(address).value_or("0.0.0.0");
|
||||
}
|
||||
|
||||
std::vector<LanSubnet> GetLanSubnets()
|
||||
{
|
||||
std::vector<LanSubnet> subnets;
|
||||
std::unordered_set<std::uint32_t> seenNetworks;
|
||||
|
||||
ULONG bufferSize = 0;
|
||||
if (GetAdaptersAddresses(
|
||||
AF_INET,
|
||||
GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST | GAA_FLAG_SKIP_DNS_SERVER,
|
||||
nullptr,
|
||||
nullptr,
|
||||
std::addressof(bufferSize)) != ERROR_BUFFER_OVERFLOW) {
|
||||
return subnets;
|
||||
}
|
||||
|
||||
std::vector<std::byte> buffer(bufferSize);
|
||||
auto* addresses = reinterpret_cast<IP_ADAPTER_ADDRESSES*>(buffer.data());
|
||||
if (GetAdaptersAddresses(
|
||||
AF_INET,
|
||||
GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST | GAA_FLAG_SKIP_DNS_SERVER,
|
||||
nullptr,
|
||||
addresses,
|
||||
std::addressof(bufferSize)) != NO_ERROR) {
|
||||
return subnets;
|
||||
}
|
||||
|
||||
for (auto* adapter = addresses; adapter != nullptr; adapter = adapter->Next) {
|
||||
if (adapter->OperStatus != IfOperStatusUp) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (auto* unicast = adapter->FirstUnicastAddress; unicast != nullptr; unicast = unicast->Next) {
|
||||
if (unicast->Address.lpSockaddr == nullptr ||
|
||||
unicast->Address.lpSockaddr->sa_family != AF_INET) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const auto* sockaddr = reinterpret_cast<sockaddr_in*>(unicast->Address.lpSockaddr);
|
||||
const auto localIp = IPv4ToString(sockaddr->sin_addr);
|
||||
if (!localIp || localIp->starts_with("127.") || localIp->starts_with("169.254.")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
auto prefixLength = static_cast<int>(unicast->OnLinkPrefixLength);
|
||||
if (prefixLength <= 0 || prefixLength > 32) {
|
||||
prefixLength = 24;
|
||||
}
|
||||
|
||||
const auto ipHost = ntohl(sockaddr->sin_addr.S_un.S_addr);
|
||||
const auto mask = prefixLength == 32 ?
|
||||
0xFFFFFFFFu :
|
||||
(~0u << (32 - prefixLength));
|
||||
auto network = ipHost & mask;
|
||||
auto broadcast = network | ~mask;
|
||||
|
||||
if ((broadcast - network) > kMaxHostsPerSubnet) {
|
||||
network = ipHost & 0xFFFFFF00u;
|
||||
broadcast = network | 0xFFu;
|
||||
}
|
||||
|
||||
if (!seenNetworks.insert(network).second) {
|
||||
continue;
|
||||
}
|
||||
|
||||
subnets.push_back(LanSubnet{
|
||||
.localIp = *localIp,
|
||||
.networkHostOrder = network,
|
||||
.broadcastHostOrder = broadcast
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return subnets;
|
||||
}
|
||||
|
||||
std::vector<std::uint32_t> BuildHostAddresses(const LanSubnet& a_subnet)
|
||||
{
|
||||
std::vector<std::uint32_t> hosts;
|
||||
const auto selfHost = IPv4ToHostOrder(a_subnet.localIp).value_or(0);
|
||||
|
||||
if (a_subnet.broadcastHostOrder <= a_subnet.networkHostOrder + 1) {
|
||||
return hosts;
|
||||
}
|
||||
|
||||
hosts.reserve(static_cast<std::size_t>(a_subnet.broadcastHostOrder - a_subnet.networkHostOrder - 1));
|
||||
for (auto host = a_subnet.networkHostOrder + 1; host < a_subnet.broadcastHostOrder; ++host) {
|
||||
if (host == selfHost) {
|
||||
continue;
|
||||
}
|
||||
hosts.push_back(host);
|
||||
}
|
||||
|
||||
return hosts;
|
||||
}
|
||||
|
||||
bool SetSocketNonBlocking(SOCKET a_socket, bool a_nonBlocking)
|
||||
{
|
||||
u_long mode = a_nonBlocking ? 1 : 0;
|
||||
return ioctlsocket(a_socket, FIONBIO, std::addressof(mode)) == 0;
|
||||
}
|
||||
|
||||
bool SendDiscoverRequest(SOCKET a_socket, const sockaddr_in& a_target)
|
||||
{
|
||||
static constexpr auto kRequest =
|
||||
R"({"type":"discover","protocol":"commonwealth-online","version":1})";
|
||||
|
||||
const auto sent = sendto(
|
||||
a_socket,
|
||||
kRequest,
|
||||
static_cast<int>(std::strlen(kRequest)),
|
||||
0,
|
||||
reinterpret_cast<const sockaddr*>(std::addressof(a_target)),
|
||||
sizeof(a_target));
|
||||
|
||||
return sent > 0;
|
||||
}
|
||||
|
||||
bool MakeDiscoverTarget(sockaddr_in& a_target, std::string_view a_host)
|
||||
{
|
||||
a_target = {};
|
||||
a_target.sin_family = AF_INET;
|
||||
a_target.sin_port = htons(kDiscoverPort);
|
||||
return inet_pton(AF_INET, std::string{ a_host }.c_str(), std::addressof(a_target.sin_addr)) == 1;
|
||||
}
|
||||
|
||||
std::optional<F4T::LanDiscovery::DiscoveredServer> ParseDiscoverResponse(
|
||||
std::string_view a_json,
|
||||
std::string_view a_host,
|
||||
int a_pingMs)
|
||||
{
|
||||
auto json = nlohmann::json::parse(a_json, nullptr, false);
|
||||
if (json.is_discarded() || !json.is_object()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
if (json.value("type", "") != "discoverResponse") {
|
||||
return std::nullopt;
|
||||
}
|
||||
if (json.value("protocol", "") != kProtocolName) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
F4T::LanDiscovery::DiscoveredServer server;
|
||||
server.host = std::string{ a_host };
|
||||
server.port = static_cast<std::uint16_t>(json.value("port", kDefaultGamePort));
|
||||
server.name = json.value("name", "");
|
||||
server.players = json.value("players", 0);
|
||||
server.maxPlayers = json.value("maxPlayers", 16);
|
||||
server.ping = a_pingMs;
|
||||
return server;
|
||||
}
|
||||
|
||||
std::string MakeServerKey(std::string_view a_host, std::uint16_t a_port)
|
||||
{
|
||||
return std::format("{}:{}", a_host, a_port);
|
||||
}
|
||||
|
||||
bool TryReadWelcomePacket(SOCKET a_socket, std::string& a_line)
|
||||
{
|
||||
std::array<char, 256> chunk{};
|
||||
a_line.clear();
|
||||
|
||||
while (a_line.find('\n') == std::string::npos) {
|
||||
const auto received = recv(a_socket, chunk.data(), static_cast<int>(chunk.size()), 0);
|
||||
if (received <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
a_line.append(chunk.data(), static_cast<std::size_t>(received));
|
||||
if (a_line.size() > 4096) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool IsWelcomePacket(std::string_view a_line)
|
||||
{
|
||||
const auto end = a_line.find('\n');
|
||||
const auto payload = a_line.substr(0, end);
|
||||
auto json = nlohmann::json::parse(payload, nullptr, false);
|
||||
return !json.is_discarded() && json.is_object() && json.value("type", "") == "welcome";
|
||||
}
|
||||
|
||||
void CloseTcpProbe(TcpProbe& a_probe)
|
||||
{
|
||||
if (a_probe.socket != INVALID_SOCKET) {
|
||||
closesocket(a_probe.socket);
|
||||
a_probe.socket = INVALID_SOCKET;
|
||||
}
|
||||
}
|
||||
|
||||
void CollectUdpResponses(
|
||||
SOCKET a_socket,
|
||||
std::chrono::steady_clock::time_point a_sendStartedAt,
|
||||
std::chrono::steady_clock::time_point a_deadline,
|
||||
std::unordered_map<std::string, F4T::LanDiscovery::DiscoveredServer>& a_discovered)
|
||||
{
|
||||
std::array<char, 2048> receiveBuffer{};
|
||||
|
||||
while (std::chrono::steady_clock::now() < a_deadline) {
|
||||
fd_set readSet{};
|
||||
FD_ZERO(std::addressof(readSet));
|
||||
FD_SET(a_socket, std::addressof(readSet));
|
||||
|
||||
const auto remaining = std::chrono::duration_cast<std::chrono::microseconds>(
|
||||
a_deadline - std::chrono::steady_clock::now());
|
||||
if (remaining.count() <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
timeval timeout{};
|
||||
timeout.tv_sec = static_cast<long>(remaining.count() / 1'000'000);
|
||||
timeout.tv_usec = static_cast<long>(remaining.count() % 1'000'000);
|
||||
|
||||
const auto selectResult = select(0, std::addressof(readSet), nullptr, nullptr, std::addressof(timeout));
|
||||
if (selectResult <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
sockaddr_in senderAddress{};
|
||||
int senderAddressSize = sizeof(senderAddress);
|
||||
const auto received = recvfrom(
|
||||
a_socket,
|
||||
receiveBuffer.data(),
|
||||
static_cast<int>(receiveBuffer.size()),
|
||||
0,
|
||||
reinterpret_cast<sockaddr*>(std::addressof(senderAddress)),
|
||||
std::addressof(senderAddressSize));
|
||||
|
||||
if (received <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const auto senderHost = IPv4ToString(senderAddress.sin_addr);
|
||||
if (!senderHost) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const auto pingMs = static_cast<int>(std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now() - a_sendStartedAt).count());
|
||||
|
||||
const auto response = ParseDiscoverResponse(
|
||||
std::string_view{ receiveBuffer.data(), static_cast<std::size_t>(received) },
|
||||
*senderHost,
|
||||
pingMs);
|
||||
if (!response) {
|
||||
continue;
|
||||
}
|
||||
|
||||
a_discovered.emplace(MakeServerKey(response->host, response->port), *response);
|
||||
}
|
||||
}
|
||||
|
||||
void ScanTcpHosts(
|
||||
const std::vector<std::uint32_t>& a_hosts,
|
||||
std::unordered_map<std::string, F4T::LanDiscovery::DiscoveredServer>& a_discovered)
|
||||
{
|
||||
for (std::size_t offset = 0; offset < a_hosts.size(); offset += kTcpBatchSize) {
|
||||
std::vector<TcpProbe> probes;
|
||||
const auto batchEnd = offset + kTcpBatchSize < a_hosts.size() ? offset + kTcpBatchSize : a_hosts.size();
|
||||
|
||||
for (std::size_t index = offset; index < batchEnd; ++index) {
|
||||
const auto host = HostOrderToIPv4(a_hosts[index]);
|
||||
const auto key = MakeServerKey(host, kDefaultGamePort);
|
||||
if (a_discovered.contains(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const auto probeSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
|
||||
if (probeSocket == INVALID_SOCKET) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!SetSocketNonBlocking(probeSocket, true)) {
|
||||
closesocket(probeSocket);
|
||||
continue;
|
||||
}
|
||||
|
||||
sockaddr_in serverAddress{};
|
||||
serverAddress.sin_family = AF_INET;
|
||||
serverAddress.sin_port = htons(kDefaultGamePort);
|
||||
if (inet_pton(AF_INET, host.c_str(), std::addressof(serverAddress.sin_addr)) != 1) {
|
||||
closesocket(probeSocket);
|
||||
continue;
|
||||
}
|
||||
|
||||
const auto connectResult = connect(
|
||||
probeSocket,
|
||||
reinterpret_cast<const sockaddr*>(std::addressof(serverAddress)),
|
||||
sizeof(serverAddress));
|
||||
if (connectResult != 0 && WSAGetLastError() != WSAEWOULDBLOCK) {
|
||||
closesocket(probeSocket);
|
||||
continue;
|
||||
}
|
||||
|
||||
probes.push_back(TcpProbe{ probeSocket, host, kDefaultGamePort });
|
||||
}
|
||||
|
||||
const auto batchDeadline = std::chrono::steady_clock::now() + kTcpBatchTimeout;
|
||||
while (!probes.empty() && std::chrono::steady_clock::now() < batchDeadline) {
|
||||
fd_set writeSet{};
|
||||
fd_set exceptSet{};
|
||||
FD_ZERO(std::addressof(writeSet));
|
||||
FD_ZERO(std::addressof(exceptSet));
|
||||
|
||||
SOCKET maxSocket = 0;
|
||||
for (const auto& probe : probes) {
|
||||
FD_SET(probe.socket, std::addressof(writeSet));
|
||||
FD_SET(probe.socket, std::addressof(exceptSet));
|
||||
if (probe.socket > maxSocket) {
|
||||
maxSocket = probe.socket;
|
||||
}
|
||||
}
|
||||
|
||||
const auto remaining = std::chrono::duration_cast<std::chrono::microseconds>(
|
||||
batchDeadline - std::chrono::steady_clock::now());
|
||||
timeval timeout{};
|
||||
timeout.tv_sec = static_cast<long>(remaining.count() / 1'000'000);
|
||||
timeout.tv_usec = static_cast<long>(remaining.count() % 1'000'000);
|
||||
|
||||
const auto selectResult = select(
|
||||
static_cast<int>(maxSocket + 1),
|
||||
nullptr,
|
||||
std::addressof(writeSet),
|
||||
std::addressof(exceptSet),
|
||||
std::addressof(timeout));
|
||||
|
||||
if (selectResult <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (auto probeIt = probes.begin(); probeIt != probes.end();) {
|
||||
auto& probe = *probeIt;
|
||||
const bool writable = FD_ISSET(probe.socket, std::addressof(writeSet)) != 0;
|
||||
const bool failed = FD_ISSET(probe.socket, std::addressof(exceptSet)) != 0;
|
||||
|
||||
if (!writable && !failed) {
|
||||
++probeIt;
|
||||
continue;
|
||||
}
|
||||
|
||||
bool accepted = false;
|
||||
if (writable && !failed) {
|
||||
int socketError = 0;
|
||||
int socketErrorSize = sizeof(socketError);
|
||||
if (getsockopt(
|
||||
probe.socket,
|
||||
SOL_SOCKET,
|
||||
SO_ERROR,
|
||||
reinterpret_cast<char*>(std::addressof(socketError)),
|
||||
std::addressof(socketErrorSize)) == 0 &&
|
||||
socketError == 0) {
|
||||
const auto probeStartedAt = std::chrono::steady_clock::now();
|
||||
SetSocketNonBlocking(probe.socket, false);
|
||||
|
||||
DWORD recvTimeoutMs = static_cast<DWORD>(kTcpBatchTimeout.count());
|
||||
setsockopt(
|
||||
probe.socket,
|
||||
SOL_SOCKET,
|
||||
SO_RCVTIMEO,
|
||||
reinterpret_cast<const char*>(std::addressof(recvTimeoutMs)),
|
||||
sizeof(recvTimeoutMs));
|
||||
|
||||
std::string line;
|
||||
if (TryReadWelcomePacket(probe.socket, line) && IsWelcomePacket(line)) {
|
||||
F4T::LanDiscovery::DiscoveredServer server;
|
||||
server.host = probe.host;
|
||||
server.port = probe.port;
|
||||
server.ping = static_cast<int>(std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now() - probeStartedAt).count());
|
||||
a_discovered.emplace(MakeServerKey(server.host, server.port), std::move(server));
|
||||
accepted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CloseTcpProbe(probe);
|
||||
probeIt = probes.erase(probeIt);
|
||||
if (accepted) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (auto& probe : probes) {
|
||||
CloseTcpProbe(probe);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace F4T::LanDiscovery
|
||||
{
|
||||
std::vector<DiscoveredServer> Scan(std::string& a_error)
|
||||
{
|
||||
a_error.clear();
|
||||
|
||||
if (!EnsureWinsockInitialized()) {
|
||||
a_error = "Could not initialize Winsock for LAN discovery.";
|
||||
return {};
|
||||
}
|
||||
|
||||
const auto discoverSocket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
|
||||
if (discoverSocket == INVALID_SOCKET) {
|
||||
a_error = "Could not create UDP socket for LAN discovery.";
|
||||
return {};
|
||||
}
|
||||
|
||||
const BOOL broadcastEnabled = TRUE;
|
||||
if (setsockopt(
|
||||
discoverSocket,
|
||||
SOL_SOCKET,
|
||||
SO_BROADCAST,
|
||||
reinterpret_cast<const char*>(std::addressof(broadcastEnabled)),
|
||||
sizeof(broadcastEnabled)) != 0) {
|
||||
closesocket(discoverSocket);
|
||||
a_error = "Could not enable LAN discovery broadcast.";
|
||||
return {};
|
||||
}
|
||||
|
||||
sockaddr_in bindAddress{};
|
||||
bindAddress.sin_family = AF_INET;
|
||||
bindAddress.sin_addr.S_un.S_addr = htonl(INADDR_ANY);
|
||||
bindAddress.sin_port = 0;
|
||||
if (bind(discoverSocket, reinterpret_cast<const sockaddr*>(std::addressof(bindAddress)), sizeof(bindAddress)) != 0) {
|
||||
closesocket(discoverSocket);
|
||||
a_error = "Could not bind LAN discovery socket.";
|
||||
return {};
|
||||
}
|
||||
|
||||
if (!SetSocketNonBlocking(discoverSocket, true)) {
|
||||
closesocket(discoverSocket);
|
||||
a_error = "Could not configure LAN discovery socket.";
|
||||
return {};
|
||||
}
|
||||
|
||||
const auto subnets = GetLanSubnets();
|
||||
std::vector<std::uint32_t> tcpHosts;
|
||||
std::unordered_set<std::uint32_t> tcpHostSet;
|
||||
|
||||
sockaddr_in target{};
|
||||
const auto sendStartedAt = std::chrono::steady_clock::now();
|
||||
|
||||
if (MakeDiscoverTarget(target, "127.0.0.1")) {
|
||||
SendDiscoverRequest(discoverSocket, target);
|
||||
}
|
||||
if (MakeDiscoverTarget(target, "255.255.255.255")) {
|
||||
SendDiscoverRequest(discoverSocket, target);
|
||||
}
|
||||
|
||||
for (const auto& subnet : subnets) {
|
||||
const auto broadcastIp = HostOrderToIPv4(subnet.broadcastHostOrder);
|
||||
if (MakeDiscoverTarget(target, broadcastIp)) {
|
||||
SendDiscoverRequest(discoverSocket, target);
|
||||
}
|
||||
|
||||
for (const auto host : BuildHostAddresses(subnet)) {
|
||||
const auto hostIp = HostOrderToIPv4(host);
|
||||
if (MakeDiscoverTarget(target, hostIp)) {
|
||||
SendDiscoverRequest(discoverSocket, target);
|
||||
}
|
||||
|
||||
if (tcpHostSet.insert(host).second) {
|
||||
tcpHosts.push_back(host);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::unordered_map<std::string, DiscoveredServer> discovered;
|
||||
const auto udpDeadline = sendStartedAt + kUdpListenDuration;
|
||||
CollectUdpResponses(discoverSocket, sendStartedAt, udpDeadline, discovered);
|
||||
closesocket(discoverSocket);
|
||||
|
||||
ScanTcpHosts(tcpHosts, discovered);
|
||||
|
||||
std::vector<DiscoveredServer> results;
|
||||
results.reserve(discovered.size());
|
||||
for (auto& [_, server] : discovered) {
|
||||
results.push_back(std::move(server));
|
||||
}
|
||||
|
||||
std::ranges::sort(results, [](const DiscoveredServer& a_lhs, const DiscoveredServer& a_rhs) {
|
||||
if (a_lhs.ping != a_rhs.ping) {
|
||||
return a_lhs.ping < a_rhs.ping;
|
||||
}
|
||||
|
||||
return a_lhs.host < a_rhs.host;
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
#include "pch.h"
|
||||
|
||||
#include "F4TNetworking.h"
|
||||
#include "F4TLanDiscovery.h"
|
||||
#include "F4TPrismaUI.h"
|
||||
#include "F4TServerBrowserBridge.h"
|
||||
#include "F4TServerBrowserData.h"
|
||||
#include "F4TSessionLaunch.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <charconv>
|
||||
#include <format>
|
||||
#include <string_view>
|
||||
#include <thread>
|
||||
|
||||
namespace F4T::ServerBrowser
|
||||
{
|
||||
@@ -18,6 +21,33 @@ namespace F4T::ServerBrowser
|
||||
PRISMA_UI_API::IVPrismaUI4* g_api = nullptr;
|
||||
PrismaView g_view = 0;
|
||||
ServerBrowserData g_data;
|
||||
std::atomic_bool g_localScanRunning{ false };
|
||||
|
||||
ServerEntry DiscoveredServerToEntry(const F4T::LanDiscovery::DiscoveredServer& a_discovered)
|
||||
{
|
||||
ServerEntry entry;
|
||||
entry.id = std::format("local:{}:{}", a_discovered.host, a_discovered.port);
|
||||
entry.name = a_discovered.name;
|
||||
entry.host = a_discovered.host;
|
||||
entry.port = static_cast<int>(a_discovered.port);
|
||||
entry.region = "Local";
|
||||
entry.worldspace = "Commonwealth";
|
||||
entry.mode = "Co-op";
|
||||
entry.players = a_discovered.players;
|
||||
entry.maxPlayers = a_discovered.maxPlayers;
|
||||
entry.ping = a_discovered.ping;
|
||||
entry.status = entry.players >= entry.maxPlayers ? "Full" : "Open";
|
||||
return entry;
|
||||
}
|
||||
|
||||
nlohmann::json LocalServersToJson()
|
||||
{
|
||||
nlohmann::json servers = nlohmann::json::array();
|
||||
for (const auto& entry : g_data.GetLocalServers()) {
|
||||
servers.push_back(entry.ToJson());
|
||||
}
|
||||
return servers;
|
||||
}
|
||||
|
||||
std::string GetLogPrefix()
|
||||
{
|
||||
@@ -61,6 +91,14 @@ namespace F4T::ServerBrowser
|
||||
});
|
||||
}
|
||||
|
||||
void PushLocalServersUpdated()
|
||||
{
|
||||
DispatchEvent(nlohmann::json{
|
||||
{ "type", "localServersUpdated" },
|
||||
{ "servers", LocalServersToJson() }
|
||||
});
|
||||
}
|
||||
|
||||
nlohmann::json ColorToJson(const RE::NiColor& a_color)
|
||||
{
|
||||
const auto toByte = [](float a_channel) {
|
||||
@@ -164,7 +202,8 @@ namespace F4T::ServerBrowser
|
||||
PushConnectionStatusEvent();
|
||||
DispatchEvent(nlohmann::json{
|
||||
{ "type", "joinSucceeded" },
|
||||
{ "serverId", a_entry.id }
|
||||
{ "serverId", a_entry.id },
|
||||
{ "server", a_entry.ToJson() }
|
||||
});
|
||||
DispatchEvent(nlohmann::json{
|
||||
{ "type", "uiSound" },
|
||||
@@ -261,6 +300,74 @@ namespace F4T::ServerBrowser
|
||||
|
||||
LogInfo(" Server list refreshed (mock data).");
|
||||
}
|
||||
|
||||
void CompleteLocalScan(std::vector<ServerEntry> a_servers, std::string a_error, bool a_success)
|
||||
{
|
||||
g_data.SetLocalServers(std::move(a_servers));
|
||||
PushLocalServersUpdated();
|
||||
|
||||
auto finishedEvent = nlohmann::json{
|
||||
{ "type", "localScanFinished" },
|
||||
{ "success", a_success }
|
||||
};
|
||||
if (!a_error.empty()) {
|
||||
finishedEvent["error"] = a_error;
|
||||
}
|
||||
DispatchEvent(std::move(finishedEvent));
|
||||
|
||||
if (a_success) {
|
||||
LogInfo(std::format(" LAN scan found {} local server(s).", g_data.GetLocalServers().size()));
|
||||
} else {
|
||||
LogWarning(std::format(" LAN scan found no servers. {}", a_error));
|
||||
}
|
||||
|
||||
g_localScanRunning.store(false);
|
||||
}
|
||||
|
||||
void ScanLocalServers()
|
||||
{
|
||||
if (g_localScanRunning.exchange(true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
DispatchEvent(nlohmann::json{ { "type", "localScanStarted" } });
|
||||
|
||||
std::thread([]() {
|
||||
std::string error;
|
||||
const auto discovered = F4T::LanDiscovery::Scan(error);
|
||||
|
||||
std::vector<ServerEntry> localServers;
|
||||
localServers.reserve(discovered.size());
|
||||
for (const auto& server : discovered) {
|
||||
localServers.push_back(DiscoveredServerToEntry(server));
|
||||
}
|
||||
|
||||
const bool success = !discovered.empty();
|
||||
if (!success && error.empty()) {
|
||||
error = "No Commonwealth Online servers responded on the LAN. "
|
||||
"Check that the host server is running and Windows Firewall allows UDP 7778 and TCP 7777.";
|
||||
}
|
||||
|
||||
const auto serversCopy = std::move(localServers);
|
||||
const auto errorCopy = std::move(error);
|
||||
|
||||
const auto* taskInterface = F4SE::GetTaskInterface();
|
||||
if (taskInterface) {
|
||||
taskInterface->AddTask([serversCopy, errorCopy, success]() {
|
||||
CompleteLocalScan(
|
||||
std::vector<ServerEntry>(serversCopy),
|
||||
errorCopy,
|
||||
success);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
CompleteLocalScan(
|
||||
std::vector<ServerEntry>(serversCopy),
|
||||
errorCopy,
|
||||
success);
|
||||
}).detach();
|
||||
}
|
||||
}
|
||||
|
||||
void ServerBrowserBridge::Initialize(PRISMA_UI_API::IVPrismaUI4* a_api, PrismaView a_view)
|
||||
@@ -294,6 +401,11 @@ namespace F4T::ServerBrowser
|
||||
return;
|
||||
}
|
||||
|
||||
if (action == "scanLocalServers") {
|
||||
ScanLocalServers();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action == "setFilter") {
|
||||
auto filters = g_data.GetFilters();
|
||||
const auto filterName = a_action.value("filterName", "");
|
||||
@@ -338,12 +450,18 @@ namespace F4T::ServerBrowser
|
||||
}
|
||||
|
||||
if (action == "switchTab") {
|
||||
if (a_action.value("tabName", "") == "local") {
|
||||
ScanLocalServers();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action == "joinServer") {
|
||||
const auto serverId = a_action.value("serverId", "");
|
||||
const auto* entry = g_data.FindServer(serverId);
|
||||
if (!entry) {
|
||||
entry = g_data.FindLocalServer(serverId);
|
||||
}
|
||||
if (!entry) {
|
||||
DispatchEvent(nlohmann::json{
|
||||
{ "type", "joinFailed" },
|
||||
@@ -362,17 +480,20 @@ namespace F4T::ServerBrowser
|
||||
|
||||
if (action == "openDirectConnect") {
|
||||
ServerEntry entry;
|
||||
entry.id = "direct";
|
||||
entry.name = "Direct Connect";
|
||||
entry.host = a_action.value("address", "127.0.0.1");
|
||||
entry.port = 7777;
|
||||
|
||||
std::string error;
|
||||
if (!PopulateJoinEntry(entry, a_action, error)) {
|
||||
entry.id = "direct";
|
||||
entry.name = "Direct Connect";
|
||||
CompleteJoinFailure(entry, error);
|
||||
return;
|
||||
}
|
||||
|
||||
entry.id = std::format("recent:{}:{}", entry.host, entry.port);
|
||||
entry.name = "";
|
||||
|
||||
if (!JoinServerEntry(entry, error)) {
|
||||
CompleteJoinFailure(entry, error);
|
||||
}
|
||||
|
||||
@@ -135,6 +135,11 @@ namespace F4T::ServerBrowser
|
||||
ApplyFilters();
|
||||
}
|
||||
|
||||
void ServerBrowserData::SetLocalServers(std::vector<ServerEntry> a_servers)
|
||||
{
|
||||
m_localServers = std::move(a_servers);
|
||||
}
|
||||
|
||||
void ServerBrowserData::ToggleFavorite(const std::string& a_serverId)
|
||||
{
|
||||
for (auto& entry : m_allServers) {
|
||||
@@ -156,6 +161,16 @@ namespace F4T::ServerBrowser
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const ServerEntry* ServerBrowserData::FindLocalServer(const std::string& a_serverId) const
|
||||
{
|
||||
for (const auto& entry : m_localServers) {
|
||||
if (entry.id == a_serverId) {
|
||||
return std::addressof(entry);
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
nlohmann::json ServerBrowserData::ToServerListJson() const
|
||||
{
|
||||
nlohmann::json servers = nlohmann::json::array();
|
||||
|
||||
Reference in New Issue
Block a user