Add PrismaUI HTML server browser & integration

Integrate a PrismaUI_F4-based HTML server browser and related tooling. Adds a ThirdParty submodule (framework-F4-Conversion), UI views (CommonwealthOnline browser HTML/JS/CSS), and multiple plugin headers/sources to bridge PrismaUI/Scaleform, handle menu input, and manage server browser data and actions (mock server list, join/connect flow).

Key changes:
- New build/deploy scripts: build-prismaui.bat, build-all.bat, deploy-all.bat, deploy-ui.bat and a PowerShell helper for linking the Ultralight SDK (ULTRALIGHT_SDK_PATH, default F:\\UltralightSDK).
- Plugin additions: F4TPrismaUI, F4TScaleformUI, F4TServerBrowserBridge, F4TServerBrowserData, F4TMenuInput, F4TDebugOverlay and implementations to create/show/hide the HTML view, forward controller input, register Scaleform functions (openManager/closeManager), and push UI events.
- Networking: add ConnectToServer(host,port) and keep ConnectToLocalServer() as wrapper; update connection logs and error handling.
- Main menu SWF/AS updates: updated MainMenu.swf and MainMenu.as to call closeManager() when leaving multiplayer and to integrate open/close hooks.
- Documentation: plugin/setup.md updated with PrismaUI build/deploy instructions.
- Removed Main_ModManager.swf (deleted).

Primary intent: provide an in-game HTML server browser (toggleable with F9 or Scaleform calls), wire up controller input, and add build/deploy workflow for PrismaUI/Ultralight so developers can build and deploy the UI and plugin together. Mock server data is used for now; network join/connect plumbing is provided for local/dev servers.
This commit is contained in:
2026-06-08 14:02:49 +12:00
parent 2ced73f7b9
commit fe620020be
38 changed files with 3565 additions and 15 deletions
+109
View File
@@ -0,0 +1,109 @@
#include "pch.h"
#include "F4TMenuInput.h"
#include "F4TServerBrowserBridge.h"
#include <F4SE/InputMap.h>
namespace F4T::MenuInput
{
namespace
{
std::atomic_bool g_browserActive{ false };
std::atomic_bool g_sinkRegistered{ false };
const char* GamepadButtonName(std::uint32_t a_keyCode)
{
switch (a_keyCode) {
case F4SE::InputMap::kGamepadButtonOffset_DPAD_UP:
return "dpad_up";
case F4SE::InputMap::kGamepadButtonOffset_DPAD_DOWN:
return "dpad_down";
case F4SE::InputMap::kGamepadButtonOffset_DPAD_LEFT:
return "dpad_left";
case F4SE::InputMap::kGamepadButtonOffset_DPAD_RIGHT:
return "dpad_right";
case F4SE::InputMap::kGamepadButtonOffset_A:
return "a";
case F4SE::InputMap::kGamepadButtonOffset_B:
return "b";
case F4SE::InputMap::kGamepadButtonOffset_X:
return "x";
case F4SE::InputMap::kGamepadButtonOffset_Y:
return "y";
case F4SE::InputMap::kGamepadButtonOffset_LEFT_SHOULDER:
return "lb";
case F4SE::InputMap::kGamepadButtonOffset_RIGHT_SHOULDER:
return "rb";
default:
return nullptr;
}
}
class GamepadInputHandler :
public RE::BSInputEventUser
{
public:
static GamepadInputHandler* GetSingleton()
{
static GamepadInputHandler instance;
return std::addressof(instance);
}
bool ShouldHandleEvent(const RE::InputEvent* a_event) override
{
if (!g_browserActive.load() || !a_event) {
return false;
}
return a_event->eventType == RE::INPUT_EVENT_TYPE::kButton &&
a_event->device == RE::INPUT_DEVICE::kGamepad;
}
void OnButtonEvent(const RE::ButtonEvent* a_event) override
{
if (!a_event || !g_browserActive.load()) {
return;
}
const auto keyCode = static_cast<std::uint32_t>(a_event->GetBSButtonCode());
const auto* buttonName = GamepadButtonName(keyCode);
if (!buttonName) {
return;
}
const char* phase = nullptr;
if (a_event->QJustPressed()) {
phase = "down";
} else if (a_event->QReleased()) {
phase = "up";
}
if (phase) {
F4T::ServerBrowser::ServerBrowserBridge::ForwardControllerInput(buttonName, phase);
}
}
};
}
void Register()
{
if (g_sinkRegistered.exchange(true)) {
return;
}
auto* menuControls = RE::MenuControls::GetSingleton();
if (menuControls) {
menuControls->handlers.push_back(GamepadInputHandler::GetSingleton());
REX::INFO("[MenuInput] Registered gamepad handler for server browser.");
} else {
REX::WARN("[MenuInput] MenuControls unavailable; gamepad navigation disabled.");
g_sinkRegistered.store(false);
}
}
void SetBrowserActive(bool a_active)
{
g_browserActive.store(a_active);
}
}
+19 -14
View File
@@ -596,8 +596,12 @@ namespace F4T::Networking
return "[LocalPlayerId=" + std::to_string(*assignedPlayerId) + "]";
}
bool ConnectToLocalServer()
bool ConnectToServer(const char* a_host, std::uint16_t a_port)
{
if (!a_host || a_host[0] == '\0') {
return false;
}
if (IsConnectedToServer()) {
return true;
}
@@ -610,27 +614,25 @@ namespace F4T::Networking
return false;
}
// Keep this first networking milestone local-only so it can prove the
// packet path without exposing any gameplay state to a remote server.
const auto localSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (localSocket == INVALID_SOCKET) {
LogWarningWithLocalPlayerPrefix("Could not create socket for Commonwealth Online local server connection.");
LogWarningWithLocalPlayerPrefix("Could not create socket for Commonwealth Online server connection.");
return false;
}
if (!SetNonBlocking(localSocket)) {
closesocket(localSocket);
LogWarningWithLocalPlayerPrefix(
"Could not configure non-blocking socket for Commonwealth Online local server connection.");
"Could not configure non-blocking socket for Commonwealth Online server connection.");
return false;
}
sockaddr_in serverAddress{};
serverAddress.sin_family = AF_INET;
serverAddress.sin_port = htons(kLocalServerPort);
if (inet_pton(AF_INET, kLocalServerHost, std::addressof(serverAddress.sin_addr)) != 1) {
serverAddress.sin_port = htons(a_port);
if (inet_pton(AF_INET, a_host, std::addressof(serverAddress.sin_addr)) != 1) {
closesocket(localSocket);
LogWarningWithLocalPlayerPrefix("Could not parse Commonwealth Online local server address.");
LogWarningWithLocalPlayerPrefix(std::format("Could not parse Commonwealth Online server address '{}'.", a_host));
return false;
}
@@ -641,12 +643,10 @@ namespace F4T::Networking
if (!connected) {
closesocket(localSocket);
// The server is a developer tool, not a runtime requirement. A failed
// connection should leave Fallout 4 running and simply disable packet sends.
LogWarningWithLocalPlayerPrefix(std::format(
"Could not connect to Commonwealth Online local server at {}:{}",
kLocalServerHost,
kLocalServerPort));
"Could not connect to Commonwealth Online server at {}:{}",
a_host,
a_port));
return false;
}
@@ -662,10 +662,15 @@ namespace F4T::Networking
StartReceiveThread();
LogInfoWithLocalPlayerPrefix(
std::format("Connected to Commonwealth Online local server at {}:{}.", kLocalServerHost, kLocalServerPort));
std::format("Connected to Commonwealth Online server at {}:{}.", a_host, a_port));
return true;
}
bool ConnectToLocalServer()
{
return ConnectToServer(kLocalServerHost, kLocalServerPort);
}
void DisconnectFromLocalServer()
{
StopReceiveThread();
+313
View File
@@ -0,0 +1,313 @@
#include "pch.h"
#include "F4TMenuInput.h"
#include "F4TNetworking.h"
#include "F4TPrismaUI.h"
#include "F4TServerBrowserBridge.h"
#include "PrismaUI_F4_API.h"
#include <cstdio>
#include <nlohmann/json.hpp>
#include <string_view>
namespace F4T::PrismaUI
{
namespace
{
constexpr auto kServerBrowserHtmlPath = "CommonwealthOnline/browser/index.html";
constexpr auto kToggleVirtualKey = 0x78; // VK_F9
PRISMA_UI_API::IVPrismaUI4* g_api = nullptr;
PrismaView g_serverBrowserView = 0;
bool g_visible = false;
bool g_domReady = false;
bool g_toggleTaskStarted = false;
bool g_cursorRestoreTaskStarted = false;
bool g_menuWatchTaskStarted = false;
bool g_pendingShow = false;
int g_cursorRestoreFramesLeft = 0;
std::string GetLogPrefix()
{
return F4T::Networking::GetLocalPlayerLogPrefix() + " [PrismaUI]";
}
void LogInfo(std::string_view a_message)
{
const auto message = GetLogPrefix() + " " + std::string(a_message);
REX::INFO(std::string_view{ message });
}
void LogWarning(std::string_view a_message)
{
const auto message = GetLogPrefix() + " " + std::string(a_message);
REX::WARN(std::string_view{ message });
}
void RestoreNativeGameCursor()
{
auto* ui = RE::UI::GetSingleton();
if (!ui) {
return;
}
if (auto cursorMenu = ui->GetMenu<RE::CursorMenu>()) {
if (cursorMenu->uiMovie) {
cursorMenu->uiMovie->SetVisible(true);
}
}
ui->RefreshCursor();
if (ui->GetMenuOpen<RE::MainMenu>()) {
if (auto* menuCursor = RE::MenuCursor::GetSingleton()) {
menuCursor->RegisterCursor();
}
}
}
void RequestNativeCursorRestore()
{
g_cursorRestoreFramesLeft = 4;
}
void HideServerBrowserInternal()
{
if (!g_visible) {
return;
}
if (!g_api || !g_api->IsValid(g_serverBrowserView)) {
g_visible = false;
F4T::MenuInput::SetBrowserActive(false);
return;
}
g_visible = false;
F4T::MenuInput::SetBrowserActive(false);
F4T::ServerBrowser::ServerBrowserBridge::OnBrowserHidden();
g_api->Unfocus(g_serverBrowserView);
g_api->Hide(g_serverBrowserView);
RequestNativeCursorRestore();
}
void WatchMainMenuState()
{
if (!g_visible) {
return;
}
const auto* ui = RE::UI::GetSingleton();
if (!ui || !ui->GetMenuOpen<RE::MainMenu>()) {
HideServerBrowserInternal();
}
}
void CreateServerBrowserView();
void ShowServerBrowserInternal()
{
if (!g_api) {
return;
}
if (!g_api->IsValid(g_serverBrowserView)) {
g_pendingShow = true;
CreateServerBrowserView();
return;
}
if (!g_domReady) {
g_pendingShow = true;
return;
}
g_pendingShow = false;
g_visible = true;
F4T::MenuInput::SetBrowserActive(true);
g_api->Show(g_serverBrowserView);
g_api->Focus(g_serverBrowserView, false, false);
F4T::ServerBrowser::ServerBrowserBridge::OnBrowserShown();
g_api->Invoke(
g_serverBrowserView,
"if(window.onBrowserShown){window.onBrowserShown();}");
}
void ToggleServerBrowser()
{
if (!g_api) {
return;
}
if (g_visible) {
HideServerBrowserInternal();
return;
}
ShowServerBrowserInternal();
}
void OnServerBrowserDomReady(PrismaView a_view)
{
g_domReady = true;
LogInfo("Server browser DOM ready.");
g_api->RegisterConsoleCallback(
a_view,
[](PrismaView, PRISMA_UI_API::ConsoleMessageLevel a_level, const char* a_message) {
if (!a_message) {
return;
}
const auto prefix = GetLogPrefix() + " [JS]";
switch (a_level) {
case PRISMA_UI_API::ConsoleMessageLevel::Error:
REX::CRITICAL("{} {}", prefix, a_message);
break;
case PRISMA_UI_API::ConsoleMessageLevel::Warning:
REX::WARN("{} {}", prefix, a_message);
break;
default:
REX::INFO("{} {}", prefix, a_message);
break;
}
});
F4T::ServerBrowser::ServerBrowserBridge::Initialize(g_api, a_view);
g_api->BindUIEvent(a_view, "coAction", [](const char* a_json) {
if (!a_json) {
return;
}
try {
const auto action = nlohmann::json::parse(a_json);
const auto actionName = action.value("action", "");
if (actionName == "closeBrowser") {
HideServerBrowserInternal();
return;
}
F4T::ServerBrowser::ServerBrowserBridge::HandleAction(action);
} catch (const std::exception& ex) {
REX::WARN("{} [JS] Failed to parse coAction JSON: {}", GetLogPrefix(), ex.what());
}
});
// Legacy bridge stubs for backward compatibility during transition.
g_api->BindUIEvent(a_view, "connectServer", [](const char*) {
if (!F4T::Networking::IsConnectedToServer()) {
F4T::Networking::ConnectToLocalServer();
}
F4T::ServerBrowser::ServerBrowserBridge::PushConnectionStatus();
});
g_api->BindUIEvent(a_view, "disconnectServer", [](const char*) {
F4T::Networking::DisconnectFromLocalServer();
F4T::ServerBrowser::ServerBrowserBridge::PushConnectionStatus();
});
g_api->BindUIEvent(a_view, "closeBrowser", [](const char*) {
HideServerBrowserInternal();
});
F4T::ServerBrowser::ServerBrowserBridge::PushConnectionStatus();
if (g_pendingShow) {
ShowServerBrowserInternal();
}
}
void CreateServerBrowserView()
{
if (!g_api) {
return;
}
if (g_serverBrowserView != 0 && g_api->IsValid(g_serverBrowserView)) {
return;
}
g_domReady = false;
g_serverBrowserView = g_api->CreateView(kServerBrowserHtmlPath, OnServerBrowserDomReady);
if (g_serverBrowserView == 0) {
LogWarning(std::format(
"Failed to create server browser view. Is Data/PrismaUI_F4/views/{} installed?",
kServerBrowserHtmlPath));
return;
}
g_api->Hide(g_serverBrowserView);
LogInfo(std::format("Created server browser view for '{}'.", kServerBrowserHtmlPath));
}
void StartGameThreadTasks()
{
const auto* taskInterface = F4SE::GetTaskInterface();
if (!taskInterface) {
LogWarning("F4SE task interface unavailable; F9 server browser toggle disabled.");
return;
}
if (!g_toggleTaskStarted) {
taskInterface->AddTaskPermanent([]() {
static bool lastToggleDown = false;
const auto toggleDown = (GetAsyncKeyState(kToggleVirtualKey) & 0x8000) != 0;
if (toggleDown && !lastToggleDown) {
ToggleServerBrowser();
}
lastToggleDown = toggleDown;
});
g_toggleTaskStarted = true;
}
if (!g_cursorRestoreTaskStarted) {
taskInterface->AddTaskPermanent([]() {
if (g_cursorRestoreFramesLeft <= 0) {
return;
}
RestoreNativeGameCursor();
g_cursorRestoreFramesLeft--;
});
g_cursorRestoreTaskStarted = true;
}
if (!g_menuWatchTaskStarted) {
taskInterface->AddTaskPermanent(WatchMainMenuState);
g_menuWatchTaskStarted = true;
}
}
}
void OnGameDataReady()
{
g_api = PRISMA_UI_API::RequestPluginAPI<PRISMA_UI_API::IVPrismaUI4>();
if (!g_api) {
LogWarning(
"PrismaUI_F4 API not found. Install PrismaUI_F4.dll and its Ultralight libs before the HTML server browser can render.");
return;
}
LogInfo("PrismaUI_F4 API loaded.");
F4T::MenuInput::Register();
StartGameThreadTasks();
CreateServerBrowserView();
}
void ShowServerBrowser()
{
ShowServerBrowserInternal();
}
void HideServerBrowser()
{
HideServerBrowserInternal();
}
bool IsBrowserVisible()
{
return g_visible;
}
}
+96
View File
@@ -0,0 +1,96 @@
#include "pch.h"
#include "F4TNetworking.h"
#include "F4TPrismaUI.h"
#include "F4TScaleformUI.h"
#include "Scaleform/G/GFx_FunctionHandler.h"
#include "Scaleform/G/GFx_Movie.h"
#include "Scaleform/G/GFx_Value.h"
namespace F4T::ScaleformUI
{
namespace
{
class OpenManagerHandler :
public Scaleform::GFx::FunctionHandler
{
public:
void Call(const Params& a_params) override
{
(void)a_params;
const auto* taskInterface = F4SE::GetTaskInterface();
if (taskInterface) {
taskInterface->AddTask([]() {
F4T::PrismaUI::ShowServerBrowser();
});
return;
}
F4T::PrismaUI::ShowServerBrowser();
}
};
class CloseManagerHandler :
public Scaleform::GFx::FunctionHandler
{
public:
void Call(const Params& a_params) override
{
(void)a_params;
const auto* taskInterface = F4SE::GetTaskInterface();
if (taskInterface) {
taskInterface->AddTask([]() {
F4T::PrismaUI::HideServerBrowser();
});
return;
}
F4T::PrismaUI::HideServerBrowser();
}
};
OpenManagerHandler g_openManagerHandler;
CloseManagerHandler g_closeManagerHandler;
bool F4SEAPI RegisterCommonwealthOnlineScaleform(
Scaleform::GFx::Movie* a_view,
Scaleform::GFx::Value* a_plugin)
{
if (!a_view || !a_plugin) {
return false;
}
Scaleform::GFx::Value openManager;
a_view->CreateFunction(&openManager, &g_openManagerHandler);
if (!a_plugin->SetMember("openManager", openManager)) {
return false;
}
Scaleform::GFx::Value closeManager;
a_view->CreateFunction(&closeManager, &g_closeManagerHandler);
return a_plugin->SetMember("closeManager", closeManager);
}
}
void RegisterPlugin()
{
const auto* scaleform = F4SE::GetScaleformInterface();
if (!scaleform) {
REX::WARN("{} [Scaleform] Scaleform interface unavailable; Main Menu Multiplayer button will not open the server browser.",
F4T::Networking::GetLocalPlayerLogPrefix());
return;
}
if (!scaleform->Register("commonwealthOnline", RegisterCommonwealthOnlineScaleform)) {
REX::WARN("{} [Scaleform] Failed to register commonwealthOnline Scaleform API.",
F4T::Networking::GetLocalPlayerLogPrefix());
return;
}
REX::INFO("{} [Scaleform] Registered root.f4se.plugins.commonwealthOnline openManager/closeManager.",
F4T::Networking::GetLocalPlayerLogPrefix());
}
}
+282
View File
@@ -0,0 +1,282 @@
#include "pch.h"
#include "F4TNetworking.h"
#include "F4TServerBrowserBridge.h"
#include "F4TServerBrowserData.h"
#include <format>
#include <string_view>
namespace F4T::ServerBrowser
{
namespace
{
PRISMA_UI_API::IVPrismaUI4* g_api = nullptr;
PrismaView g_view = 0;
ServerBrowserData g_data;
std::string GetLogPrefix()
{
return F4T::Networking::GetLocalPlayerLogPrefix() + " [BrowserBridge]";
}
void LogInfo(std::string_view a_message)
{
REX::INFO("{}{}", GetLogPrefix(), a_message);
}
void LogWarning(std::string_view a_message)
{
REX::WARN("{}{}", GetLogPrefix(), a_message);
}
void DispatchEvent(const nlohmann::json& a_event)
{
if (!g_api || g_view == 0 || !g_api->IsValid(g_view)) {
return;
}
const auto script = std::format("if(window.dispatchCoEvent){{window.dispatchCoEvent({});}}", a_event.dump());
g_api->Invoke(g_view, script.c_str());
}
void PushServerListUpdated()
{
DispatchEvent(nlohmann::json{
{ "type", "serverListUpdated" },
{ "servers", g_data.ToServerListJson() }
});
}
void PushConnectionStatusEvent()
{
const auto connected = F4T::Networking::IsConnectedToServer();
g_data.SetConnectionStatus(connected ? "Connected" : "Online");
DispatchEvent(nlohmann::json{
{ "type", "connectionStatusChanged" },
{ "status", g_data.GetConnectionStatus() },
{ "serverCount", g_data.GetFilteredServers().size() }
});
if (g_api && g_view != 0 && g_api->IsValid(g_view)) {
const auto prefix = F4T::Networking::GetLocalPlayerLogPrefix();
const auto script = std::format(
"if(window.setConnectionStatus){{window.setConnectionStatus({},{});}}",
connected ? "true" : "false",
nlohmann::json(prefix).dump());
g_api->Invoke(g_view, script.c_str());
}
}
bool JoinServerEntry(const ServerEntry& a_entry, std::string& a_error)
{
DispatchEvent(nlohmann::json{
{ "type", "joinStarted" },
{ "serverId", a_entry.id }
});
if (a_entry.passworded) {
a_error = "Password required — password prompt not yet implemented.";
return false;
}
const auto host = a_entry.host.empty() ? "127.0.0.1" : a_entry.host;
const auto port = static_cast<std::uint16_t>(a_entry.port > 0 ? a_entry.port : 7777);
if (!F4T::Networking::ConnectToServer(host.c_str(), port)) {
if (host == "127.0.0.1" && port == 7777) {
a_error = "Could not connect to local relay. Start server/server.py first.";
} else {
a_error = std::format("Connection failed — server offline or unreachable at {}:{}", host, port);
}
return false;
}
return true;
}
void RefreshServerList()
{
g_data.SetRefreshing(true);
DispatchEvent(nlohmann::json{ { "type", "serverListRefreshStarted" } });
// TODO M6: Replace mock refresh with master-server / LAN discovery query.
g_data.SetAllServers(ServerBrowserData::CreateMockServers());
g_data.SetRefreshing(false);
PushServerListUpdated();
PushConnectionStatusEvent();
DispatchEvent(nlohmann::json{
{ "type", "serverListRefreshFinished" },
{ "success", true }
});
LogInfo(" Server list refreshed (mock data).");
}
}
void ServerBrowserBridge::Initialize(PRISMA_UI_API::IVPrismaUI4* a_api, PrismaView a_view)
{
g_api = a_api;
g_view = a_view;
g_data.SetAllServers(ServerBrowserData::CreateMockServers());
}
void ServerBrowserBridge::OnBrowserShown()
{
DispatchEvent(nlohmann::json{ { "type", "menuOpened" } });
RefreshServerList();
}
void ServerBrowserBridge::OnBrowserHidden()
{
DispatchEvent(nlohmann::json{ { "type", "menuClosed" } });
}
void ServerBrowserBridge::HandleAction(const nlohmann::json& a_action)
{
const auto action = a_action.value("action", "");
if (action.empty()) {
return;
}
if (action == "refreshServerList") {
RefreshServerList();
return;
}
if (action == "setFilter") {
auto filters = g_data.GetFilters();
const auto filterName = a_action.value("filterName", "");
if (filterName == "search") {
filters.search = a_action.value("value", "");
} else if (filterName == "region") {
filters.region = a_action.value("value", "Any Region");
} else if (filterName == "mode") {
filters.mode = a_action.value("value", "Any Mode");
} else if (filterName == "worldspace") {
filters.worldspace = a_action.value("value", "Any Worldspace");
} else if (filterName == "ping") {
filters.ping = a_action.value("value", "Any");
} else if (filterName == "showFull" || filterName == "hasPassword" || filterName == "modded") {
const auto value = a_action.value("value", false);
if (filterName == "showFull") {
filters.showFull = value;
} else if (filterName == "hasPassword") {
filters.hasPassword = value;
} else {
filters.modded = value;
}
}
g_data.SetFilters(filters);
PushServerListUpdated();
return;
}
if (action == "resetFilters") {
g_data.ResetFilters();
PushServerListUpdated();
return;
}
if (action == "toggleFavorite") {
const auto serverId = a_action.value("serverId", "");
if (!serverId.empty()) {
g_data.ToggleFavorite(serverId);
PushServerListUpdated();
}
return;
}
if (action == "switchTab") {
return;
}
if (action == "joinServer") {
const auto serverId = a_action.value("serverId", "");
const auto* entry = g_data.FindServer(serverId);
if (!entry) {
DispatchEvent(nlohmann::json{
{ "type", "joinFailed" },
{ "serverId", serverId },
{ "reason", "Server not found." }
});
return;
}
std::string error;
if (JoinServerEntry(*entry, error)) {
PushConnectionStatusEvent();
DispatchEvent(nlohmann::json{
{ "type", "joinSucceeded" },
{ "serverId", serverId }
});
DispatchEvent(nlohmann::json{
{ "type", "uiSound" },
{ "sound", "joinSuccess" }
});
LogInfo(std::format(" Joined server '{}'.", entry->name));
} else {
DispatchEvent(nlohmann::json{
{ "type", "joinFailed" },
{ "serverId", serverId },
{ "reason", error }
});
DispatchEvent(nlohmann::json{
{ "type", "uiSound" },
{ "sound", "joinFailed" }
});
LogWarning(std::format(" Join failed for '{}': {}", entry->name, error));
}
return;
}
if (action == "openDirectConnect") {
ServerEntry entry;
entry.id = "direct";
entry.name = "Direct Connect";
entry.host = a_action.value("address", "127.0.0.1");
entry.port = a_action.value("port", 7777);
std::string error;
if (JoinServerEntry(entry, error)) {
PushConnectionStatusEvent();
DispatchEvent(nlohmann::json{
{ "type", "joinSucceeded" },
{ "serverId", "direct" }
});
} else {
DispatchEvent(nlohmann::json{
{ "type", "joinFailed" },
{ "serverId", "direct" },
{ "reason", error }
});
}
return;
}
if (action == "closeBrowser") {
return;
}
}
void ServerBrowserBridge::ForwardControllerInput(const char* a_button, const char* a_phase)
{
if (!a_button || !a_phase) {
return;
}
DispatchEvent(nlohmann::json{
{ "type", "controllerInput" },
{ "button", a_button },
{ "phase", a_phase }
});
}
void ServerBrowserBridge::PushConnectionStatus()
{
PushConnectionStatusEvent();
}
}
+250
View File
@@ -0,0 +1,250 @@
#include "pch.h"
#include "F4TServerBrowserData.h"
#include <algorithm>
#include <cctype>
namespace F4T::ServerBrowser
{
namespace
{
bool ContainsIgnoreCase(std::string_view a_haystack, std::string_view a_needle)
{
if (a_needle.empty()) {
return true;
}
std::string haystackLower(a_haystack);
std::string needleLower(a_needle);
std::ranges::transform(haystackLower, haystackLower.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
std::ranges::transform(needleLower, needleLower.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return haystackLower.find(needleLower) != std::string::npos;
}
}
nlohmann::json ServerEntry::ToJson() const
{
auto json = nlohmann::json{
{ "id", id },
{ "name", name },
{ "region", region },
{ "players", players },
{ "maxPlayers", maxPlayers },
{ "ping", ping },
{ "worldspace", worldspace },
{ "mode", mode },
{ "status", status },
{ "modded", modded },
{ "passworded", passworded },
{ "favorite", favorite },
{ "description", description }
};
if (!host.empty()) {
json["host"] = host;
}
if (port > 0) {
json["port"] = port;
}
return json;
}
ServerEntry ServerEntry::FromJson(const nlohmann::json& a_json)
{
ServerEntry entry;
entry.id = a_json.value("id", "");
entry.name = a_json.value("name", "");
entry.region = a_json.value("region", "");
entry.worldspace = a_json.value("worldspace", "");
entry.mode = a_json.value("mode", "");
entry.status = a_json.value("status", "");
entry.description = a_json.value("description", "");
entry.host = a_json.value("host", "");
entry.port = a_json.value("port", 0);
entry.players = a_json.value("players", 0);
entry.maxPlayers = a_json.value("maxPlayers", 0);
entry.ping = a_json.value("ping", 0);
entry.modded = a_json.value("modded", false);
entry.passworded = a_json.value("passworded", false);
entry.favorite = a_json.value("favorite", false);
return entry;
}
void ServerBrowserData::SetFilters(const BrowserFilters& a_filters)
{
m_filters = a_filters;
ApplyFilters();
}
void ServerBrowserData::ResetFilters()
{
m_filters = BrowserFilters{};
ApplyFilters();
}
bool ServerBrowserData::PassesFilters(const ServerEntry& a_entry) const
{
if (!m_filters.search.empty() && !ContainsIgnoreCase(a_entry.name, m_filters.search)) {
return false;
}
if (m_filters.region != "Any Region" && a_entry.region != m_filters.region) {
return false;
}
if (m_filters.mode != "Any Mode" && a_entry.mode != m_filters.mode) {
return false;
}
if (m_filters.worldspace != "Any Worldspace" && a_entry.worldspace != m_filters.worldspace) {
return false;
}
if (m_filters.ping == "< 50 ms" && a_entry.ping >= 50) {
return false;
}
if (m_filters.ping == "< 100 ms" && a_entry.ping >= 100) {
return false;
}
if (m_filters.ping == "< 150 ms" && a_entry.ping >= 150) {
return false;
}
if (!m_filters.showFull && a_entry.status == "Full") {
return false;
}
if (m_filters.hasPassword && !a_entry.passworded) {
return false;
}
if (m_filters.modded && !a_entry.modded) {
return false;
}
return true;
}
void ServerBrowserData::ApplyFilters()
{
m_filteredServers.clear();
for (const auto& entry : m_allServers) {
if (PassesFilters(entry)) {
m_filteredServers.push_back(entry);
}
}
}
void ServerBrowserData::SetAllServers(std::vector<ServerEntry> a_servers)
{
m_allServers = std::move(a_servers);
ApplyFilters();
}
void ServerBrowserData::ToggleFavorite(const std::string& a_serverId)
{
for (auto& entry : m_allServers) {
if (entry.id == a_serverId) {
entry.favorite = !entry.favorite;
break;
}
}
ApplyFilters();
}
const ServerEntry* ServerBrowserData::FindServer(const std::string& a_serverId) const
{
for (const auto& entry : m_allServers) {
if (entry.id == a_serverId) {
return std::addressof(entry);
}
}
return nullptr;
}
nlohmann::json ServerBrowserData::ToServerListJson() const
{
nlohmann::json servers = nlohmann::json::array();
for (const auto& entry : m_filteredServers) {
servers.push_back(entry.ToJson());
}
return servers;
}
std::vector<ServerEntry> ServerBrowserData::CreateMockServers()
{
return {
ServerEntry{
.id = "nz-1",
.name = "Commonwealth NZ #1",
.region = "Oceania",
.worldspace = "Commonwealth",
.mode = "Co-op",
.status = "Open",
.description = "Relaxed co-op server for explorers and builders. No PvP.",
.players = 6,
.maxPlayers = 12,
.ping = 32,
.modded = true,
.favorite = true },
ServerEntry{
.id = "vault111",
.name = "Vault 111 Co-op",
.region = "North America",
.worldspace = "Commonwealth",
.mode = "Co-op",
.status = "Open",
.description = "Story-friendly co-op.",
.players = 3,
.maxPlayers = 8,
.ping = 48,
.modded = false },
ServerEntry{
.id = "dc-rp",
.name = "DC Wasteland RP",
.region = "North America",
.worldspace = "Commonwealth",
.mode = "Roleplay",
.status = "Full",
.description = "Light roleplay around Diamond City.",
.players = 10,
.maxPlayers = 10,
.ping = 72,
.modded = true,
.passworded = true },
ServerEntry{
.id = "far-harbor",
.name = "Far Harbor Expedition",
.region = "Europe",
.worldspace = "Far Harbor",
.mode = "Co-op",
.status = "Open",
.description = "DLC-focused co-op through Far Harbor.",
.players = 4,
.maxPlayers = 6,
.ping = 118,
.modded = true,
.favorite = true },
ServerEntry{
.id = "local-relay",
.name = "Local Relay (Dev)",
.region = "Local",
.worldspace = "Commonwealth",
.mode = "Co-op",
.status = "Open",
.description = "Python relay prototype. Start server/server.py before joining.",
.host = "127.0.0.1",
.port = 7777,
.players = 0,
.maxPlayers = 16,
.ping = 1,
.modded = false },
ServerEntry{
.id = "survival-hard",
.name = "Survival Hardcore",
.region = "Europe",
.worldspace = "Commonwealth",
.mode = "Survival",
.status = "Open",
.description = "Hardcore survival with limited respawns.",
.players = 2,
.maxPlayers = 4,
.ping = 95,
.modded = true }
};
}
}
+6
View File
@@ -1,5 +1,7 @@
#include "F4TNetworking.h"
#include "F4TLocalAnimationGraphDebug.h"
#include "F4TPrismaUI.h"
#include "F4TScaleformUI.h"
#include "F4TProxyActorController.h"
#include "F4TProxyAnimationSync.h"
#include "F4TProxyPuppet.h"
@@ -636,6 +638,9 @@ namespace
}
switch (a_msg->type) {
case F4SE::MessagingInterface::kGameDataReady:
F4T::PrismaUI::OnGameDataReady();
break;
case F4SE::MessagingInterface::kNewGame:
case F4SE::MessagingInterface::kPostLoadGame:
case F4SE::MessagingInterface::kGameLoaded:
@@ -652,6 +657,7 @@ namespace
F4SE_PLUGIN_LOAD(const F4SE::LoadInterface* a_f4se)
{
F4SE::Init(a_f4se);
F4T::ScaleformUI::RegisterPlugin();
LogInfoWithLocalPlayerPrefix("Commonwealth Online plugin loaded successfully.");
// TODO: Consider separate per-instance log files once launcher/profile support exists.