Add server validation, profile store, session launch

Introduce server validation and a session launch flow with persistent server profiles.

- Add ConnectAndValidateServer to Networking to validate handshake/timeouts and return human-friendly errors; avoid auto-connecting in transform send path and remove unconditional local connect on startup.
- Add ServerProfileStore (header + implementation) to persist server->saveFile bindings under Documents/My Games/.../CommonwealthOnline/server-profiles.json with thread-safe load/save, lookup, touch and bind operations.
- Add SessionLaunch (header + implementation) to handle launching validated sessions, first-time join flow (auto coc to SanctuaryExt, character creation menus, save binding), F4SE message handling and periodic Update processing.
- Update ServerBrowserBridge to parse/validate ports, use ConnectAndValidateServer, display joinLoading UI events, schedule validated session launch on the game task thread, and centralize join success/failure handling.
- Wire SessionLaunch into main.cpp (update loop and F4SE message hook).
- Improve direct-connect UI in app.js with client-side validation, port parsing, and handling of joinLoading events.

These changes provide better connection validation, user feedback during joins, and automatic handling for first-time server joins with persistence of associated saves.
This commit is contained in:
2026-06-09 16:26:33 +12:00
parent e6748556ae
commit e61922678b
9 changed files with 963 additions and 47 deletions
+1
View File
@@ -8,6 +8,7 @@ namespace F4T::Networking
std::string GetLocalPlayerLogPrefix();
bool ConnectToServer(const char* a_host, std::uint16_t a_port);
bool ConnectToLocalServer();
bool ConnectAndValidateServer(const char* a_host, std::uint16_t a_port, std::string& a_error);
void DisconnectFromLocalServer();
bool IsConnectedToServer();
bool SendTransformPacket(
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include <cstdint>
#include <optional>
#include <string>
namespace F4T::ServerProfileStore
{
std::string MakeServerKey(const std::string& a_host, std::uint16_t a_port);
bool HasProfile(const std::string& a_serverKey);
std::optional<std::string> LookupSaveFile(const std::string& a_serverKey);
void BindSaveFile(const std::string& a_serverKey, const std::string& a_saveFile);
void TouchProfile(const std::string& a_serverKey);
}
+16
View File
@@ -0,0 +1,16 @@
#pragma once
#include "F4TServerBrowserData.h"
namespace F4SE
{
class MessagingInterface;
}
namespace F4T::SessionLaunch
{
bool LaunchValidatedSession(const ServerBrowser::ServerEntry& a_entry, std::string& a_error);
void OnF4SEMessage(F4SE::MessagingInterface::Message* a_msg);
void Update();
void TryCompletePendingProfileBind();
}
+38 -1
View File
@@ -671,6 +671,43 @@ namespace F4T::Networking
return ConnectToServer(kLocalServerHost, kLocalServerPort);
}
bool ConnectAndValidateServer(const char* a_host, std::uint16_t a_port, std::string& a_error)
{
if (!a_host || a_host[0] == '\0') {
a_error = "Server address is required.";
return false;
}
if (IsConnectedToServer()) {
DisconnectFromLocalServer();
}
g_lastConnectAttempt = std::chrono::steady_clock::time_point{};
if (!ConnectToServer(a_host, a_port)) {
if (std::string_view{ a_host } == kLocalServerHost && a_port == kLocalServerPort) {
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 {}:{}", a_host, a_port);
}
return false;
}
constexpr auto kWelcomeTimeout = 3s;
const auto deadline = std::chrono::steady_clock::now() + kWelcomeTimeout;
while (std::chrono::steady_clock::now() < deadline) {
if (F4T::RemotePlayerState::GetAssignedPlayerId().has_value()) {
return true;
}
std::this_thread::sleep_for(25ms);
}
DisconnectFromLocalServer();
a_error = "Server did not respond with a valid Commonwealth Online handshake.";
return false;
}
void DisconnectFromLocalServer()
{
StopReceiveThread();
@@ -709,7 +746,7 @@ namespace F4T::Networking
float a_movementSpeed,
float a_animationGraphSpeed)
{
if (!IsConnectedToServer() && !ConnectToLocalServer()) {
if (!IsConnectedToServer()) {
return false;
}
+119 -41
View File
@@ -4,8 +4,10 @@
#include "F4TPrismaUI.h"
#include "F4TServerBrowserBridge.h"
#include "F4TServerBrowserData.h"
#include "F4TSessionLaunch.h"
#include <algorithm>
#include <charconv>
#include <format>
#include <string_view>
@@ -112,6 +114,81 @@ namespace F4T::ServerBrowser
}
}
std::optional<std::uint16_t> ParsePortValue(const nlohmann::json& a_value)
{
if (a_value.is_number_unsigned() || a_value.is_number_integer()) {
const auto port = a_value.get<std::int64_t>();
if (port >= 1 && port <= 65535) {
return static_cast<std::uint16_t>(port);
}
return std::nullopt;
}
if (a_value.is_string()) {
const auto text = a_value.get<std::string>();
std::uint32_t port = 0;
const auto* begin = text.data();
const auto* end = begin + text.size();
const auto result = std::from_chars(begin, end, port);
if (result.ec == std::errc{} && result.ptr == end && port >= 1 && port <= 65535) {
return static_cast<std::uint16_t>(port);
}
}
return std::nullopt;
}
bool PopulateJoinEntry(ServerEntry& a_entry, const nlohmann::json& a_action, std::string& a_error)
{
a_entry.host = a_action.value("address", a_entry.host);
if (a_entry.host.empty()) {
a_entry.host = "127.0.0.1";
}
if (a_action.contains("port")) {
const auto port = ParsePortValue(a_action.at("port"));
if (!port) {
a_error = "Port must be a number between 1 and 65535.";
return false;
}
a_entry.port = *port;
} else if (a_entry.port <= 0) {
a_entry.port = 7777;
}
return true;
}
void CompleteJoinSuccess(const ServerEntry& a_entry)
{
PushConnectionStatusEvent();
DispatchEvent(nlohmann::json{
{ "type", "joinSucceeded" },
{ "serverId", a_entry.id }
});
DispatchEvent(nlohmann::json{
{ "type", "uiSound" },
{ "sound", "joinSuccess" }
});
LogInfo(std::format(" Joined server '{}'.", a_entry.name));
}
void CompleteJoinFailure(const ServerEntry& a_entry, std::string_view a_reason)
{
F4T::Networking::DisconnectFromLocalServer();
PushConnectionStatusEvent();
DispatchEvent(nlohmann::json{
{ "type", "joinFailed" },
{ "serverId", a_entry.id },
{ "reason", a_reason }
});
DispatchEvent(nlohmann::json{
{ "type", "uiSound" },
{ "sound", "joinFailed" }
});
LogWarning(std::format(" Join failed for '{}': {}", a_entry.name, a_reason));
}
bool JoinServerEntry(const ServerEntry& a_entry, std::string& a_error)
{
DispatchEvent(nlohmann::json{
@@ -127,15 +204,41 @@ namespace F4T::ServerBrowser
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);
}
DispatchEvent(nlohmann::json{
{ "type", "joinLoading" },
{ "serverId", a_entry.id },
{ "message", "Validating server..." }
});
if (!F4T::Networking::ConnectAndValidateServer(host.c_str(), port, a_error)) {
return false;
}
DispatchEvent(nlohmann::json{
{ "type", "joinLoading" },
{ "serverId", a_entry.id },
{ "message", "Loading game..." }
});
const auto* taskInterface = F4SE::GetTaskInterface();
if (!taskInterface) {
a_error = "Game task interface is unavailable.";
F4T::Networking::DisconnectFromLocalServer();
return false;
}
const auto entryCopy = a_entry;
taskInterface->AddTask([entryCopy]() {
std::string launchError;
if (!F4T::SessionLaunch::LaunchValidatedSession(entryCopy, launchError)) {
CompleteJoinFailure(entryCopy, launchError);
return;
}
CompleteJoinSuccess(entryCopy);
F4T::PrismaUI::HideServerBrowser();
});
return true;
}
@@ -251,28 +354,8 @@ namespace F4T::ServerBrowser
}
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));
if (!JoinServerEntry(*entry, error)) {
CompleteJoinFailure(*entry, error);
}
return;
}
@@ -282,21 +365,16 @@ namespace F4T::ServerBrowser
entry.id = "direct";
entry.name = "Direct Connect";
entry.host = a_action.value("address", "127.0.0.1");
entry.port = a_action.value("port", 7777);
entry.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 }
});
if (!PopulateJoinEntry(entry, a_action, error)) {
CompleteJoinFailure(entry, error);
return;
}
if (!JoinServerEntry(entry, error)) {
CompleteJoinFailure(entry, error);
}
return;
}
+174
View File
@@ -0,0 +1,174 @@
#include "pch.h"
#include "F4TServerProfileStore.h"
#include "REX/W32/OLE32.h"
#include "REX/W32/SHELL32.h"
#include <filesystem>
#include <fstream>
#include <format>
#include <mutex>
#include <nlohmann/json.hpp>
namespace
{
std::mutex g_storeMutex;
nlohmann::json g_profiles = nlohmann::json::object();
bool g_loaded = false;
std::filesystem::path GetProfilesPath()
{
wchar_t* knownBuffer{ nullptr };
const auto knownResult =
REX::W32::SHGetKnownFolderPath(REX::W32::FOLDERID_Documents, REX::W32::KF_FLAG_DEFAULT, nullptr, std::addressof(knownBuffer));
std::unique_ptr<wchar_t[], decltype(&REX::W32::CoTaskMemFree)> knownPath(knownBuffer, REX::W32::CoTaskMemFree);
if (!knownPath || knownResult != 0) {
return {};
}
auto path = std::filesystem::path(knownPath.get());
path /= std::format("My Games/{}/CommonwealthOnline", F4SE::GetSaveFolderName());
path /= "server-profiles.json";
return path;
}
void EnsureLoaded()
{
if (g_loaded) {
return;
}
g_loaded = true;
const auto path = GetProfilesPath();
if (path.empty()) {
return;
}
std::ifstream input(path);
if (!input.is_open()) {
return;
}
try {
input >> g_profiles;
if (!g_profiles.is_object()) {
g_profiles = nlohmann::json::object();
}
} catch (const std::exception&) {
g_profiles = nlohmann::json::object();
}
}
void SaveProfiles()
{
const auto path = GetProfilesPath();
if (path.empty()) {
return;
}
std::error_code ec;
std::filesystem::create_directories(path.parent_path(), ec);
std::ofstream output(path);
if (!output.is_open()) {
return;
}
output << g_profiles.dump(2);
}
}
namespace F4T::ServerProfileStore
{
std::string MakeServerKey(const std::string& a_host, const std::uint16_t a_port)
{
const auto host = a_host.empty() ? "127.0.0.1" : a_host;
return std::format("{}:{}", host, a_port);
}
bool HasProfile(const std::string& a_serverKey)
{
const std::scoped_lock lock(g_storeMutex);
EnsureLoaded();
const auto servers = g_profiles.find("servers");
if (servers == g_profiles.end() || !servers->is_object()) {
return false;
}
const auto entry = servers->find(a_serverKey);
return entry != servers->end() && entry->is_object() && entry->contains("saveFile");
}
std::optional<std::string> LookupSaveFile(const std::string& a_serverKey)
{
const std::scoped_lock lock(g_storeMutex);
EnsureLoaded();
const auto servers = g_profiles.find("servers");
if (servers == g_profiles.end() || !servers->is_object()) {
return std::nullopt;
}
const auto entry = servers->find(a_serverKey);
if (entry == servers->end() || !entry->is_object()) {
return std::nullopt;
}
const auto saveFile = entry->find("saveFile");
if (saveFile == entry->end() || !saveFile->is_string() || saveFile->get<std::string>().empty()) {
return std::nullopt;
}
return saveFile->get<std::string>();
}
void BindSaveFile(const std::string& a_serverKey, const std::string& a_saveFile)
{
if (a_serverKey.empty() || a_saveFile.empty()) {
return;
}
const std::scoped_lock lock(g_storeMutex);
EnsureLoaded();
if (!g_profiles.contains("servers") || !g_profiles["servers"].is_object()) {
g_profiles["servers"] = nlohmann::json::object();
}
const auto now = std::chrono::system_clock::now();
const auto epochSeconds =
std::chrono::duration_cast<std::chrono::seconds>(now.time_since_epoch()).count();
g_profiles["servers"][a_serverKey] = nlohmann::json{
{ "saveFile", a_saveFile },
{ "lastJoined", epochSeconds }
};
SaveProfiles();
}
void TouchProfile(const std::string& a_serverKey)
{
const std::scoped_lock lock(g_storeMutex);
EnsureLoaded();
const auto servers = g_profiles.find("servers");
if (servers == g_profiles.end() || !servers->is_object()) {
return;
}
const auto entry = servers->find(a_serverKey);
if (entry == servers->end() || !entry->is_object()) {
return;
}
const auto now = std::chrono::system_clock::now();
const auto epochSeconds =
std::chrono::duration_cast<std::chrono::seconds>(now.time_since_epoch()).count();
(*entry)["lastJoined"] = epochSeconds;
SaveProfiles();
}
}
+572
View File
@@ -0,0 +1,572 @@
#include "pch.h"
#include "F4TSessionLaunch.h"
#include "F4TNetworking.h"
#include "F4TServerProfileStore.h"
#include "RE/B/BGSSaveLoadManager.h"
#include "RE/C/Console.h"
#include "RE/G/GameScript.h"
#include "RE/L/LoadingMenu.h"
#include "RE/M/MainMenu.h"
#include "RE/P/PlayerCharacter.h"
#include "RE/T/TESWorldSpace.h"
#include "RE/U/UI.h"
#include <cmath>
#include <cstring>
#include <format>
#include <optional>
namespace
{
constexpr std::uint32_t kCommonwealthWorldspaceFormId = 0x0000003C;
constexpr std::uint32_t kSanctuaryExtCellFormId = 0x0000DD60;
constexpr int kSpawnPollIntervalFrames = 15;
constexpr int kMaxSpawnWaitAttempts = 600;
constexpr int kWorldReadyDelayPolls = 10;
enum class CharGenPhase
{
None,
CocLoading,
WaitingForWorldReady,
OpeningLooksMenu,
WaitingForLooksMenuClose,
OpeningSpecialMenu,
WaitingForSpecialMenuClose,
Complete
};
std::string g_pendingProfileBindServerKey;
CharGenPhase g_charGenPhase = CharGenPhase::None;
bool g_pendingProfileBind = false;
bool g_cocIssued = false;
bool g_sawLooksMenuOpen = false;
bool g_sawSpecialMenuOpen = false;
int g_spawnPollFrameCounter = 0;
int g_spawnWaitAttempts = 0;
int g_worldReadyPolls = 0;
std::string GetLogPrefix()
{
return F4T::Networking::GetLocalPlayerLogPrefix() + " [SessionLaunch]";
}
void LogInfo(std::string_view a_message)
{
REX::INFO("{} {}", GetLogPrefix(), a_message);
}
void LogWarning(std::string_view a_message)
{
REX::WARN("{} {}", GetLogPrefix(), a_message);
}
bool IsMainMenuOpen()
{
const auto* ui = RE::UI::GetSingleton();
return ui && ui->GetMenuOpen<RE::MainMenu>();
}
bool IsLoadingScreenActive()
{
const auto* ui = RE::UI::GetSingleton();
return ui && ui->GetMenuOpen<RE::LoadingMenu>();
}
bool IsPlaceholderSpawnPosition(const RE::PlayerCharacter& a_player)
{
const auto pos = a_player.GetPosition();
return std::abs(pos.x - 2048.0F) < 1.0F && std::abs(pos.y - 2048.0F) < 1.0F && std::abs(pos.z) < 1.0F;
}
bool IsWorldReadyForCharGen(const RE::PlayerCharacter& a_player)
{
if (IsLoadingScreenActive() || IsMainMenuOpen()) {
return false;
}
if (IsPlaceholderSpawnPosition(a_player)) {
return false;
}
const auto* cell = a_player.GetParentCell();
if (!cell) {
return false;
}
return true;
}
RE::MainMenu* GetMainMenu()
{
const auto* ui = RE::UI::GetSingleton();
return ui ? ui->GetMenu<RE::MainMenu>().get() : nullptr;
}
bool IsMenuOpen(const char* a_menuName)
{
const auto* ui = RE::UI::GetSingleton();
return ui && ui->GetMenuOpen(a_menuName);
}
bool IsPlayerInSanctuaryExt(const RE::PlayerCharacter& a_player)
{
if (IsPlaceholderSpawnPosition(a_player)) {
return false;
}
const auto* parentCell = a_player.GetParentCell();
if (!parentCell) {
return false;
}
if (parentCell->GetFormID() == kSanctuaryExtCellFormId) {
return true;
}
if (!parentCell->IsExterior()) {
return false;
}
const auto* worldspace = parentCell->worldSpace ? parentCell->worldSpace : a_player.cachedWorldspace;
return worldspace && worldspace->GetFormID() == kCommonwealthWorldspaceFormId;
}
void LogPlayerLocation(std::string_view a_label, const RE::PlayerCharacter& a_player)
{
const auto* cell = a_player.GetParentCell();
const auto cellId = cell ? cell->GetFormID() : 0U;
const auto pos = a_player.GetPosition();
const auto* worldspace = cell && cell->worldSpace ? cell->worldSpace : a_player.cachedWorldspace;
const auto worldId = worldspace ? worldspace->GetFormID() : 0U;
LogInfo(std::format(
"{} cell={:08X} world={:08X} exterior={} pos=({:.1f},{:.1f},{:.1f}) looksMenu={} specialMenu={}.",
a_label,
cellId,
worldId,
cell && cell->IsExterior(),
pos.x,
pos.y,
pos.z,
IsMenuOpen("LooksMenu"),
IsMenuOpen("SPECIALMenu")));
}
std::int32_t FindSaveIndexByFileName(const std::string& a_saveFile)
{
auto* saveLoadManager = RE::BGSSaveLoadManager::GetSingleton();
if (!saveLoadManager) {
return -1;
}
saveLoadManager->BuildSaveGameList(0);
for (std::uint32_t i = 0; i < saveLoadManager->saveGameCount; ++i) {
if (i >= saveLoadManager->saveGameList.size()) {
break;
}
const auto* entry = saveLoadManager->saveGameList[i];
if (!entry || !entry->fileName) {
continue;
}
if (_stricmp(entry->fileName, a_saveFile.c_str()) == 0) {
return static_cast<std::int32_t>(i);
}
}
return -1;
}
RE::BGSSaveLoadFileEntry* FindSaveEntryByFileName(const std::string& a_saveFile)
{
auto* saveLoadManager = RE::BGSSaveLoadManager::GetSingleton();
if (!saveLoadManager) {
return nullptr;
}
saveLoadManager->BuildSaveGameList(0);
for (std::uint32_t i = 0; i < saveLoadManager->saveGameCount; ++i) {
if (i >= saveLoadManager->saveGameList.size()) {
break;
}
auto* entry = saveLoadManager->saveGameList[i];
if (!entry || !entry->fileName) {
continue;
}
if (_stricmp(entry->fileName, a_saveFile.c_str()) == 0) {
return entry;
}
}
return nullptr;
}
bool LoadSaveFromMainMenu(const std::string& a_saveFile, std::string& a_error)
{
const auto saveIndex = FindSaveIndexByFileName(a_saveFile);
if (saveIndex < 0) {
a_error = std::format("Associated save '{}' was not found.", a_saveFile);
return false;
}
auto* mainMenu = GetMainMenu();
if (!mainMenu) {
a_error = "Main menu is not available to load the associated save.";
return false;
}
mainMenu->DoLoadGame(saveIndex);
return true;
}
bool LoadSaveInGame(const std::string& a_saveFile, std::string& a_error)
{
auto* saveEntry = FindSaveEntryByFileName(a_saveFile);
if (!saveEntry) {
a_error = std::format("Associated save '{}' was not found.", a_saveFile);
return false;
}
auto* saveLoadManager = RE::BGSSaveLoadManager::GetSingleton();
if (!saveLoadManager) {
a_error = "Save/load manager is unavailable.";
return false;
}
saveLoadManager->queuedEntryToLoad = saveEntry;
saveLoadManager->QueueSaveLoadTask(RE::BGSSaveLoadManager::QUEUED_TASK::kLoadGame);
return true;
}
void ResetFirstJoinState()
{
g_charGenPhase = CharGenPhase::None;
g_pendingProfileBind = false;
g_pendingProfileBindServerKey.clear();
g_cocIssued = false;
g_sawLooksMenuOpen = false;
g_sawSpecialMenuOpen = false;
g_spawnPollFrameCounter = 0;
g_spawnWaitAttempts = 0;
g_worldReadyPolls = 0;
}
void QueueFirstTimeCocJoin(const std::string& a_serverKey)
{
g_charGenPhase = CharGenPhase::CocLoading;
g_pendingProfileBind = true;
g_pendingProfileBindServerKey = a_serverKey;
g_cocIssued = false;
g_sawLooksMenuOpen = false;
g_sawSpecialMenuOpen = false;
g_spawnPollFrameCounter = 0;
g_spawnWaitAttempts = 0;
g_worldReadyPolls = 0;
LogInfo(std::format("Queued first-time coc join for server {}.", a_serverKey));
}
void IssueCocSanctuaryExt()
{
if (g_cocIssued) {
return;
}
g_cocIssued = true;
RE::Console::ExecuteCommand("coc SanctuaryExt");
LogInfo("Issued coc SanctuaryExt from main menu.");
}
void OpenLooksMenu()
{
auto* gameVM = RE::GameVM::GetSingleton();
auto* player = RE::PlayerCharacter::GetSingleton();
if (gameVM && player) {
const auto vm = gameVM->GetVM();
if (vm) {
vm->DispatchMethodCall(
static_cast<std::uint64_t>(player->GetHandle().get_handle()),
"Actor",
"ShowLooksMenu",
nullptr);
LogInfo("Dispatched Actor.ShowLooksMenu for first-time character creation.");
return;
}
}
RE::Console::ExecuteCommand("CallFunction Actor ShowLooksMenu player");
LogInfo("Fell back to CallFunction Actor ShowLooksMenu player.");
}
void OpenSpecialMenu()
{
RE::Console::ExecuteCommand("ShowSPECIALMenu");
LogInfo("Opened SPECIALMenu for name and stats.");
}
std::optional<std::string> ResolveCurrentSaveFileName()
{
auto* saveLoadManager = RE::BGSSaveLoadManager::GetSingleton();
if (!saveLoadManager) {
return std::nullopt;
}
if (saveLoadManager->mostRecentSaveGame && saveLoadManager->mostRecentSaveGame[0] != '\0') {
return std::string{ saveLoadManager->mostRecentSaveGame };
}
saveLoadManager->BuildSaveGameList(0);
if (saveLoadManager->saveGameCount == 0 || saveLoadManager->saveGameList.empty()) {
return std::nullopt;
}
const auto index = saveLoadManager->saveGameCount - 1;
if (index >= saveLoadManager->saveGameList.size()) {
return std::nullopt;
}
const auto* entry = saveLoadManager->saveGameList[index];
if (!entry || !entry->fileName || entry->fileName[0] == '\0') {
return std::nullopt;
}
return std::string{ entry->fileName };
}
void QueueProfileSave()
{
auto* saveLoadManager = RE::BGSSaveLoadManager::GetSingleton();
if (saveLoadManager) {
saveLoadManager->QueueSaveLoadTask(RE::BGSSaveLoadManager::QUEUED_TASK::kQuickSave);
LogInfo("Queued quicksave after character creation.");
}
}
void CompleteFirstTimeJoin(RE::PlayerCharacter& a_player)
{
LogPlayerLocation("First-time join complete after character creation", a_player);
g_charGenPhase = CharGenPhase::Complete;
g_spawnWaitAttempts = 0;
QueueProfileSave();
}
void TryFirstJoinFlowOnGameThread()
{
if (g_charGenPhase == CharGenPhase::None || g_charGenPhase == CharGenPhase::Complete) {
return;
}
if (g_charGenPhase == CharGenPhase::CocLoading) {
if (!g_cocIssued) {
IssueCocSanctuaryExt();
}
const auto* player = RE::PlayerCharacter::GetSingleton();
if (!player || !IsPlayerInSanctuaryExt(*player)) {
if (++g_spawnWaitAttempts >= kMaxSpawnWaitAttempts) {
LogWarning("Timed out waiting for coc SanctuaryExt to finish loading.");
ResetFirstJoinState();
}
return;
}
g_charGenPhase = CharGenPhase::WaitingForWorldReady;
g_spawnWaitAttempts = 0;
g_worldReadyPolls = 0;
LogPlayerLocation("SanctuaryExt loaded, waiting for loading screen to finish", *player);
return;
}
if (g_charGenPhase == CharGenPhase::WaitingForWorldReady) {
const auto* player = RE::PlayerCharacter::GetSingleton();
if (!player || !IsPlayerInSanctuaryExt(*player)) {
return;
}
if (!IsWorldReadyForCharGen(*player)) {
return;
}
if (++g_worldReadyPolls < kWorldReadyDelayPolls) {
return;
}
g_charGenPhase = CharGenPhase::OpeningLooksMenu;
LogPlayerLocation("World ready, opening character creation", *player);
return;
}
if (g_charGenPhase == CharGenPhase::OpeningLooksMenu) {
OpenLooksMenu();
g_charGenPhase = CharGenPhase::WaitingForLooksMenuClose;
return;
}
if (g_charGenPhase == CharGenPhase::WaitingForLooksMenuClose) {
if (IsMenuOpen("LooksMenu")) {
g_sawLooksMenuOpen = true;
return;
}
if (!g_sawLooksMenuOpen) {
return;
}
g_charGenPhase = CharGenPhase::OpeningSpecialMenu;
return;
}
if (g_charGenPhase == CharGenPhase::OpeningSpecialMenu) {
OpenSpecialMenu();
g_charGenPhase = CharGenPhase::WaitingForSpecialMenuClose;
return;
}
if (g_charGenPhase == CharGenPhase::WaitingForSpecialMenuClose) {
if (IsMenuOpen("SPECIALMenu")) {
g_sawSpecialMenuOpen = true;
return;
}
if (!g_sawSpecialMenuOpen) {
return;
}
auto* player = RE::PlayerCharacter::GetSingleton();
if (!player) {
return;
}
CompleteFirstTimeJoin(*player);
}
}
bool IsFirstJoinInProgress()
{
return g_charGenPhase != CharGenPhase::None && g_charGenPhase != CharGenPhase::Complete;
}
}
namespace F4T::SessionLaunch
{
bool LaunchValidatedSession(const ServerBrowser::ServerEntry& a_entry, std::string& a_error)
{
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);
const auto serverKey = ServerProfileStore::MakeServerKey(host, port);
const bool onMainMenu = IsMainMenuOpen();
const bool inGame = RE::PlayerCharacter::GetSingleton() != nullptr;
const bool hasProfile = ServerProfileStore::HasProfile(serverKey);
if (!onMainMenu && !inGame) {
a_error = "Game is not ready to launch a session.";
return false;
}
if (!onMainMenu && !hasProfile) {
a_error = "Return to the main menu to create a new character for this server.";
return false;
}
if (hasProfile) {
const auto saveFile = ServerProfileStore::LookupSaveFile(serverKey);
if (!saveFile) {
a_error = "Server profile exists but has no associated save file.";
return false;
}
ServerProfileStore::TouchProfile(serverKey);
if (onMainMenu) {
if (!LoadSaveFromMainMenu(*saveFile, a_error)) {
return false;
}
} else if (!LoadSaveInGame(*saveFile, a_error)) {
return false;
}
LogInfo(std::format("Loading associated save '{}' for server {}.", *saveFile, serverKey));
return true;
}
if (!onMainMenu) {
a_error = "Return to the main menu to join this server for the first time.";
return false;
}
QueueFirstTimeCocJoin(serverKey);
IssueCocSanctuaryExt();
return true;
}
void Update()
{
if (!IsFirstJoinInProgress() && !g_pendingProfileBind) {
return;
}
if (IsFirstJoinInProgress()) {
if (++g_spawnPollFrameCounter % kSpawnPollIntervalFrames != 0) {
return;
}
TryFirstJoinFlowOnGameThread();
}
TryCompletePendingProfileBind();
}
void TryCompletePendingProfileBind()
{
if (g_pendingProfileBindServerKey.empty() || !g_pendingProfileBind) {
return;
}
if (IsFirstJoinInProgress()) {
return;
}
const auto saveFileName = ResolveCurrentSaveFileName();
if (!saveFileName) {
return;
}
ServerProfileStore::BindSaveFile(g_pendingProfileBindServerKey, *saveFileName);
g_pendingProfileBindServerKey.clear();
g_pendingProfileBind = false;
LogInfo(std::format("Bound server profile to save '{}'.", *saveFileName));
}
void OnF4SEMessage(F4SE::MessagingInterface::Message* a_msg)
{
if (!a_msg) {
return;
}
switch (a_msg->type) {
case F4SE::MessagingInterface::kPostLoadGame:
case F4SE::MessagingInterface::kGameLoaded:
if (IsFirstJoinInProgress()) {
const auto* taskInterface = F4SE::GetTaskInterface();
if (taskInterface) {
taskInterface->AddTask([]() {
TryFirstJoinFlowOnGameThread();
});
}
}
TryCompletePendingProfileBind();
break;
default:
break;
}
}
}
+3 -4
View File
@@ -1,6 +1,7 @@
#include "F4TNetworking.h"
#include "F4TLocalAnimationGraphDebug.h"
#include "F4TPrismaUI.h"
#include "F4TSessionLaunch.h"
#include "F4TScaleformUI.h"
#include "F4TProxyActorController.h"
#include "F4TProxyAnimationSync.h"
@@ -626,6 +627,7 @@ namespace
taskInterface->AddTaskPermanent([]() {
PollLocalPlayerTransform();
F4T::ProxyActorController::Update();
F4T::SessionLaunch::Update();
});
pollingStarted = true;
@@ -647,6 +649,7 @@ namespace
// Plugin load happens before a save/new game has finished creating the player.
// These messages are the minimal safe timing point to start polling for movement.
StartPlayerPositionPolling();
F4T::SessionLaunch::OnF4SEMessage(a_msg);
break;
default:
break;
@@ -662,10 +665,6 @@ F4SE_PLUGIN_LOAD(const F4SE::LoadInterface* a_f4se)
LogInfoWithLocalPlayerPrefix("Commonwealth Online plugin loaded successfully.");
// TODO: Consider separate per-instance log files once launcher/profile support exists.
// Networking is intentionally localhost-only for the first milestone. If the
// test server is not running, the game must continue normally and movement
// logging still provides useful debugging data.
F4T::Networking::ConnectToLocalServer();
std::atexit(F4T::Networking::DisconnectFromLocalServer);
const auto* messaging = F4SE::GetMessagingInterface();