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:
+5
-1
@@ -9,8 +9,12 @@ For testing notes, milestone summaries, known issues, and next steps, see [`docs
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Changed
|
### Added
|
||||||
|
- Event-driven world time resync via optional `timeSync` field on `worldState` packets; host (client 1) pushes time after menus, loading screens, save/load, wait/sleep, and other time freezes.
|
||||||
|
- `timeSync` tracking in `server/fake_client.py` host world-state snapshot output.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- World time no longer re-applies on every `worldState` heartbeat; weather heartbeats continue at ~1 Hz while time stays in sync during normal gameplay.
|
||||||
- Reformatted `changelog.md` to follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
- Reformatted `changelog.md` to follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -30,9 +30,11 @@ both send and receive paths:
|
|||||||
11. The server tracks a world-state host (`worldStateHostPlayerId`). The first
|
11. The server tracks a world-state host (`worldStateHostPlayerId`). The first
|
||||||
connected client becomes host; when the host disconnects, the server reassigns
|
connected client becomes host; when the host disconnects, the server reassigns
|
||||||
host to the lowest remaining `playerId` and broadcasts `worldStateHost`. The
|
host to the lowest remaining `playerId` and broadcasts `worldStateHost`. The
|
||||||
host sends low-frequency `worldState` packets with `gameHour`, `gameDaysPassed`,
|
host sends `worldState` packets with `gameHour`, `gameDaysPassed`, optional
|
||||||
and optional `weatherFormId`. Promoted hosts continue with their already-synced
|
`weatherFormId`, and optional `timeSync`. Weather heartbeats run at ~1 Hz;
|
||||||
local time and weather so remaining clients should not see a jump.
|
time is pushed with `timeSync: true` on connect, after time freezes (menus,
|
||||||
|
loads, saves), and on large time jumps. Promoted hosts continue with their
|
||||||
|
already-synced local time and weather so remaining clients should not see a jump.
|
||||||
12. On the game-thread update path, the proxy controller reads a copied
|
12. On the game-thread update path, the proxy controller reads a copied
|
||||||
remote-player snapshot before attempting any actor work. The snapshot is
|
remote-player snapshot before attempting any actor work. The snapshot is
|
||||||
sorted by `playerId`, and only remote players with valid transform data in
|
sorted by `playerId`, and only remote players with valid transform data in
|
||||||
|
|||||||
@@ -9,6 +9,42 @@ failed experiments, successful tests, and next steps.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-06-23 - Event-Driven Time Sync
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
Improved host-authoritative time sync so calendar time stays aligned without constant heartbeat updates. The host (client 1) now pushes time with `timeSync: true` after gameplay time resumes from freezes (menus, pip-boy, dialogue, loading screens, save/load, wait/sleep). Non-host clients buffer the latest host time during freezes and snap on resume.
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
- `plugin/include/F4TWorldStateSync.h`
|
||||||
|
- `plugin/src/F4TWorldStateSync.cpp`
|
||||||
|
- `plugin/include/F4TNetworking.h`
|
||||||
|
- `plugin/src/F4TNetworking.cpp`
|
||||||
|
- `protocol/world-state.md`
|
||||||
|
- `changelog.md`
|
||||||
|
- `server/fake_client.py`
|
||||||
|
|
||||||
|
### Details
|
||||||
|
- Added `IsGameTimeFrozen()` detection using `Main` flags, open menus, save/load busy state, and sleeping.
|
||||||
|
- Split host send logic: weather continues on ~1 Hz heartbeat; time sends on initial connect, resume-from-freeze, or large time jumps.
|
||||||
|
- Added optional `timeSync` field on `worldState` packets so weather-only heartbeats do not re-apply time on clients.
|
||||||
|
- Clients store latest host time from every packet and force-apply when local gameplay time resumes.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- Not run in-game in this session.
|
||||||
|
- Recommended manual checks:
|
||||||
|
- Connect host first, then second client; verify times match after load.
|
||||||
|
- Open pip-boy on client 2 while host keeps playing; close pip-boy and confirm client 2 snaps to host time (`Applied host world time` in log).
|
||||||
|
- Wait/sleep on host; confirm `timeSync=yes` in host log and clients update.
|
||||||
|
- Verify weather still updates on ~1 Hz outdoors without constant time apply logs.
|
||||||
|
|
||||||
|
### Known Issues
|
||||||
|
- None currently known.
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
- In-game two-instance verification across load screens and pause menu exits.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-06-22 - World-State Host Reassignment
|
## 2026-06-22 - World-State Host Reassignment
|
||||||
|
|
||||||
### Summary
|
### Summary
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ Full field definitions live in [`protocol/world-state.md`](../protocol/world-sta
|
|||||||
"gameHour": 14.25,
|
"gameHour": 14.25,
|
||||||
"gameDaysPassed": 12.5,
|
"gameDaysPassed": 12.5,
|
||||||
"weatherFormId": "00123456",
|
"weatherFormId": "00123456",
|
||||||
|
"timeSync": true,
|
||||||
"clientTime": 1717424000.123,
|
"clientTime": 1717424000.123,
|
||||||
"serverTime": 1717424000.456
|
"serverTime": 1717424000.456
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,5 +33,6 @@ namespace F4T::Networking
|
|||||||
float a_gameHour,
|
float a_gameHour,
|
||||||
float a_gameDaysPassed,
|
float a_gameDaysPassed,
|
||||||
bool a_hasWeatherFormId,
|
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 };
|
float gameDaysPassed{ 0.0F };
|
||||||
std::optional<std::uint32_t> weatherFormId;
|
std::optional<std::uint32_t> weatherFormId;
|
||||||
bool hasWeatherFormId{ false };
|
bool hasWeatherFormId{ false };
|
||||||
|
bool applyTime{ false };
|
||||||
};
|
};
|
||||||
|
|
||||||
bool IsWorldStateHost();
|
bool IsWorldStateHost();
|
||||||
|
|||||||
@@ -379,7 +379,8 @@ namespace
|
|||||||
static_cast<float>(*gameHour),
|
static_cast<float>(*gameHour),
|
||||||
static_cast<float>(*gameDaysPassed),
|
static_cast<float>(*gameDaysPassed),
|
||||||
std::nullopt,
|
std::nullopt,
|
||||||
false
|
false,
|
||||||
|
ReadBool(a_packet, "timeSync").value_or(false)
|
||||||
};
|
};
|
||||||
|
|
||||||
const auto weatherFormIdText = ReadString(a_packet, "weatherFormId");
|
const auto weatherFormIdText = ReadString(a_packet, "weatherFormId");
|
||||||
@@ -395,15 +396,18 @@ namespace
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (ShouldLogRemoteUpdate(1)) {
|
if (ShouldLogRemoteUpdate(1)) {
|
||||||
|
const auto timeSyncNote = pendingState.applyTime ? "timeSync=yes" : "timeSync=no";
|
||||||
if (pendingState.hasWeatherFormId && pendingState.weatherFormId) {
|
if (pendingState.hasWeatherFormId && pendingState.weatherFormId) {
|
||||||
LogInfoWithLocalPlayerPrefix(std::format(
|
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.gameHour,
|
||||||
pendingState.gameDaysPassed,
|
pendingState.gameDaysPassed,
|
||||||
*pendingState.weatherFormId));
|
*pendingState.weatherFormId));
|
||||||
} else {
|
} else {
|
||||||
LogInfoWithLocalPlayerPrefix(std::format(
|
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.gameHour,
|
||||||
pendingState.gameDaysPassed));
|
pendingState.gameDaysPassed));
|
||||||
}
|
}
|
||||||
@@ -974,7 +978,8 @@ namespace F4T::Networking
|
|||||||
float a_gameHour,
|
float a_gameHour,
|
||||||
float a_gameDaysPassed,
|
float a_gameDaysPassed,
|
||||||
bool a_hasWeatherFormId,
|
bool a_hasWeatherFormId,
|
||||||
std::uint32_t a_weatherFormId)
|
std::uint32_t a_weatherFormId,
|
||||||
|
bool a_timeSync)
|
||||||
{
|
{
|
||||||
if (!IsConnectedToServer()) {
|
if (!IsConnectedToServer()) {
|
||||||
return false;
|
return false;
|
||||||
@@ -987,24 +992,26 @@ namespace F4T::Networking
|
|||||||
const auto clientTime =
|
const auto clientTime =
|
||||||
std::chrono::duration<double>(std::chrono::system_clock::now().time_since_epoch()).count();
|
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;
|
int packetSize = 0;
|
||||||
if (a_hasWeatherFormId) {
|
if (a_hasWeatherFormId) {
|
||||||
packetSize = std::snprintf(
|
packetSize = std::snprintf(
|
||||||
packet.data(),
|
packet.data(),
|
||||||
packet.size(),
|
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_gameHour,
|
||||||
a_gameDaysPassed,
|
a_gameDaysPassed,
|
||||||
a_weatherFormId,
|
a_weatherFormId,
|
||||||
|
a_timeSync ? "true" : "false",
|
||||||
clientTime);
|
clientTime);
|
||||||
} else {
|
} else {
|
||||||
packetSize = std::snprintf(
|
packetSize = std::snprintf(
|
||||||
packet.data(),
|
packet.data(),
|
||||||
packet.size(),
|
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_gameHour,
|
||||||
a_gameDaysPassed,
|
a_gameDaysPassed,
|
||||||
|
a_timeSync ? "true" : "false",
|
||||||
clientTime);
|
clientTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,20 @@
|
|||||||
#include "F4TNetworking.h"
|
#include "F4TNetworking.h"
|
||||||
#include "F4TRemotePlayerState.h"
|
#include "F4TRemotePlayerState.h"
|
||||||
|
|
||||||
|
#include "RE/B/BGSSaveLoadManager.h"
|
||||||
#include "RE/C/Calendar.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/P/PlayerCharacter.h"
|
||||||
|
#include "RE/S/SitWaitMenu.h"
|
||||||
#include "RE/S/Sky.h"
|
#include "RE/S/Sky.h"
|
||||||
#include "RE/T/TESDataHandler.h"
|
#include "RE/T/TESDataHandler.h"
|
||||||
#include "RE/T/TESForm.h"
|
#include "RE/T/TESForm.h"
|
||||||
#include "RE/T/TESObjectCELL.h"
|
#include "RE/T/TESObjectCELL.h"
|
||||||
#include "RE/T/TESWeather.h"
|
#include "RE/T/TESWeather.h"
|
||||||
|
#include "RE/U/UI.h"
|
||||||
|
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <format>
|
#include <format>
|
||||||
@@ -56,6 +63,16 @@ namespace
|
|||||||
auto g_lastHostSendTime = std::chrono::steady_clock::time_point{};
|
auto g_lastHostSendTime = std::chrono::steady_clock::time_point{};
|
||||||
std::optional<std::uint32_t> g_worldStateHostPlayerId{};
|
std::optional<std::uint32_t> g_worldStateHostPlayerId{};
|
||||||
std::unordered_map<std::string, std::chrono::steady_clock::time_point> g_lastLogTimes;
|
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)
|
std::string GetLocalPlayerLogMessage(std::string_view a_message)
|
||||||
{
|
{
|
||||||
@@ -97,6 +114,37 @@ namespace
|
|||||||
return playerCell && playerCell->IsExterior();
|
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)
|
bool TryReadCalendarValues(float& a_outGameHour, float& a_outGameDaysPassed)
|
||||||
{
|
{
|
||||||
const auto* calendar = RE::Calendar::GetSingleton();
|
const auto* calendar = RE::Calendar::GetSingleton();
|
||||||
@@ -124,19 +172,22 @@ namespace
|
|||||||
return currentWeather->GetFormID();
|
return currentWeather->GetFormID();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ShouldSendWorldState(
|
bool ShouldSendTimeState(float a_gameHour, float a_gameDaysPassed)
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
if (!g_lastSentWorldState.initialized) {
|
if (!g_lastSentWorldState.initialized) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (std::abs(a_gameHour - g_lastSentWorldState.gameHour) > kGameHourSendThreshold ||
|
return std::abs(a_gameHour - g_lastSentWorldState.gameHour) > kGameHourSendThreshold ||
|
||||||
std::abs(a_gameDaysPassed - g_lastSentWorldState.gameDaysPassed) > kGameDaysPassedSendThreshold) {
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +200,7 @@ namespace
|
|||||||
a_now - g_lastHostSendTime >= kWorldStateHeartbeatInterval;
|
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();
|
auto* calendar = RE::Calendar::GetSingleton();
|
||||||
if (!calendar || !calendar->gameHour || !calendar->gameDaysPassed) {
|
if (!calendar || !calendar->gameHour || !calendar->gameDaysPassed) {
|
||||||
@@ -158,7 +209,8 @@ namespace
|
|||||||
|
|
||||||
const auto localGameHour = calendar->gameHour->GetValue();
|
const auto localGameHour = calendar->gameHour->GetValue();
|
||||||
const auto localGameDaysPassed = calendar->gameDaysPassed->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) {
|
std::abs(localGameDaysPassed - a_gameDaysPassed) <= kGameDaysPassedApplyThreshold) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -232,7 +284,7 @@ namespace
|
|||||||
g_lastSentWorldState.gameDaysPassed = gameDaysPassed;
|
g_lastSentWorldState.gameDaysPassed = gameDaysPassed;
|
||||||
g_lastSentWorldState.hasWeatherFormId = weatherFormId.has_value();
|
g_lastSentWorldState.hasWeatherFormId = weatherFormId.has_value();
|
||||||
g_lastSentWorldState.weatherFormId = weatherFormId;
|
g_lastSentWorldState.weatherFormId = weatherFormId;
|
||||||
g_lastSentWorldState.initialized = true;
|
g_lastSentWorldState.initialized = false;
|
||||||
g_lastHostSendTime = std::chrono::steady_clock::now();
|
g_lastHostSendTime = std::chrono::steady_clock::now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,6 +355,8 @@ namespace F4T::WorldStateSync
|
|||||||
g_lastHostSendTime = {};
|
g_lastHostSendTime = {};
|
||||||
g_worldStateHostPlayerId = std::nullopt;
|
g_worldStateHostPlayerId = std::nullopt;
|
||||||
g_lastLogTimes.clear();
|
g_lastLogTimes.clear();
|
||||||
|
g_wasGameTimeFrozen = false;
|
||||||
|
g_latestHostTime = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
void SetPendingWorldState(PendingWorldState a_state)
|
void SetPendingWorldState(PendingWorldState a_state)
|
||||||
@@ -324,6 +378,10 @@ namespace F4T::WorldStateSync
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bool timeFrozen = IsGameTimeFrozen();
|
||||||
|
const bool justResumed = g_wasGameTimeFrozen && !timeFrozen;
|
||||||
|
g_wasGameTimeFrozen = timeFrozen;
|
||||||
|
|
||||||
float gameHour = 0.0F;
|
float gameHour = 0.0F;
|
||||||
float gameDaysPassed = 0.0F;
|
float gameDaysPassed = 0.0F;
|
||||||
if (!TryReadCalendarValues(gameHour, gameDaysPassed)) {
|
if (!TryReadCalendarValues(gameHour, gameDaysPassed)) {
|
||||||
@@ -333,7 +391,9 @@ namespace F4T::WorldStateSync
|
|||||||
const auto weatherFormId = TryReadExteriorWeatherFormId();
|
const auto weatherFormId = TryReadExteriorWeatherFormId();
|
||||||
const bool hasWeatherFormId = weatherFormId.has_value();
|
const bool hasWeatherFormId = weatherFormId.has_value();
|
||||||
const auto now = std::chrono::steady_clock::now();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,27 +401,33 @@ namespace F4T::WorldStateSync
|
|||||||
gameHour,
|
gameHour,
|
||||||
gameDaysPassed,
|
gameDaysPassed,
|
||||||
hasWeatherFormId,
|
hasWeatherFormId,
|
||||||
hasWeatherFormId ? *weatherFormId : 0U)) {
|
hasWeatherFormId ? *weatherFormId : 0U,
|
||||||
|
sendTime)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sendTime) {
|
||||||
g_lastSentWorldState.gameHour = gameHour;
|
g_lastSentWorldState.gameHour = gameHour;
|
||||||
g_lastSentWorldState.gameDaysPassed = gameDaysPassed;
|
g_lastSentWorldState.gameDaysPassed = gameDaysPassed;
|
||||||
|
}
|
||||||
g_lastSentWorldState.hasWeatherFormId = hasWeatherFormId;
|
g_lastSentWorldState.hasWeatherFormId = hasWeatherFormId;
|
||||||
g_lastSentWorldState.weatherFormId = weatherFormId;
|
g_lastSentWorldState.weatherFormId = weatherFormId;
|
||||||
g_lastSentWorldState.initialized = true;
|
g_lastSentWorldState.initialized = true;
|
||||||
g_lastHostSendTime = now;
|
g_lastHostSendTime = now;
|
||||||
|
|
||||||
if (ShouldLog("world_state_host_send")) {
|
if (ShouldLog("world_state_host_send")) {
|
||||||
|
const auto timeSyncNote = sendTime ? "timeSync=yes" : "timeSync=no";
|
||||||
if (hasWeatherFormId) {
|
if (hasWeatherFormId) {
|
||||||
LogInfo(std::format(
|
LogInfo(std::format(
|
||||||
"Sent worldState: gameHour={:.2f}, gameDaysPassed={:.3f}, weatherFormId=0x{:08X}.",
|
"Sent worldState ({}): gameHour={:.2f}, gameDaysPassed={:.3f}, weatherFormId=0x{:08X}.",
|
||||||
|
timeSyncNote,
|
||||||
gameHour,
|
gameHour,
|
||||||
gameDaysPassed,
|
gameDaysPassed,
|
||||||
*weatherFormId));
|
*weatherFormId));
|
||||||
} else {
|
} else {
|
||||||
LogInfo(std::format(
|
LogInfo(std::format(
|
||||||
"Sent worldState: gameHour={:.2f}, gameDaysPassed={:.3f}, weatherFormId=<none>.",
|
"Sent worldState ({}): gameHour={:.2f}, gameDaysPassed={:.3f}, weatherFormId=<none>.",
|
||||||
|
timeSyncNote,
|
||||||
gameHour,
|
gameHour,
|
||||||
gameDaysPassed));
|
gameDaysPassed));
|
||||||
}
|
}
|
||||||
@@ -374,22 +440,41 @@ namespace F4T::WorldStateSync
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bool timeFrozen = IsGameTimeFrozen();
|
||||||
|
const bool justResumed = g_wasGameTimeFrozen && !timeFrozen;
|
||||||
|
g_wasGameTimeFrozen = timeFrozen;
|
||||||
|
|
||||||
PendingWorldState pendingState{};
|
PendingWorldState pendingState{};
|
||||||
|
bool hasPendingState = false;
|
||||||
{
|
{
|
||||||
const std::scoped_lock lock(g_pendingMutex);
|
const std::scoped_lock lock(g_pendingMutex);
|
||||||
if (!g_hasPendingWorldState) {
|
if (g_hasPendingWorldState) {
|
||||||
|
pendingState = g_pendingWorldState;
|
||||||
|
g_hasPendingWorldState = false;
|
||||||
|
hasPendingState = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingState = g_pendingWorldState;
|
|
||||||
g_hasPendingWorldState = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
ApplyTimeState(pendingState.gameHour, pendingState.gameDaysPassed);
|
|
||||||
ApplyWeatherState(pendingState.hasWeatherFormId, pendingState.weatherFormId);
|
ApplyWeatherState(pendingState.hasWeatherFormId, pendingState.weatherFormId);
|
||||||
|
|
||||||
g_lastAppliedWorldState.gameHour = pendingState.gameHour;
|
|
||||||
g_lastAppliedWorldState.gameDaysPassed = pendingState.gameDaysPassed;
|
|
||||||
g_lastAppliedWorldState.hasWeatherFormId = pendingState.hasWeatherFormId;
|
g_lastAppliedWorldState.hasWeatherFormId = pendingState.hasWeatherFormId;
|
||||||
g_lastAppliedWorldState.weatherFormId = pendingState.weatherFormId;
|
g_lastAppliedWorldState.weatherFormId = pendingState.weatherFormId;
|
||||||
g_lastAppliedWorldState.initialized = true;
|
g_lastAppliedWorldState.initialized = true;
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ know the current host immediately.
|
|||||||
"gameHour": 14.25,
|
"gameHour": 14.25,
|
||||||
"gameDaysPassed": 12.5,
|
"gameDaysPassed": 12.5,
|
||||||
"weatherFormId": "00123456",
|
"weatherFormId": "00123456",
|
||||||
|
"timeSync": true,
|
||||||
"clientTime": 1780212128.301,
|
"clientTime": 1780212128.301,
|
||||||
"serverTime": 1780212128.301
|
"serverTime": 1780212128.301
|
||||||
}
|
}
|
||||||
@@ -57,18 +58,22 @@ know the current host immediately.
|
|||||||
| `gameHour` | number (float) | yes | In-game hour of day, `0.0`–`24.0` |
|
| `gameHour` | number (float) | yes | In-game hour of day, `0.0`–`24.0` |
|
||||||
| `gameDaysPassed` | number (float) | yes | In-game days passed global |
|
| `gameDaysPassed` | number (float) | yes | In-game days passed global |
|
||||||
| `weatherFormId` | string | no | 8-character hex `TESWeather` form ID; omitted or `""` when host is indoors |
|
| `weatherFormId` | string | no | 8-character hex `TESWeather` form ID; omitted or `""` when host is indoors |
|
||||||
|
| `timeSync` | boolean | no | When `true`, non-host clients should apply `gameHour` and `gameDaysPassed`. Omitted or `false` on weather-only heartbeats. |
|
||||||
| `clientTime` | number (double) | no | Sender Unix epoch seconds |
|
| `clientTime` | number (double) | no | Sender Unix epoch seconds |
|
||||||
| `serverTime` | number (double) | no | Relay timestamp added by server |
|
| `serverTime` | number (double) | no | Relay timestamp added by server |
|
||||||
|
|
||||||
## Send Cadence (Host)
|
## Send Cadence (Host)
|
||||||
|
|
||||||
- About 1 Hz heartbeat while connected
|
- About 1 Hz heartbeat for **weather** while connected (`timeSync` omitted or `false`)
|
||||||
- Immediate send when `weatherFormId` changes
|
- Immediate send with `timeSync: true` when:
|
||||||
- Immediate send when `gameHour` or `gameDaysPassed` jumps beyond a small threshold (wait, sleep, console time changes)
|
- First connected or promoted to host
|
||||||
|
- Game time resumes after a freeze (menus, pip-boy, dialogue, loading screens, save/load, wait/sleep)
|
||||||
|
- `gameHour` or `gameDaysPassed` jumps beyond a small threshold (wait, sleep, console time changes)
|
||||||
|
- Immediate send when `weatherFormId` changes (includes current time fields; sets `timeSync` only when time also changed or on resume)
|
||||||
|
|
||||||
## Client Apply Rules
|
## Client Apply Rules
|
||||||
|
|
||||||
- **Time:** non-host clients snap local calendar globals when host values differ beyond threshold.
|
- **Time:** non-host clients track the latest host time from every packet but only snap local calendar globals when `timeSync` is `true` and gameplay time is not frozen, or immediately when gameplay time resumes after a freeze (using the latest stored host values).
|
||||||
- **Weather:** non-host clients apply forced weather only when the local player is in an exterior cell.
|
- **Weather:** non-host clients apply forced weather only when the local player is in an exterior cell.
|
||||||
- Missing optional fields use safe defaults on receive.
|
- Missing optional fields use safe defaults on receive.
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ def update_host_world_state(packet: dict[str, Any]) -> None:
|
|||||||
"gameHour": get_optional_game_time(packet, "gameHour"),
|
"gameHour": get_optional_game_time(packet, "gameHour"),
|
||||||
"gameDaysPassed": get_optional_game_time(packet, "gameDaysPassed"),
|
"gameDaysPassed": get_optional_game_time(packet, "gameDaysPassed"),
|
||||||
"weatherFormId": packet.get("weatherFormId", ""),
|
"weatherFormId": packet.get("weatherFormId", ""),
|
||||||
|
"timeSync": packet.get("timeSync", False),
|
||||||
"clientTime": packet.get("clientTime", 0.0),
|
"clientTime": packet.get("clientTime", 0.0),
|
||||||
"serverTime": packet.get("serverTime", 0.0),
|
"serverTime": packet.get("serverTime", 0.0),
|
||||||
"lastReceivedLocalTime": time.time(),
|
"lastReceivedLocalTime": time.time(),
|
||||||
@@ -93,6 +94,7 @@ def print_host_world_state(world_state: dict[str, Any]) -> None:
|
|||||||
f"Game Hour: {world_state['gameHour']:.3f}",
|
f"Game Hour: {world_state['gameHour']:.3f}",
|
||||||
f"Game Days Passed: {world_state['gameDaysPassed']:.3f}",
|
f"Game Days Passed: {world_state['gameDaysPassed']:.3f}",
|
||||||
f"Weather Form ID: {weather_form_id}",
|
f"Weather Form ID: {weather_form_id}",
|
||||||
|
f"Time Sync: {world_state.get('timeSync', False)}",
|
||||||
f"Client Time: {client_time}",
|
f"Client Time: {client_time}",
|
||||||
f"Last Server Time: {server_time}",
|
f"Last Server Time: {server_time}",
|
||||||
f"Last Received Local Time: {world_state['lastReceivedLocalTime']}",
|
f"Last Received Local Time: {world_state['lastReceivedLocalTime']}",
|
||||||
|
|||||||
Reference in New Issue
Block a user