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:
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user