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:
2026-06-22 15:53:17 +12:00
parent 7efe407421
commit 9ceb6601ae
18 changed files with 1318 additions and 25 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 B

+104
View File
@@ -7,6 +7,110 @@ failed experiments, successful tests, and next steps.
---
## 2026-06-18 - LAN scan fixes for cross-device discovery
### Summary
Fixed Local tab scans not completing or finding servers on other devices (e.g. Steam Deck → host PC). Scan results are now dispatched on the game thread, and discovery probes each LAN host directly instead of relying on broadcast alone.
### Files Changed
- `plugin/src/F4TLanDiscovery.cpp`
- `plugin/src/F4TServerBrowserBridge.cpp`
- `server/lan_discovery.py`
- `ui/views/CommonwealthOnline/browser/components/server-list.js`
- `ui/views/CommonwealthOnline/browser/app.js`
### Details
- PrismaUI events from the scan thread were likely dropped; completion now uses `F4SE::GetTaskInterface()->AddTask`.
- UDP discovery sends unicast `discover` probes to every host on each active /24 subnet, plus a batched TCP `welcome` fallback on port 7777.
- Server logs when it receives a LAN discovery probe.
### Testing
- Not run in-game. On host PC: start `server/server.py`, allow UDP 7778 + TCP 7777 through Windows Firewall (Private network). On Steam Deck, open Multiplayer → Local and scan; host LAN IP should appear within ~5–10 seconds.
### Known Issues
- First scan may take several seconds while the subnet is probed.
- Windows Firewall must allow inbound UDP 7778 on the host.
### Next Steps
- Verify Steam Deck → PC scan in-game after plugin rebuild.
---
## 2026-06-18 - Local servers tab and LAN discovery
### Summary
Added a **Local** tab to the multiplayer menu that scans the LAN for Commonwealth Online servers. The Python relay now responds to UDP discovery probes on port 7778, and the plugin broadcasts discover requests plus a TCP welcome fallback for `127.0.0.1` and the primary LAN address.
### Files Changed
- `server/lan_discovery.py` (new)
- `server/server_core.py`
- `server/README.md`
- `plugin/include/F4TLanDiscovery.h` (new)
- `plugin/src/F4TLanDiscovery.cpp` (new)
- `plugin/include/F4TServerBrowserData.h`
- `plugin/src/F4TServerBrowserData.cpp`
- `plugin/src/F4TServerBrowserBridge.cpp`
- `plugin/xmake.lua`
- `ui/views/CommonwealthOnline/browser/index.html`
- `ui/views/CommonwealthOnline/browser/components/tabs.js`
- `ui/views/CommonwealthOnline/browser/components/server-list.js`
- `ui/views/CommonwealthOnline/browser/components/controller-prompts.js`
- `ui/views/CommonwealthOnline/browser/app.js`
- `docs/protocol.md`
### Details
- Tab order is now: Direct Connect, Browse, Local, Favorites, Recent.
- Opening the Local tab or pressing X (Scan) triggers `scanLocalServers`.
- Discovered servers show as `IP:port` when unnamed/local; player count and ping are shown when available.
- UDP discovery uses `discover` / `discoverResponse` packets documented in `docs/protocol.md`.
### Testing
- Not run in-game. Suggested manual test:
1. Start `server/server.py` on the host PC.
2. Rebuild plugin and redeploy UI.
3. Open Main Menu → Multiplayer → Local.
4. Confirm `127.0.0.1:7777` (or LAN IP) appears after scan.
5. Join from the Local list and verify connection.
6. From a second PC on the same LAN, repeat and confirm the host's LAN IP is listed.
### Known Issues
- Subnet broadcast assumes a /24 LAN. Unusual masks may need future adapter-aware discovery.
- TCP fallback briefly opens a connection and consumes a player slot on the relay until the probe disconnects.
### Next Steps
- In-game verify Windows Firewall prompts for UDP 7778 on first LAN scan.
- Test with two machines on the same subnet.
---
## 2026-06-18 - Recent servers tab
### Summary
Made the multiplayer menu Recent tab functional. Successful joins (browser list or direct connect) are saved to a persisted recent list (up to 8 entries). Recent entries reconnect via stored host/port when they are no longer in the browser list. Display names fall back to `IP:port` when the server has no name or is local.
### Files Changed
- `ui/views/CommonwealthOnline/browser/app.js`
- `ui/views/CommonwealthOnline/browser/components/server-list.js`
- `plugin/src/F4TServerBrowserBridge.cpp`
### Details
- Recent servers persist in `localStorage` under `co_recent_servers`.
- `joinSucceeded` now includes a full `server` snapshot from the plugin for direct-connect and browser joins.
- Direct-connect entries use a stable `recent:host:port` id and empty name so the UI shows address labels.
- Recent list rows show a simplified single-column layout; browser/favorites rows still show favorites, player count, and ping.
### Testing
- Not run in-game. After redeploying UI and rebuilding the plugin: join via Direct Connect or a browser server, reopen Multiplayer → Recent, confirm the entry appears with `127.0.0.1:7777` (or host:port) for local/direct joins, and reconnect from the Recent tab.
### Known Issues
- Recent list is stored in the Ultralight webview `localStorage`; persistence across game restarts depends on PrismaUI profile retention.
### Next Steps
- In-game verify Recent survives closing and reopening the browser menu.
- Optional: move recent-server persistence to the plugin for guaranteed cross-session storage.
---
## 2026-06-18 - Server browser UI resolution scaling
### Summary
+35
View File
@@ -18,6 +18,41 @@ Long-term versions may move to a binary protocol if performance becomes a proble
- `ping`
- `pong`
## LAN Discovery (UDP)
Local server discovery uses a separate UDP channel so clients can find
Commonwealth Online relays on the LAN without scanning every TCP port.
- **Discovery port:** `7778` (UDP)
- **Game port:** `7777` (TCP) by default
### `discover` (client → server, UDP)
```json
{"type":"discover","protocol":"commonwealth-online","version":1}
```
### `discoverResponse` (server → client, UDP)
```json
{
"type": "discoverResponse",
"protocol": "commonwealth-online",
"version": 1,
"name": "Commonwealth Online Server",
"port": 7777,
"players": 2,
"maxPlayers": 16
}
```
Clients should connect to the **source IP** of the UDP response using the
`port` from the response body.
If UDP discovery is unavailable, the plugin may fall back to probing
`127.0.0.1:7777` and the local LAN IPv4 address with a short TCP connect and
`welcome` handshake check.
## Main Rule
The protocol should be testable outside Fallout 4 before it is used inside the F4SE plugin.
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
namespace F4T::LanDiscovery
{
struct DiscoveredServer
{
std::string host;
std::uint16_t port{ 7777 };
std::string name;
int players{ 0 };
int maxPlayers{ 16 };
int ping{ 0 };
};
std::vector<DiscoveredServer> Scan(std::string& a_error);
}
+4
View File
@@ -48,8 +48,11 @@ namespace F4T::ServerBrowser
void ResetFilters();
void ApplyFilters();
void SetAllServers(std::vector<ServerEntry> a_servers);
void SetLocalServers(std::vector<ServerEntry> a_servers);
void ToggleFavorite(const std::string& a_serverId);
const ServerEntry* FindServer(const std::string& a_serverId) const;
const ServerEntry* FindLocalServer(const std::string& a_serverId) const;
const std::vector<ServerEntry>& GetLocalServers() const { return m_localServers; }
const std::vector<ServerEntry>& GetFilteredServers() const { return m_filteredServers; }
const BrowserFilters& GetFilters() const { return m_filters; }
std::string GetConnectionStatus() const { return m_connectionStatus; }
@@ -63,6 +66,7 @@ namespace F4T::ServerBrowser
bool PassesFilters(const ServerEntry& a_entry) const;
std::vector<ServerEntry> m_allServers;
std::vector<ServerEntry> m_localServers;
std::vector<ServerEntry> m_filteredServers;
BrowserFilters m_filters;
std::string m_connectionStatus{ "Online" };
+584
View File
@@ -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;
}
}
+124 -3
View File
@@ -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);
}
+15
View File
@@ -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();
+1 -1
View File
@@ -29,5 +29,5 @@ target("CommonwealthOnline")
add_includedirs("include")
add_includedirs("../ThirdParty/framework-F4-Conversion/example-f4se-plugin/src")
add_packages("nlohmann_json")
add_syslinks("ws2_32")
add_syslinks("ws2_32", "iphlpapi")
set_pcxxheader("src/pch.h")
+5 -1
View File
@@ -9,6 +9,10 @@ packets, assigns incrementing `playerId` values, sends `welcome` packets, adds
to every other connected client, and broadcasts disconnect packets when clients
disconnect.
The server also listens on UDP port `7778` for LAN discovery probes. Clients
can send a `discover` packet (see `docs/protocol.md`) and receive a
`discoverResponse` with the game port and current player count.
Transform packets may include optional movement-state fields such as
`isMoving`, `isSprinting`, `isSneaking`, `isJumping`, `isCrouching`, `weaponDrawn`, and
`movementSpeed`. The server preserves these fields automatically because it
@@ -49,7 +53,7 @@ interface on this PC.
- **Outside your network:** forward TCP port `7777` on your router to the host
PC, then give remote players your public IP (or hostname) and port.
You may need to allow inbound TCP `7777` through Windows Firewall on the host PC.
You may need to allow inbound TCP `7777` and UDP `7778` through Windows Firewall on the host PC.
In the developer GUI, the Fake Clients tab can create simple synthetic TCP
clients for local relay testing. Click `Start Server`, then click
+115
View File
@@ -0,0 +1,115 @@
from __future__ import annotations
import json
import socket
import threading
from typing import Any
DISCOVERY_PORT = 7778
PROTOCOL_NAME = "commonwealth-online"
SERVER_NAME = "Commonwealth Online Server"
DEFAULT_MAX_PLAYERS = 16
class LanDiscoveryResponder:
"""UDP responder so LAN clients can find running Commonwealth Online servers."""
def __init__(self, server: Any, discovery_port: int = DISCOVERY_PORT) -> None:
self._server = server
self._discovery_port = discovery_port
self._socket: socket.socket | None = None
self._thread: threading.Thread | None = None
self._running = False
def start(self) -> None:
if self._running:
return
discovery_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
if hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
discovery_socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
else:
discovery_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
discovery_socket.bind(("0.0.0.0", self._discovery_port))
discovery_socket.settimeout(0.5)
except OSError:
discovery_socket.close()
raise
self._socket = discovery_socket
self._running = True
self._thread = threading.Thread(target=self._listen_loop, daemon=True)
self._thread.start()
def stop(self) -> None:
self._running = False
discovery_socket = self._socket
self._socket = None
if discovery_socket is not None:
try:
discovery_socket.close()
except OSError:
pass
thread = self._thread
if thread is not None and thread is not threading.current_thread():
thread.join(timeout=1.0)
def _listen_loop(self) -> None:
while self._running:
discovery_socket = self._socket
if discovery_socket is None:
break
try:
data, address = discovery_socket.recvfrom(2048)
except socket.timeout:
continue
except OSError:
if self._running:
break
continue
response = self._build_response(data)
if response is None:
continue
self._server._log(
f"LAN discovery probe from {address[0]}:{address[1]} — replying with game port {response['port']}"
)
try:
encoded = json.dumps(response, separators=(",", ":")).encode("utf-8")
discovery_socket.sendto(encoded, address)
except OSError:
pass
def _build_response(self, data: bytes) -> dict[str, Any] | None:
try:
packet = json.loads(data.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return None
if not isinstance(packet, dict):
return None
if packet.get("type") != "discover":
return None
if packet.get("protocol") != PROTOCOL_NAME:
return None
stats = self._server.get_stats()
connected_clients = int(stats.get("connectedClients", 0))
game_port = int(stats.get("port", 7777))
return {
"type": "discoverResponse",
"protocol": PROTOCOL_NAME,
"version": 1,
"name": SERVER_NAME,
"port": game_port,
"players": connected_clients,
"maxPlayers": DEFAULT_MAX_PLAYERS,
}
+12
View File
@@ -8,6 +8,7 @@ from collections.abc import Callable
from typing import Any
from client_session import ClientSession
from lan_discovery import DISCOVERY_PORT, LanDiscoveryResponder
HOST = "0.0.0.0"
@@ -36,6 +37,7 @@ class FalloutTogetherServer:
self._log_listeners: list[Callable[[str], None]] = []
self._server_socket: socket.socket | None = None
self._accept_thread: threading.Thread | None = None
self._discovery: LanDiscoveryResponder | None = None
self._running = False
self._started_at: float | None = None
self._next_player_id = 1
@@ -57,6 +59,8 @@ class FalloutTogetherServer:
return
self._prepare_server_socket()
self._discovery = LanDiscoveryResponder(self)
self._discovery.start()
self._accept_thread = threading.Thread(target=self._accept_loop, daemon=True)
self._accept_thread.start()
@@ -66,6 +70,8 @@ class FalloutTogetherServer:
raise RuntimeError("Server is already running.")
self._prepare_server_socket()
self._discovery = LanDiscoveryResponder(self)
self._discovery.start()
self._accept_loop()
@@ -79,6 +85,11 @@ class FalloutTogetherServer:
self._server_socket = None
clients = list(self._clients.values())
discovery = self._discovery
self._discovery = None
if discovery is not None:
discovery.stop()
if server_socket is not None:
try:
server_socket.close()
@@ -149,6 +160,7 @@ class FalloutTogetherServer:
self._started_at = time.time()
self._log(f"Commonwealth Online server listening on {self.host}:{self.port}")
self._log(f"LAN discovery listening on UDP port {DISCOVERY_PORT}")
if self.host == "0.0.0.0":
self._log(f"Local clients can connect at 127.0.0.1:{self.port}")
lan_addresses = get_lan_addresses()
+220 -17
View File
@@ -12,6 +12,8 @@
var repeatTimer = null;
var repeatAction = null;
var RECENT_STORAGE_KEY = "co_recent_servers";
var MAX_RECENT = 8;
var state = {
activeTab: "server-browser",
@@ -33,8 +35,10 @@
servers: [],
favorites: [],
recent: [],
localServers: [],
connectionStatus: "Online",
isRefreshing: false,
isScanningLocal: false,
directConnect: { address: "127.0.0.1", port: "7777" },
directConnectFieldIndex: 0,
joinOverlay: null
@@ -55,6 +59,9 @@
case "refreshServerList":
refreshLocalMock();
break;
case "scanLocalServers":
scanLocalMock();
break;
case "setFilter":
state.filters[payload.filterName] = payload.value;
applyLocalFilters();
@@ -73,7 +80,10 @@
joinDirectLocal(payload.address, payload.port);
break;
case "switchTab":
switchTab(payload.tabName);
switchTab(payload.tabName, { skipAction: true });
if (payload.tabName === "local") {
scanLocalMock();
}
break;
case "closeBrowser":
if (typeof window.closeBrowser === "function") window.closeBrowser();
@@ -94,6 +104,35 @@
}, 600);
}
function scanLocalMock() {
state.isScanningLocal = true;
render();
setTimeout(function () {
state.localServers = (window.CO_MOCK_SERVERS || []).filter(function (s) {
return s.region === "Local" ||
(s.host && window.CO_ServerList && CO_ServerList.isLocalHost(s.host));
}).map(function (s) {
return {
id: s.id || ("local:" + (s.host || "127.0.0.1") + ":" + portNumFrom(s.port)),
name: s.name || "",
host: s.host || "127.0.0.1",
port: portNumFrom(s.port),
region: "Local",
players: s.players || 0,
maxPlayers: s.maxPlayers || 16,
ping: s.ping || 1,
worldspace: s.worldspace || "",
mode: s.mode || ""
};
});
state.isScanningLocal = false;
if (state.activeTab === "local" && state.selectedServerIndex >= state.localServers.length) {
state.selectedServerIndex = Math.max(0, state.localServers.length - 1);
}
render();
}, 800);
}
function resetLocalFilters() {
state.filters = {
search: "",
@@ -156,6 +195,28 @@
}
}
function updateLocalCount() {
var countEl = document.getElementById("coLocalCount");
var titleEl = document.getElementById("coLocalTitle");
if (!countEl && !titleEl) return;
var count = state.localServers.length;
if (countEl) {
if (state.isScanningLocal) {
countEl.textContent = "Scanning";
} else {
countEl.textContent = count + " Found";
}
}
if (titleEl && state.activeTab === "local") {
if (state.isScanningLocal) {
titleEl.textContent = "Scanning...";
} else {
titleEl.textContent = count === 1 ? "1 Local Server" : count + " Local Servers";
}
}
}
function toggleFavoriteLocal(serverId) {
var server = findServerById(serverId);
if (!server) return;
@@ -172,6 +233,13 @@
return null;
}
function findLocalServerById(id) {
for (var i = 0; i < state.localServers.length; i++) {
if (state.localServers[i].id === id) return state.localServers[i];
}
return null;
}
function getSelectedServer() {
return getActiveList()[state.selectedServerIndex] || null;
}
@@ -179,14 +247,98 @@
function getActiveList() {
if (state.activeTab === "favorites") return state.favorites;
if (state.activeTab === "recent") return state.recent;
if (state.activeTab === "local") return state.localServers;
return state.servers;
}
function joinServerLocal(serverId) {
var server = findServerById(serverId);
function recentEntryKey(server) {
if (!server) return "";
var host = server.host || server.address || "";
if (host) {
return host + ":" + portNumFrom(server.port);
}
return server.id || "";
}
function snapshotRecentEntry(server) {
return {
id: server.id || recentEntryKey(server),
name: server.name || "",
host: server.host || server.address || "",
port: portNumFrom(server.port),
region: server.region || "",
players: server.players || 0,
maxPlayers: server.maxPlayers || 0,
ping: server.ping || 0,
worldspace: server.worldspace || "",
mode: server.mode || "",
favorite: !!server.favorite
};
}
function loadRecent() {
try {
var raw = localStorage.getItem(RECENT_STORAGE_KEY);
if (!raw) return;
var parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
state.recent = parsed.slice(0, MAX_RECENT);
}
} catch (err) {
console.warn("[CO] Failed to load recent servers:", err);
}
}
function saveRecent() {
try {
localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(state.recent));
} catch (err) {
console.warn("[CO] Failed to save recent servers:", err);
}
}
function joinServerEntry(server) {
if (!server) return;
showJoinOverlay("Connecting to " + server.name + "...");
sendAction("joinServer", { serverId: serverId });
var label = window.CO_ServerList
? CO_ServerList.displayName(server)
: (server.name || server.host || "server");
showJoinOverlay("Connecting to " + label + "...");
var browserEntry = server.id ? (findServerById(server.id) || findLocalServerById(server.id)) : null;
if (browserEntry) {
sendAction("joinServer", { serverId: server.id });
return;
}
var host = server.host || server.address;
if (host) {
sendAction("openDirectConnect", {
address: host,
port: String(portNumFrom(server.port))
});
return;
}
if (server.id) {
sendAction("joinServer", { serverId: server.id });
return;
}
showJoinOverlay("Server connection details unavailable.", true);
}
function findServerInActiveList(serverId) {
var list = getActiveList();
for (var i = 0; i < list.length; i++) {
if (list[i].id === serverId) return list[i];
}
return null;
}
function joinServerLocal(serverId) {
var server = findServerById(serverId) || findServerInActiveList(serverId);
if (!server) return;
joinServerEntry(server);
}
function validateDirectConnect(address, port) {
@@ -232,7 +384,8 @@
render();
}
function switchTab(tabName) {
function switchTab(tabName, options) {
options = options || {};
state.activeTab = tabName;
state.selectedTabIndex = CO_Tabs.TAB_ORDER.indexOf(tabName);
if (state.selectedTabIndex < 0) state.selectedTabIndex = 0;
@@ -240,15 +393,28 @@
else if (tabName === "direct-connect") {
state.focusZone = "tabs";
state.directConnectFieldIndex = 0;
} else if (tabName === "favorites" || tabName === "recent" || tabName === "local") {
var list = state.servers;
if (tabName === "favorites") list = state.favorites;
else if (tabName === "recent") list = state.recent;
else if (tabName === "local") list = state.localServers;
if (state.selectedServerIndex >= list.length) {
state.selectedServerIndex = Math.max(0, list.length - 1);
}
if (tabName === "local") {
state.focusZone = "server-list";
}
}
if (!options.skipAction) {
sendAction("switchTab", { tabName: tabName });
}
sendAction("switchTab", { tabName: tabName });
render();
}
function render() {
if (!root) return;
panel.classList.toggle("co-loading", state.isRefreshing);
panel.classList.toggle("co-loading", state.isRefreshing || state.isScanningLocal);
CO_Tabs.render(root, state);
CO_ControllerPrompts.render(root, state);
@@ -268,9 +434,10 @@
var listServers = state.servers;
if (state.activeTab === "favorites") listServers = state.favorites;
if (state.activeTab === "recent") listServers = state.recent;
if (state.activeTab === "local") listServers = state.localServers;
var listState = Object.assign({}, state, { servers: listServers });
if (state.activeTab === "favorites" || state.activeTab === "recent") {
if (state.activeTab === "favorites" || state.activeTab === "recent" || state.activeTab === "local") {
listState.focusZone = state.focusZone === "filters" ? "server-list" : state.focusZone;
}
@@ -297,6 +464,7 @@
}
updateServerCount();
updateLocalCount();
}
function bindUi() {
@@ -406,7 +574,11 @@
return;
}
if (button === "x") {
sendAction("refreshServerList", {});
if (state.activeTab === "local") {
sendAction("scanLocalServers", {});
} else {
sendAction("refreshServerList", {});
}
return;
}
if (button === "y") {
@@ -450,7 +622,7 @@
function joinSelected() {
var sel = getSelectedServer();
if (!sel) return;
joinServerLocal(sel.id);
joinServerEntry(sel);
}
function nextFocusZone(fromIdx, direction) {
@@ -545,6 +717,21 @@
console.warn("[CO] Refresh failed:", event.error);
}
break;
case "localScanStarted":
state.isScanningLocal = true;
break;
case "localServersUpdated":
state.localServers = event.servers || [];
if (state.activeTab === "local" && state.selectedServerIndex >= state.localServers.length) {
state.selectedServerIndex = Math.max(0, state.localServers.length - 1);
}
break;
case "localScanFinished":
state.isScanningLocal = false;
if (!event.success) {
console.warn("[CO] Local scan found no servers.", event.error || "");
}
break;
case "joinStarted":
showJoinOverlay("Connecting...");
break;
@@ -556,7 +743,7 @@
break;
case "joinSucceeded":
hideJoinOverlay();
addRecent(event.serverId);
addRecent(event.server || event.serverId);
break;
case "connectionStatusChanged":
state.connectionStatus = event.status || "Online";
@@ -590,12 +777,27 @@
render();
};
function addRecent(serverId) {
var server = findServerById(serverId);
function addRecent(serverOrId, serverSnapshot) {
var server = serverSnapshot;
if (!server) {
if (serverOrId && typeof serverOrId === "object") {
server = serverOrId;
} else if (serverOrId) {
server = findServerById(serverOrId);
}
}
if (!server) return;
state.recent = state.recent.filter(function (s) { return s.id !== serverId; });
state.recent.unshift(server);
if (state.recent.length > 8) state.recent.pop();
var entry = snapshotRecentEntry(server);
var key = recentEntryKey(entry);
state.recent = state.recent.filter(function (s) {
return recentEntryKey(s) !== key && s.id !== entry.id;
});
state.recent.unshift(entry);
if (state.recent.length > MAX_RECENT) {
state.recent.length = MAX_RECENT;
}
saveRecent();
}
window.setConnectionStatus = function (connected, logPrefix) {
@@ -628,6 +830,7 @@
}
};
loadRecent();
state.allServers = (window.CO_MOCK_SERVERS || []).slice();
applyLocalFilters();
bindUi();
@@ -51,6 +51,14 @@
]);
}
if (state.activeTab === "local") {
return TAB_SWITCH_PROMPTS.concat([
{ key: "A", label: "Join" },
{ key: "X", label: "Scan" },
{ key: "B", label: "Back" }
]);
}
if (state.focusZone === "filters") {
var isReset = state.selectedFilterIndex === CO_Filters.FILTER_ROWS.length;
return TAB_SWITCH_PROMPTS.concat([
@@ -8,28 +8,82 @@
return "co-ping-bad";
},
isLocalHost: function (host) {
if (!host) return false;
return /^(127\.0\.0\.1|localhost|::1)$/i.test(String(host).trim());
},
displayName: function (server) {
if (!server) return "";
var host = server.host || server.address || "";
var port = server.port > 0 ? server.port : 7777;
var name = String(server.name || "").trim();
var isLocal = server.region === "Local" || CO_ServerList.isLocalHost(host);
var isDirect = server.id === "direct" || name === "Direct Connect";
if (!name || isLocal || isDirect) {
return (host || "127.0.0.1") + ":" + port;
}
return name;
},
render: function (root, state) {
var wrap = root.querySelector(".co-server-list-wrap");
var list = root.querySelector(".co-server-list");
if (!wrap || !list) return;
wrap.className = "co-server-list-wrap" + (state.focusZone === "server-list" ? " focused" : "");
var isRecent = state.activeTab === "recent";
var isLocal = state.activeTab === "local";
if (!state.servers.length) {
list.innerHTML = '<div class="co-empty">No servers found</div>';
var emptyText = "No servers found";
if (isRecent) emptyText = "No recent servers";
else if (isLocal) {
emptyText = state.isScanningLocal ?
"Scanning local network..." :
"No local servers found. Check the host PC is running server.py and firewall allows UDP 7778 / TCP 7777.";
}
list.innerHTML = '<div class="co-empty">' + emptyText + "</div>";
CO_ServerList.updateScrollIndicator(wrap, list);
return;
}
list.innerHTML = state.servers.map(function (server, index) {
var selected = index === state.selectedServerIndex ? " selected" : "";
var label = CO_ServerList.displayName(server);
if (isRecent) {
return (
'<div class="co-server-row' + selected + '" data-index="' + index + '" data-id="' + escapeAttr(server.id || "") + '">' +
'<div class="co-server-row-left">' +
'<span class="co-server-row-name">' + escapeHtml(label) + "</span>" +
"</div>" +
"</div>"
);
}
if (isLocal) {
var pingCls = CO_ServerList.pingClass(server.ping || 0);
return (
'<div class="co-server-row' + selected + '" data-index="' + index + '" data-id="' + escapeAttr(server.id || "") + '">' +
'<div class="co-server-row-left">' +
'<span class="co-server-row-name">' + escapeHtml(label) + "</span>" +
"</div>" +
'<div class="co-server-row-right">' +
"<span>" + (server.players || 0) + "/" + (server.maxPlayers || 16) + "</span>" +
'<span class="co-ping ' + pingCls + '">' + (server.ping || 0) + " ms</span>" +
"</div>" +
"</div>"
);
}
var fav = server.favorite ? " on" : "";
var pingCls = CO_ServerList.pingClass(server.ping);
return (
'<div class="co-server-row' + selected + '" data-index="' + index + '" data-id="' + escapeAttr(server.id) + '">' +
'<div class="co-server-row-left">' +
'<span class="co-fav' + fav + '">' + (server.favorite ? "\u2605" : "\u2606") + "</span>" +
'<span class="co-server-row-name">' + escapeHtml(server.name) + "</span>" +
'<span class="co-server-row-name">' + escapeHtml(label) + "</span>" +
"</div>" +
'<div class="co-server-row-right">' +
"<span>" + server.players + "/" + server.maxPlayers + "</span>" +
@@ -1,7 +1,7 @@
(function () {
"use strict";
var TAB_ORDER = ["direct-connect", "server-browser", "favorites", "recent"];
var TAB_ORDER = ["direct-connect", "server-browser", "local", "favorites", "recent"];
window.CO_Tabs = {
TAB_ORDER: TAB_ORDER,
@@ -13,6 +13,7 @@
var labels = {
"direct-connect": "Direct Connect",
"server-browser": "Browse",
local: "Local",
favorites: "Favorites",
recent: "Recent"
};
@@ -43,6 +43,19 @@
</div>
</div>
<div class="co-tab-pane" data-pane="local">
<header class="co-subpanel-header">
<h2 class="co-subpanel-title" id="coLocalTitle">Local Servers</h2>
<div class="co-subpanel-meta">
<span class="co-count" id="coLocalCount"></span>
</div>
</header>
<div class="co-server-list-wrap">
<div class="co-server-list co-local-list"></div>
<div class="co-scroll-indicator" aria-hidden="true">&raquo;&raquo;</div>
</div>
</div>
<div class="co-tab-pane" data-pane="favorites">
<header class="co-subpanel-header">
<h2 class="co-subpanel-title">Favorites</h2>