Event-driven world time sync (timeSync flag)
Introduce an optional timeSync field and event-driven time pushes so host time is only re-applied on connect, resume-from-freeze, or large jumps while weather continues on ~1 Hz heartbeats. Added IsGameTimeFrozen detection and client buffering of latest host time; clients apply host time only when timeSync is true or when gameplay just resumed. Updated networking to read/write timeSync, adjusted F4TWorldStateSync logic (separate time vs weather send/apply paths, changed function signatures and behavior), and updated protocol/docs/changelog and fake_client logging to reflect the new field and behavior.
This commit is contained in:
@@ -33,5 +33,6 @@ namespace F4T::Networking
|
||||
float a_gameHour,
|
||||
float a_gameDaysPassed,
|
||||
bool a_hasWeatherFormId,
|
||||
std::uint32_t a_weatherFormId);
|
||||
std::uint32_t a_weatherFormId,
|
||||
bool a_timeSync = false);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace F4T::WorldStateSync
|
||||
float gameDaysPassed{ 0.0F };
|
||||
std::optional<std::uint32_t> weatherFormId;
|
||||
bool hasWeatherFormId{ false };
|
||||
bool applyTime{ false };
|
||||
};
|
||||
|
||||
bool IsWorldStateHost();
|
||||
|
||||
@@ -379,7 +379,8 @@ namespace
|
||||
static_cast<float>(*gameHour),
|
||||
static_cast<float>(*gameDaysPassed),
|
||||
std::nullopt,
|
||||
false
|
||||
false,
|
||||
ReadBool(a_packet, "timeSync").value_or(false)
|
||||
};
|
||||
|
||||
const auto weatherFormIdText = ReadString(a_packet, "weatherFormId");
|
||||
@@ -395,15 +396,18 @@ namespace
|
||||
}
|
||||
|
||||
if (ShouldLogRemoteUpdate(1)) {
|
||||
const auto timeSyncNote = pendingState.applyTime ? "timeSync=yes" : "timeSync=no";
|
||||
if (pendingState.hasWeatherFormId && pendingState.weatherFormId) {
|
||||
LogInfoWithLocalPlayerPrefix(std::format(
|
||||
"Queued host worldState: gameHour={:.2f}, gameDaysPassed={:.3f}, weatherFormId=0x{:08X}.",
|
||||
"Queued host worldState ({}): gameHour={:.2f}, gameDaysPassed={:.3f}, weatherFormId=0x{:08X}.",
|
||||
timeSyncNote,
|
||||
pendingState.gameHour,
|
||||
pendingState.gameDaysPassed,
|
||||
*pendingState.weatherFormId));
|
||||
} else {
|
||||
LogInfoWithLocalPlayerPrefix(std::format(
|
||||
"Queued host worldState: gameHour={:.2f}, gameDaysPassed={:.3f}, weatherFormId=<none>.",
|
||||
"Queued host worldState ({}): gameHour={:.2f}, gameDaysPassed={:.3f}, weatherFormId=<none>.",
|
||||
timeSyncNote,
|
||||
pendingState.gameHour,
|
||||
pendingState.gameDaysPassed));
|
||||
}
|
||||
@@ -974,7 +978,8 @@ namespace F4T::Networking
|
||||
float a_gameHour,
|
||||
float a_gameDaysPassed,
|
||||
bool a_hasWeatherFormId,
|
||||
std::uint32_t a_weatherFormId)
|
||||
std::uint32_t a_weatherFormId,
|
||||
bool a_timeSync)
|
||||
{
|
||||
if (!IsConnectedToServer()) {
|
||||
return false;
|
||||
@@ -987,24 +992,26 @@ namespace F4T::Networking
|
||||
const auto clientTime =
|
||||
std::chrono::duration<double>(std::chrono::system_clock::now().time_since_epoch()).count();
|
||||
|
||||
std::array<char, 256> packet{};
|
||||
std::array<char, 320> packet{};
|
||||
int packetSize = 0;
|
||||
if (a_hasWeatherFormId) {
|
||||
packetSize = std::snprintf(
|
||||
packet.data(),
|
||||
packet.size(),
|
||||
"{\"type\":\"worldState\",\"gameHour\":%.3f,\"gameDaysPassed\":%.3f,\"weatherFormId\":\"%08X\",\"clientTime\":%.3f}\n",
|
||||
"{\"type\":\"worldState\",\"gameHour\":%.3f,\"gameDaysPassed\":%.3f,\"weatherFormId\":\"%08X\",\"timeSync\":%s,\"clientTime\":%.3f}\n",
|
||||
a_gameHour,
|
||||
a_gameDaysPassed,
|
||||
a_weatherFormId,
|
||||
a_timeSync ? "true" : "false",
|
||||
clientTime);
|
||||
} else {
|
||||
packetSize = std::snprintf(
|
||||
packet.data(),
|
||||
packet.size(),
|
||||
"{\"type\":\"worldState\",\"gameHour\":%.3f,\"gameDaysPassed\":%.3f,\"clientTime\":%.3f}\n",
|
||||
"{\"type\":\"worldState\",\"gameHour\":%.3f,\"gameDaysPassed\":%.3f,\"timeSync\":%s,\"clientTime\":%.3f}\n",
|
||||
a_gameHour,
|
||||
a_gameDaysPassed,
|
||||
a_timeSync ? "true" : "false",
|
||||
clientTime);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,20 @@
|
||||
#include "F4TNetworking.h"
|
||||
#include "F4TRemotePlayerState.h"
|
||||
|
||||
#include "RE/B/BGSSaveLoadManager.h"
|
||||
#include "RE/C/Calendar.h"
|
||||
#include "RE/L/LoadingMenu.h"
|
||||
#include "RE/M/Main.h"
|
||||
#include "RE/M/MainMenu.h"
|
||||
#include "RE/P/PipboyMenu.h"
|
||||
#include "RE/P/PlayerCharacter.h"
|
||||
#include "RE/S/SitWaitMenu.h"
|
||||
#include "RE/S/Sky.h"
|
||||
#include "RE/T/TESDataHandler.h"
|
||||
#include "RE/T/TESForm.h"
|
||||
#include "RE/T/TESObjectCELL.h"
|
||||
#include "RE/T/TESWeather.h"
|
||||
#include "RE/U/UI.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <format>
|
||||
@@ -56,6 +63,16 @@ namespace
|
||||
auto g_lastHostSendTime = std::chrono::steady_clock::time_point{};
|
||||
std::optional<std::uint32_t> g_worldStateHostPlayerId{};
|
||||
std::unordered_map<std::string, std::chrono::steady_clock::time_point> g_lastLogTimes;
|
||||
bool g_wasGameTimeFrozen{ false };
|
||||
|
||||
struct LatestHostTime
|
||||
{
|
||||
float gameHour{ -1.0F };
|
||||
float gameDaysPassed{ -1.0F };
|
||||
bool initialized{ false };
|
||||
};
|
||||
|
||||
LatestHostTime g_latestHostTime{};
|
||||
|
||||
std::string GetLocalPlayerLogMessage(std::string_view a_message)
|
||||
{
|
||||
@@ -97,6 +114,37 @@ namespace
|
||||
return playerCell && playerCell->IsExterior();
|
||||
}
|
||||
|
||||
bool IsGameTimeFrozen()
|
||||
{
|
||||
const auto* main = RE::Main::GetSingleton();
|
||||
if (main && (main->freezeTime || main->inMenuMode || main->inDialog)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const auto* player = RE::PlayerCharacter::GetSingleton();
|
||||
if (player && player->isSleeping) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const auto* ui = RE::UI::GetSingleton();
|
||||
if (ui) {
|
||||
if (ui->GetMenuOpen<RE::LoadingMenu>() ||
|
||||
ui->GetMenuOpen<RE::MainMenu>() ||
|
||||
ui->GetMenuOpen<RE::SitWaitMenu>() ||
|
||||
ui->GetMenuOpen<RE::PipboyMenu>()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const auto* saveLoadManager = RE::BGSSaveLoadManager::GetSingleton();
|
||||
if (saveLoadManager &&
|
||||
(saveLoadManager->saveLoadTasksThread.busy || saveLoadManager->queuedTasks != 0)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool TryReadCalendarValues(float& a_outGameHour, float& a_outGameDaysPassed)
|
||||
{
|
||||
const auto* calendar = RE::Calendar::GetSingleton();
|
||||
@@ -124,19 +172,22 @@ namespace
|
||||
return currentWeather->GetFormID();
|
||||
}
|
||||
|
||||
bool ShouldSendWorldState(
|
||||
float a_gameHour,
|
||||
float a_gameDaysPassed,
|
||||
bool a_hasWeatherFormId,
|
||||
std::optional<std::uint32_t> a_weatherFormId,
|
||||
const std::chrono::steady_clock::time_point& a_now)
|
||||
bool ShouldSendTimeState(float a_gameHour, float a_gameDaysPassed)
|
||||
{
|
||||
if (!g_lastSentWorldState.initialized) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std::abs(a_gameHour - g_lastSentWorldState.gameHour) > kGameHourSendThreshold ||
|
||||
std::abs(a_gameDaysPassed - g_lastSentWorldState.gameDaysPassed) > kGameDaysPassedSendThreshold) {
|
||||
return std::abs(a_gameHour - g_lastSentWorldState.gameHour) > kGameHourSendThreshold ||
|
||||
std::abs(a_gameDaysPassed - g_lastSentWorldState.gameDaysPassed) > kGameDaysPassedSendThreshold;
|
||||
}
|
||||
|
||||
bool ShouldSendWeatherState(
|
||||
bool a_hasWeatherFormId,
|
||||
std::optional<std::uint32_t> a_weatherFormId,
|
||||
const std::chrono::steady_clock::time_point& a_now)
|
||||
{
|
||||
if (!g_lastSentWorldState.initialized) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -149,7 +200,7 @@ namespace
|
||||
a_now - g_lastHostSendTime >= kWorldStateHeartbeatInterval;
|
||||
}
|
||||
|
||||
void ApplyTimeState(float a_gameHour, float a_gameDaysPassed)
|
||||
void ApplyTimeState(float a_gameHour, float a_gameDaysPassed, bool a_force)
|
||||
{
|
||||
auto* calendar = RE::Calendar::GetSingleton();
|
||||
if (!calendar || !calendar->gameHour || !calendar->gameDaysPassed) {
|
||||
@@ -158,7 +209,8 @@ namespace
|
||||
|
||||
const auto localGameHour = calendar->gameHour->GetValue();
|
||||
const auto localGameDaysPassed = calendar->gameDaysPassed->GetValue();
|
||||
if (std::abs(localGameHour - a_gameHour) <= kGameHourApplyThreshold &&
|
||||
if (!a_force &&
|
||||
std::abs(localGameHour - a_gameHour) <= kGameHourApplyThreshold &&
|
||||
std::abs(localGameDaysPassed - a_gameDaysPassed) <= kGameDaysPassedApplyThreshold) {
|
||||
return;
|
||||
}
|
||||
@@ -232,7 +284,7 @@ namespace
|
||||
g_lastSentWorldState.gameDaysPassed = gameDaysPassed;
|
||||
g_lastSentWorldState.hasWeatherFormId = weatherFormId.has_value();
|
||||
g_lastSentWorldState.weatherFormId = weatherFormId;
|
||||
g_lastSentWorldState.initialized = true;
|
||||
g_lastSentWorldState.initialized = false;
|
||||
g_lastHostSendTime = std::chrono::steady_clock::now();
|
||||
}
|
||||
}
|
||||
@@ -303,6 +355,8 @@ namespace F4T::WorldStateSync
|
||||
g_lastHostSendTime = {};
|
||||
g_worldStateHostPlayerId = std::nullopt;
|
||||
g_lastLogTimes.clear();
|
||||
g_wasGameTimeFrozen = false;
|
||||
g_latestHostTime = {};
|
||||
}
|
||||
|
||||
void SetPendingWorldState(PendingWorldState a_state)
|
||||
@@ -324,6 +378,10 @@ namespace F4T::WorldStateSync
|
||||
return;
|
||||
}
|
||||
|
||||
const bool timeFrozen = IsGameTimeFrozen();
|
||||
const bool justResumed = g_wasGameTimeFrozen && !timeFrozen;
|
||||
g_wasGameTimeFrozen = timeFrozen;
|
||||
|
||||
float gameHour = 0.0F;
|
||||
float gameDaysPassed = 0.0F;
|
||||
if (!TryReadCalendarValues(gameHour, gameDaysPassed)) {
|
||||
@@ -333,7 +391,9 @@ namespace F4T::WorldStateSync
|
||||
const auto weatherFormId = TryReadExteriorWeatherFormId();
|
||||
const bool hasWeatherFormId = weatherFormId.has_value();
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (!ShouldSendWorldState(gameHour, gameDaysPassed, hasWeatherFormId, weatherFormId, now)) {
|
||||
const bool sendTime = justResumed || ShouldSendTimeState(gameHour, gameDaysPassed);
|
||||
const bool sendWeather = ShouldSendWeatherState(hasWeatherFormId, weatherFormId, now);
|
||||
if (!sendTime && !sendWeather) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -341,27 +401,33 @@ namespace F4T::WorldStateSync
|
||||
gameHour,
|
||||
gameDaysPassed,
|
||||
hasWeatherFormId,
|
||||
hasWeatherFormId ? *weatherFormId : 0U)) {
|
||||
hasWeatherFormId ? *weatherFormId : 0U,
|
||||
sendTime)) {
|
||||
return;
|
||||
}
|
||||
|
||||
g_lastSentWorldState.gameHour = gameHour;
|
||||
g_lastSentWorldState.gameDaysPassed = gameDaysPassed;
|
||||
if (sendTime) {
|
||||
g_lastSentWorldState.gameHour = gameHour;
|
||||
g_lastSentWorldState.gameDaysPassed = gameDaysPassed;
|
||||
}
|
||||
g_lastSentWorldState.hasWeatherFormId = hasWeatherFormId;
|
||||
g_lastSentWorldState.weatherFormId = weatherFormId;
|
||||
g_lastSentWorldState.initialized = true;
|
||||
g_lastHostSendTime = now;
|
||||
|
||||
if (ShouldLog("world_state_host_send")) {
|
||||
const auto timeSyncNote = sendTime ? "timeSync=yes" : "timeSync=no";
|
||||
if (hasWeatherFormId) {
|
||||
LogInfo(std::format(
|
||||
"Sent worldState: gameHour={:.2f}, gameDaysPassed={:.3f}, weatherFormId=0x{:08X}.",
|
||||
"Sent worldState ({}): gameHour={:.2f}, gameDaysPassed={:.3f}, weatherFormId=0x{:08X}.",
|
||||
timeSyncNote,
|
||||
gameHour,
|
||||
gameDaysPassed,
|
||||
*weatherFormId));
|
||||
} else {
|
||||
LogInfo(std::format(
|
||||
"Sent worldState: gameHour={:.2f}, gameDaysPassed={:.3f}, weatherFormId=<none>.",
|
||||
"Sent worldState ({}): gameHour={:.2f}, gameDaysPassed={:.3f}, weatherFormId=<none>.",
|
||||
timeSyncNote,
|
||||
gameHour,
|
||||
gameDaysPassed));
|
||||
}
|
||||
@@ -374,22 +440,41 @@ namespace F4T::WorldStateSync
|
||||
return;
|
||||
}
|
||||
|
||||
const bool timeFrozen = IsGameTimeFrozen();
|
||||
const bool justResumed = g_wasGameTimeFrozen && !timeFrozen;
|
||||
g_wasGameTimeFrozen = timeFrozen;
|
||||
|
||||
PendingWorldState pendingState{};
|
||||
bool hasPendingState = false;
|
||||
{
|
||||
const std::scoped_lock lock(g_pendingMutex);
|
||||
if (!g_hasPendingWorldState) {
|
||||
return;
|
||||
if (g_hasPendingWorldState) {
|
||||
pendingState = g_pendingWorldState;
|
||||
g_hasPendingWorldState = false;
|
||||
hasPendingState = true;
|
||||
}
|
||||
|
||||
pendingState = g_pendingWorldState;
|
||||
g_hasPendingWorldState = false;
|
||||
}
|
||||
|
||||
ApplyTimeState(pendingState.gameHour, pendingState.gameDaysPassed);
|
||||
if (hasPendingState) {
|
||||
g_latestHostTime.gameHour = pendingState.gameHour;
|
||||
g_latestHostTime.gameDaysPassed = pendingState.gameDaysPassed;
|
||||
g_latestHostTime.initialized = true;
|
||||
}
|
||||
|
||||
const bool shouldApplyTime =
|
||||
!timeFrozen && g_latestHostTime.initialized && (justResumed || (hasPendingState && pendingState.applyTime));
|
||||
if (shouldApplyTime) {
|
||||
ApplyTimeState(g_latestHostTime.gameHour, g_latestHostTime.gameDaysPassed, justResumed);
|
||||
g_lastAppliedWorldState.gameHour = g_latestHostTime.gameHour;
|
||||
g_lastAppliedWorldState.gameDaysPassed = g_latestHostTime.gameDaysPassed;
|
||||
}
|
||||
|
||||
if (!hasPendingState) {
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyWeatherState(pendingState.hasWeatherFormId, pendingState.weatherFormId);
|
||||
|
||||
g_lastAppliedWorldState.gameHour = pendingState.gameHour;
|
||||
g_lastAppliedWorldState.gameDaysPassed = pendingState.gameDaysPassed;
|
||||
g_lastAppliedWorldState.hasWeatherFormId = pendingState.hasWeatherFormId;
|
||||
g_lastAppliedWorldState.weatherFormId = pendingState.weatherFormId;
|
||||
g_lastAppliedWorldState.initialized = true;
|
||||
|
||||
Reference in New Issue
Block a user