Improve server profile resolution and save handling

Add host normalization and ResolveServerKey to match equivalent endpoints (localhost/127.0.0.1, bracketed IPv6, etc.) so reconnects reuse existing server profiles. Harden profile storage: sanitize save-folder components, use Documents fallback via _dupenv_s when needed, log write/open failures, and fix Windows build issues from unsafe getenv usage. Make session launch resilient to missing or rotated exit saves by accepting Exitsave... names, refreshing to the newest exit save, and falling back to first-time flow from the main menu (instead of hard-failing). Update session launch to prefer resolved profile keys, touch/bind profiles appropriately, and improve related load error handling. Also add a simple Creation Kit quest script (CO_Vault109StartQuestScript) and update changelog/docs to document these changes.
This commit is contained in:
2026-06-26 15:29:16 +12:00
parent 442cc06020
commit 8772e024f8
6 changed files with 399 additions and 27 deletions
+1
View File
@@ -7,6 +7,7 @@
namespace F4T::ServerProfileStore
{
std::string MakeServerKey(const std::string& a_host, std::uint16_t a_port);
std::optional<std::string> ResolveServerKey(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);
+185 -6
View File
@@ -5,6 +5,9 @@
#include "REX/W32/OLE32.h"
#include "REX/W32/SHELL32.h"
#include <charconv>
#include <cctype>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <format>
@@ -17,19 +20,166 @@ namespace
std::mutex g_storeMutex;
nlohmann::json g_profiles = nlohmann::json::object();
bool g_loaded = false;
bool g_warnedNoProfilesPath = false;
std::filesystem::path GetProfilesPath()
void LogStoreWarning(std::string_view a_message)
{
REX::WARN("[ServerProfileStore] {}", a_message);
}
std::filesystem::path GetDocumentsPath()
{
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) {
if (knownPath && knownResult == 0) {
return std::filesystem::path(knownPath.get());
}
char* userProfileRaw = nullptr;
std::size_t userProfileLen = 0;
if (_dupenv_s(&userProfileRaw, &userProfileLen, "USERPROFILE") == 0 && userProfileRaw && userProfileRaw[0] != '\0') {
std::unique_ptr<char[], decltype(&std::free)> userProfile(userProfileRaw, &std::free);
return std::filesystem::path(userProfile.get()) / "Documents";
}
return {};
}
std::string SanitizeFolderComponent(std::string_view a_name, std::string_view a_fallback)
{
if (a_name.empty()) {
return std::string{ a_fallback };
}
std::string sanitized{ a_name };
for (auto& ch : sanitized) {
if (ch == '\\' || ch == '/' || ch == ':' || ch == '*' || ch == '?' || ch == '"' || ch == '<' || ch == '>' || ch == '|') {
ch = '_';
}
}
std::size_t start = 0;
while (start < sanitized.size() && std::isspace(static_cast<unsigned char>(sanitized[start])) != 0) {
++start;
}
std::size_t end = sanitized.size();
while (end > start && std::isspace(static_cast<unsigned char>(sanitized[end - 1])) != 0) {
--end;
}
if (start >= end) {
return std::string{ a_fallback };
}
return sanitized.substr(start, end - start);
}
std::string TrimAscii(std::string_view a_text)
{
std::size_t start = 0;
while (start < a_text.size() && std::isspace(static_cast<unsigned char>(a_text[start])) != 0) {
++start;
}
std::size_t end = a_text.size();
while (end > start && std::isspace(static_cast<unsigned char>(a_text[end - 1])) != 0) {
--end;
}
return std::string{ a_text.substr(start, end - start) };
}
std::string ToLowerAscii(std::string a_text)
{
for (auto& ch : a_text) {
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
}
return a_text;
}
std::string NormalizeHost(std::string_view a_host)
{
auto host = ToLowerAscii(TrimAscii(a_host));
if (host.empty()) {
return "127.0.0.1";
}
if (host.size() > 2 && host.front() == '[' && host.back() == ']') {
host = host.substr(1, host.size() - 2);
}
if (host == "localhost" || host == "::1" || host == "0:0:0:0:0:0:0:1" || host == "127.0.0.1") {
return "127.0.0.1";
}
if (host.rfind("::ffff:", 0) == 0) {
const auto mappedHost = host.substr(7);
if (mappedHost == "127.0.0.1") {
return "127.0.0.1";
}
}
return host;
}
bool TryParseServerKey(std::string_view a_serverKey, std::string& a_host, std::uint16_t& a_port)
{
const auto separator = a_serverKey.rfind(':');
if (separator == std::string_view::npos || separator == 0 || separator + 1 >= a_serverKey.size()) {
return false;
}
const auto hostPart = a_serverKey.substr(0, separator);
const auto portPart = a_serverKey.substr(separator + 1);
std::uint32_t parsedPort = 0;
const auto* begin = portPart.data();
const auto* end = begin + portPart.size();
const auto result = std::from_chars(begin, end, parsedPort);
if (result.ec != std::errc{} || result.ptr != end || parsedPort == 0 || parsedPort > 65535) {
return false;
}
a_host = NormalizeHost(hostPart);
a_port = static_cast<std::uint16_t>(parsedPort);
return true;
}
bool IsValidProfileEntry(const nlohmann::json& a_entry)
{
return a_entry.is_object();
}
bool ServerKeyMatches(const std::string& a_existingKey, const std::string& a_host, std::uint16_t a_port)
{
std::string existingHost;
std::uint16_t existingPort = 0;
if (!TryParseServerKey(a_existingKey, existingHost, existingPort)) {
return false;
}
return existingPort == a_port && existingHost == NormalizeHost(a_host);
}
std::filesystem::path GetProfilesPath()
{
auto documentsPath = GetDocumentsPath();
if (documentsPath.empty()) {
if (!g_warnedNoProfilesPath) {
LogStoreWarning("Could not resolve Documents path for server profile storage.");
g_warnedNoProfilesPath = true;
}
return {};
}
auto path = std::filesystem::path(knownPath.get());
path /= std::format("My Games/{}/CommonwealthOnline", F4SE::GetSaveFolderName());
const auto saveFolderName = SanitizeFolderComponent(F4SE::GetSaveFolderName(), "Fallout4");
auto path = std::move(documentsPath);
path /= "My Games";
path /= saveFolderName;
path /= "CommonwealthOnline";
path /= "server-profiles.json";
return path;
}
@@ -73,6 +223,7 @@ namespace
std::ofstream output(path);
if (!output.is_open()) {
LogStoreWarning(std::format("Failed to open server profile store for write: {}", path.string()));
return;
}
@@ -84,8 +235,36 @@ 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);
return std::format("{}:{}", NormalizeHost(a_host), a_port);
}
std::optional<std::string> ResolveServerKey(const std::string& a_host, std::uint16_t a_port)
{
const auto preferredKey = MakeServerKey(a_host, a_port);
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 preferredEntry = servers->find(preferredKey);
if (preferredEntry != servers->end() && IsValidProfileEntry(*preferredEntry)) {
return preferredKey;
}
for (auto entryIt = servers->begin(); entryIt != servers->end(); ++entryIt) {
if (!IsValidProfileEntry(entryIt.value())) {
continue;
}
if (ServerKeyMatches(entryIt.key(), a_host, a_port)) {
return entryIt.key();
}
}
return std::nullopt;
}
bool HasProfile(const std::string& a_serverKey)
+120 -21
View File
@@ -112,6 +112,11 @@ namespace
return ui && ui->GetMenuOpen(a_menuName);
}
bool IsExitSaveName(std::string_view a_saveName)
{
return a_saveName.size() >= 8 && _strnicmp(a_saveName.data(), "Exitsave", 8) == 0;
}
bool IsPlayerInSanctuaryExt(const RE::PlayerCharacter& a_player)
{
if (IsPlaceholderSpawnPosition(a_player)) {
@@ -328,17 +333,52 @@ namespace
return std::nullopt;
}
const auto index = saveLoadManager->saveGameCount - 1;
if (index >= saveLoadManager->saveGameList.size()) {
for (std::int32_t i = static_cast<std::int32_t>(saveLoadManager->saveGameCount) - 1; i >= 0; --i) {
const auto index = static_cast<std::uint32_t>(i);
if (index >= saveLoadManager->saveGameList.size()) {
continue;
}
const auto* entry = saveLoadManager->saveGameList[index];
if (!entry || !entry->fileName || entry->fileName[0] == '\0') {
continue;
}
return std::string{ entry->fileName };
}
return std::nullopt;
}
std::optional<std::string> ResolveLatestExitSaveFileName()
{
auto* saveLoadManager = RE::BGSSaveLoadManager::GetSingleton();
if (!saveLoadManager) {
return std::nullopt;
}
const auto* entry = saveLoadManager->saveGameList[index];
if (!entry || !entry->fileName || entry->fileName[0] == '\0') {
saveLoadManager->BuildSaveGameList(0);
if (saveLoadManager->saveGameCount == 0 || saveLoadManager->saveGameList.empty()) {
return std::nullopt;
}
return std::string{ entry->fileName };
for (std::int32_t i = static_cast<std::int32_t>(saveLoadManager->saveGameCount) - 1; i >= 0; --i) {
const auto index = static_cast<std::uint32_t>(i);
if (index >= saveLoadManager->saveGameList.size()) {
continue;
}
const auto* entry = saveLoadManager->saveGameList[index];
if (!entry || !entry->fileName || entry->fileName[0] == '\0') {
continue;
}
const std::string candidate{ entry->fileName };
if (IsExitSaveName(candidate)) {
return candidate;
}
}
return std::nullopt;
}
void QueueProfileSave()
@@ -453,6 +493,11 @@ namespace
{
return g_charGenPhase != CharGenPhase::None && g_charGenPhase != CharGenPhase::Complete;
}
bool IsMissingAssociatedSaveError(std::string_view a_error)
{
return a_error.find("was not found") != std::string_view::npos;
}
}
namespace F4T::SessionLaunch
@@ -462,10 +507,12 @@ namespace F4T::SessionLaunch
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 auto resolvedServerKey = ServerProfileStore::ResolveServerKey(host, port);
const auto profileKey = resolvedServerKey.value_or(serverKey);
const bool onMainMenu = IsMainMenuOpen();
const bool inGame = RE::PlayerCharacter::GetSingleton() != nullptr;
const bool hasProfile = ServerProfileStore::HasProfile(serverKey);
const bool hasProfile = resolvedServerKey.has_value();
if (!onMainMenu && !inGame) {
a_error = "Game is not ready to launch a session.";
@@ -478,23 +525,79 @@ namespace F4T::SessionLaunch
}
if (hasProfile) {
const auto saveFile = ServerProfileStore::LookupSaveFile(serverKey);
auto saveFile = ServerProfileStore::LookupSaveFile(profileKey);
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)) {
if (!onMainMenu) {
a_error = "Server profile has no associated save file. Return to the main menu to create a replacement save.";
return false;
}
} else if (!LoadSaveInGame(*saveFile, a_error)) {
LogWarning(std::format("Server profile '{}' has no bound save file; running first-time flow.", profileKey));
QueueFirstTimeCocJoin(profileKey);
IssueCocSanctuaryExt();
return true;
}
// Exit save filenames rotate frequently. Prefer the newest available exit save
// so profile loading does not stick to a stale exitsave filename.
if (IsExitSaveName(*saveFile)) {
const auto latestExitSave = ResolveLatestExitSaveFileName();
if (latestExitSave && *latestExitSave != *saveFile) {
LogInfo(std::format(
"Updating associated exit save from '{}' to '{}' for server {}.",
*saveFile,
*latestExitSave,
profileKey));
saveFile = latestExitSave;
ServerProfileStore::BindSaveFile(profileKey, *saveFile);
}
}
std::string loadError;
bool loadStarted = false;
if (onMainMenu) {
loadStarted = LoadSaveFromMainMenu(*saveFile, loadError);
if (!loadStarted && IsMissingAssociatedSaveError(loadError)) {
if (IsExitSaveName(*saveFile)) {
const auto latestExitSave = ResolveLatestExitSaveFileName();
if (latestExitSave && *latestExitSave != *saveFile) {
LogInfo(std::format(
"Retrying load with latest exit save '{}' (previous '{}').",
*latestExitSave,
*saveFile));
saveFile = latestExitSave;
ServerProfileStore::BindSaveFile(profileKey, *saveFile);
loadStarted = LoadSaveFromMainMenu(*saveFile, loadError);
}
}
}
if (!loadStarted && IsMissingAssociatedSaveError(loadError)) {
LogWarning(std::format(
"Associated save '{}' for server '{}' is missing; running first-time flow.",
*saveFile,
profileKey));
QueueFirstTimeCocJoin(profileKey);
IssueCocSanctuaryExt();
return true;
}
} else {
loadStarted = LoadSaveInGame(*saveFile, loadError);
if (!loadStarted && IsMissingAssociatedSaveError(loadError)) {
a_error = std::format(
"Associated save '{}' was not found. Return to the main menu to create a replacement save for this server.",
*saveFile);
return false;
}
}
if (!loadStarted) {
a_error = loadError;
return false;
}
LogInfo(std::format("Loading associated save '{}' for server {}.", *saveFile, serverKey));
ServerProfileStore::TouchProfile(profileKey);
LogInfo(std::format("Loading associated save '{}' for server {}.", *saveFile, profileKey));
return true;
}
@@ -531,10 +634,6 @@ namespace F4T::SessionLaunch
return;
}
if (IsFirstJoinInProgress()) {
return;
}
const auto saveFileName = ResolveCurrentSaveFileName();
if (!saveFileName) {
return;