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:
@@ -18,6 +18,13 @@ For testing notes, milestone summaries, known issues, and next steps, see [`docs
|
|||||||
- `timeSync` tracking in `server/fake_client.py` host world-state snapshot output.
|
- `timeSync` tracking in `server/fake_client.py` host world-state snapshot output.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- If a server profile exists but its associated save was deleted, joining from the main menu now falls back to first-time character creation and re-binds the profile to the newly created save instead of hard-failing.
|
||||||
|
- Pending first-time profile binding now completes as soon as a save exists even if character-creation phase flags are still active, preventing repeated `coc` first-time launches on reconnect.
|
||||||
|
- Session launch now resolves equivalent server endpoints (for example `localhost`, `127.0.0.1`, and legacy host-case variants) to an existing server profile before deciding first-time join flow, preventing unintended new-character creation when reconnecting.
|
||||||
|
- Server profile persistence now uses a sanitized save-folder path with a Documents fallback and write-failure logging, preventing silent loss of server profile bindings across game restarts.
|
||||||
|
- Pending profile binding now accepts exit saves (including `Exitsave...`) so the last auto-save on game exit can be rebound and loaded on reconnect, reducing accidental progress loss.
|
||||||
|
- Fixed Windows build issues in server profile path fallback by replacing unsafe `getenv` usage and accepting `std::string_view` save-folder names in folder sanitization.
|
||||||
|
- Reconnect now refreshes stale associated `Exitsave...` names to the newest available exit save before load, preventing fallback to first-time flow when exit-save filenames rotate between sessions.
|
||||||
- Server GUI weather now runs `fw <8-digit-form-id>` on the current `worldStateHostPlayerId` client (not hardcoded to player 1), scheduled on the game thread.
|
- Server GUI weather now runs `fw <8-digit-form-id>` on the current `worldStateHostPlayerId` client (not hardcoded to player 1), scheduled on the game thread.
|
||||||
- Weather `serverWorldState` handling now defers until world-state host assignment is known, then skips only non-host clients.
|
- Weather `serverWorldState` handling now defers until world-state host assignment is known, then skips only non-host clients.
|
||||||
- Weather `serverWorldState` packets are broadcast to all clients so the designated host receives them even after host reassignment.
|
- Weather `serverWorldState` packets are broadcast to all clients so the designated host receives them even after host reassignment.
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
Scriptname CO_Vault109StartQuestScript extends Quest
|
||||||
|
|
||||||
|
ObjectReference Property Vault109SpawnMarker Auto Const
|
||||||
|
|
||||||
|
Event OnQuestInit()
|
||||||
|
If IsStageDone(10) == False
|
||||||
|
Utility.Wait(1.0)
|
||||||
|
|
||||||
|
Debug.Notification("Vault 109 spawn quest ran")
|
||||||
|
Game.GetPlayer().MoveTo(Vault109SpawnMarker)
|
||||||
|
|
||||||
|
SetStage(10)
|
||||||
|
EndIf
|
||||||
|
EndEvent
|
||||||
@@ -9,6 +9,78 @@ failed experiments, successful tests, and next steps.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-06-26 - Deleted Save Recreates First-Time Flow
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
Updated reconnect launch behavior so deleting a server's associated save allows first-time connection testing again: the next join from main menu now recreates the character/save flow and rebinds that server profile automatically.
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
- `plugin/src/F4TSessionLaunch.cpp`
|
||||||
|
- `changelog.md`
|
||||||
|
- `docs/dev-log.md`
|
||||||
|
|
||||||
|
### Details
|
||||||
|
- If a server profile exists but has no bound save file, joining from main menu now starts first-time flow instead of returning an error.
|
||||||
|
- If a profile has a bound save file but the save no longer exists on disk, main-menu join now falls back to first-time flow.
|
||||||
|
- Recreated first-time flow binds back to the same profile key, replacing the missing-save association with the newly created save.
|
||||||
|
- In-game joins still require returning to main menu for replacement-save creation when the associated save is missing.
|
||||||
|
- Removed the `IsFirstJoinInProgress()` gate in pending profile bind completion so profile binding can finish as soon as a save is available, even if char-gen menu phase tracking has not reached `Complete`.
|
||||||
|
- Updated server profile path building to sanitize save-folder naming, add a Documents-path fallback, and log write failures so profile persistence cannot silently fail.
|
||||||
|
- Updated pending profile bind save selection to allow `Exitsave...` entries so exit auto-saves can be used for reconnect continuity.
|
||||||
|
- Fixed compile issues from the profile-path hardening change by switching the environment fallback to `_dupenv_s` and updating folder sanitization to accept `std::string_view` input safely.
|
||||||
|
- Added exit-save refresh on reconnect: if the associated save is an `Exitsave...`, session launch now resolves the newest available exit save, rebinds the profile to it, and loads that updated save.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- Not run in-game in this session.
|
||||||
|
- Recommended manual checks:
|
||||||
|
- Start server and connect once from main menu to create/bind a save.
|
||||||
|
- Exit to main menu, delete that save, then reconnect to the same server; confirm first-time flow runs and a new save is created/bound.
|
||||||
|
- Disconnect and reconnect again; confirm it now loads the newly bound save (no new character creation).
|
||||||
|
- Connect two Fallout 4 instances and verify transform sync packets still send/relay, remote state parsing still works, and proxy actor movement remains visible in `F4TTestCell01`.
|
||||||
|
- Check `CommonwealthOnline.log` for fallback logs and normal associated-save load logs.
|
||||||
|
|
||||||
|
### Known Issues
|
||||||
|
- Missing-save replacement flow requires starting the join from main menu; in-game replacement is still intentionally blocked.
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
- Optionally add a UI hint when an associated save is missing during in-game join to direct players back to the main menu.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-06-26 - Reconnect Uses Existing Server Save Profile
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
Fixed session launch profile matching so reconnects use the existing server-associated save instead of incorrectly entering first-time character creation when host formatting differs.
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
- `plugin/include/F4TServerProfileStore.h`
|
||||||
|
- `plugin/src/F4TServerProfileStore.cpp`
|
||||||
|
- `plugin/src/F4TSessionLaunch.cpp`
|
||||||
|
- `changelog.md`
|
||||||
|
- `docs/dev-log.md`
|
||||||
|
|
||||||
|
### Details
|
||||||
|
- Added endpoint normalization in server profile keys (trim/lowercase host and canonical loopback aliases to `127.0.0.1`).
|
||||||
|
- Added server profile key resolution that searches existing stored keys for equivalent host/port endpoints.
|
||||||
|
- Updated session launch to use resolved profile keys for save lookup and profile touch before deciding first-time flow.
|
||||||
|
- First-time joins still bind using the normalized server key, so future reconnects match reliably.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- Not run in-game in this session.
|
||||||
|
- Recommended manual checks:
|
||||||
|
- Start the Python server and connect one Fallout 4 instance from main menu to a server entry using `localhost` (or LAN list), complete first-time flow, and confirm a save/profile bind occurs.
|
||||||
|
- Return to main menu and reconnect to the same server using a different equivalent address format (`127.0.0.1` vs `localhost`); confirm no new character creation and associated save loads.
|
||||||
|
- Connect two Fallout 4 instances and verify transform packets are still sent/received, remote player state is parsed, and proxy actor movement remains visible in `F4TTestCell01`.
|
||||||
|
- Check `CommonwealthOnline.log` and server output for normal packet relay plus launch logs showing associated save loading.
|
||||||
|
|
||||||
|
### Known Issues
|
||||||
|
- Matching is still based on host/port identity; if the same physical server moves to a different IP/port, it is treated as a different server profile.
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
- Consider adding a stable server identifier from handshake metadata to support profile continuity across host/IP changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-06-25 - Weather Host Reassignment Uses `worldStateHostPlayerId`
|
## 2026-06-25 - Weather Host Reassignment Uses `worldStateHostPlayerId`
|
||||||
|
|
||||||
### Summary
|
### Summary
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
namespace F4T::ServerProfileStore
|
namespace F4T::ServerProfileStore
|
||||||
{
|
{
|
||||||
std::string MakeServerKey(const std::string& a_host, std::uint16_t a_port);
|
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);
|
bool HasProfile(const std::string& a_serverKey);
|
||||||
std::optional<std::string> LookupSaveFile(const std::string& a_serverKey);
|
std::optional<std::string> LookupSaveFile(const std::string& a_serverKey);
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
#include "REX/W32/OLE32.h"
|
#include "REX/W32/OLE32.h"
|
||||||
#include "REX/W32/SHELL32.h"
|
#include "REX/W32/SHELL32.h"
|
||||||
|
|
||||||
|
#include <charconv>
|
||||||
|
#include <cctype>
|
||||||
|
#include <cstdlib>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <format>
|
#include <format>
|
||||||
@@ -17,19 +20,166 @@ namespace
|
|||||||
std::mutex g_storeMutex;
|
std::mutex g_storeMutex;
|
||||||
nlohmann::json g_profiles = nlohmann::json::object();
|
nlohmann::json g_profiles = nlohmann::json::object();
|
||||||
bool g_loaded = false;
|
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 };
|
wchar_t* knownBuffer{ nullptr };
|
||||||
const auto knownResult =
|
const auto knownResult =
|
||||||
REX::W32::SHGetKnownFolderPath(REX::W32::FOLDERID_Documents, REX::W32::KF_FLAG_DEFAULT, nullptr, std::addressof(knownBuffer));
|
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);
|
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 {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
auto path = std::filesystem::path(knownPath.get());
|
std::string SanitizeFolderComponent(std::string_view a_name, std::string_view a_fallback)
|
||||||
path /= std::format("My Games/{}/CommonwealthOnline", F4SE::GetSaveFolderName());
|
{
|
||||||
|
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 {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto saveFolderName = SanitizeFolderComponent(F4SE::GetSaveFolderName(), "Fallout4");
|
||||||
|
auto path = std::move(documentsPath);
|
||||||
|
path /= "My Games";
|
||||||
|
path /= saveFolderName;
|
||||||
|
path /= "CommonwealthOnline";
|
||||||
path /= "server-profiles.json";
|
path /= "server-profiles.json";
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
@@ -73,6 +223,7 @@ namespace
|
|||||||
|
|
||||||
std::ofstream output(path);
|
std::ofstream output(path);
|
||||||
if (!output.is_open()) {
|
if (!output.is_open()) {
|
||||||
|
LogStoreWarning(std::format("Failed to open server profile store for write: {}", path.string()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,8 +235,36 @@ namespace F4T::ServerProfileStore
|
|||||||
{
|
{
|
||||||
std::string MakeServerKey(const std::string& a_host, const std::uint16_t a_port)
|
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("{}:{}", NormalizeHost(a_host), a_port);
|
||||||
return std::format("{}:{}", 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)
|
bool HasProfile(const std::string& a_serverKey)
|
||||||
|
|||||||
+113
-14
@@ -112,6 +112,11 @@ namespace
|
|||||||
return ui && ui->GetMenuOpen(a_menuName);
|
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)
|
bool IsPlayerInSanctuaryExt(const RE::PlayerCharacter& a_player)
|
||||||
{
|
{
|
||||||
if (IsPlaceholderSpawnPosition(a_player)) {
|
if (IsPlaceholderSpawnPosition(a_player)) {
|
||||||
@@ -328,17 +333,52 @@ namespace
|
|||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
|
|
||||||
const auto index = saveLoadManager->saveGameCount - 1;
|
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()) {
|
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;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::optional<std::string> ResolveLatestExitSaveFileName()
|
||||||
|
{
|
||||||
|
auto* saveLoadManager = RE::BGSSaveLoadManager::GetSingleton();
|
||||||
|
if (!saveLoadManager) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveLoadManager->BuildSaveGameList(0);
|
||||||
|
if (saveLoadManager->saveGameCount == 0 || saveLoadManager->saveGameList.empty()) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
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];
|
const auto* entry = saveLoadManager->saveGameList[index];
|
||||||
if (!entry || !entry->fileName || entry->fileName[0] == '\0') {
|
if (!entry || !entry->fileName || entry->fileName[0] == '\0') {
|
||||||
return std::nullopt;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return std::string{ entry->fileName };
|
const std::string candidate{ entry->fileName };
|
||||||
|
if (IsExitSaveName(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
|
|
||||||
void QueueProfileSave()
|
void QueueProfileSave()
|
||||||
@@ -453,6 +493,11 @@ namespace
|
|||||||
{
|
{
|
||||||
return g_charGenPhase != CharGenPhase::None && g_charGenPhase != CharGenPhase::Complete;
|
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
|
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 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 port = static_cast<std::uint16_t>(a_entry.port > 0 ? a_entry.port : 7777);
|
||||||
const auto serverKey = ServerProfileStore::MakeServerKey(host, port);
|
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 onMainMenu = IsMainMenuOpen();
|
||||||
const bool inGame = RE::PlayerCharacter::GetSingleton() != nullptr;
|
const bool inGame = RE::PlayerCharacter::GetSingleton() != nullptr;
|
||||||
const bool hasProfile = ServerProfileStore::HasProfile(serverKey);
|
const bool hasProfile = resolvedServerKey.has_value();
|
||||||
|
|
||||||
if (!onMainMenu && !inGame) {
|
if (!onMainMenu && !inGame) {
|
||||||
a_error = "Game is not ready to launch a session.";
|
a_error = "Game is not ready to launch a session.";
|
||||||
@@ -478,23 +525,79 @@ namespace F4T::SessionLaunch
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasProfile) {
|
if (hasProfile) {
|
||||||
const auto saveFile = ServerProfileStore::LookupSaveFile(serverKey);
|
auto saveFile = ServerProfileStore::LookupSaveFile(profileKey);
|
||||||
if (!saveFile) {
|
if (!saveFile) {
|
||||||
a_error = "Server profile exists but has no associated save file.";
|
if (!onMainMenu) {
|
||||||
|
a_error = "Server profile has no associated save file. Return to the main menu to create a replacement save.";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
ServerProfileStore::TouchProfile(serverKey);
|
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) {
|
if (onMainMenu) {
|
||||||
if (!LoadSaveFromMainMenu(*saveFile, a_error)) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
} else if (!LoadSaveInGame(*saveFile, a_error)) {
|
}
|
||||||
|
|
||||||
|
if (!loadStarted) {
|
||||||
|
a_error = loadError;
|
||||||
return false;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,10 +634,6 @@ namespace F4T::SessionLaunch
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsFirstJoinInProgress()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const auto saveFileName = ResolveCurrentSaveFileName();
|
const auto saveFileName = ResolveCurrentSaveFileName();
|
||||||
if (!saveFileName) {
|
if (!saveFileName) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user