Add host-authoritative world-state sync

Implement host-authoritative time/weather sync across server and clients. Adds a new worldState packet (and worldStateHost for host assignment) and server-side host tracking/reassignment to the lowest remaining playerId when the host disconnects. Plugin-side changes introduce F4T::WorldStateSync (new header + implementation), polling/apply hooks in the game-thread main loop, and Networking support to send/receive/parse worldState/worldStateHost packets. The server validates host-only sends, relays worldState to peers, broadcasts worldStateHost on host handoff, and augments logging/stats. Protocol and documentation files updated (protocol/world-state.md, docs/*) and server/fake_client.py extended to track/print host world-state snapshots.
This commit is contained in:
2026-06-22 21:50:59 +12:00
parent 9ceb6601ae
commit 99595ef87c
15 changed files with 1042 additions and 33 deletions
+184 -3
View File
@@ -2,6 +2,7 @@
#include "F4TNetworking.h"
#include "F4TRemotePlayerState.h"
#include "F4TWorldStateSync.h"
#include <WinSock2.h>
#include <WS2tcpip.h>
@@ -169,9 +170,9 @@ namespace
}
}
std::optional<std::uint32_t> ReadPlayerId(const Json& a_packet)
std::optional<std::uint32_t> ReadPlayerId(const Json& a_packet, const char* a_fieldName = "playerId")
{
const auto playerId = a_packet.find("playerId");
const auto playerId = a_packet.find(a_fieldName);
if (playerId == a_packet.end()) {
return std::nullopt;
}
@@ -306,7 +307,109 @@ namespace
}
F4T::RemotePlayerState::SetAssignedPlayerId(*playerId);
LogInfoWithLocalPlayerPrefix(std::format("Assigned server playerId: {}", *playerId));
const auto worldStateHostPlayerId = ReadPlayerId(a_packet, "worldStateHostPlayerId");
if (worldStateHostPlayerId) {
F4T::WorldStateSync::SetWorldStateHostPlayerId(*worldStateHostPlayerId);
} else {
F4T::WorldStateSync::SetWorldStateHostPlayerId(1);
}
if (F4T::WorldStateSync::IsWorldStateHost()) {
LogInfoWithLocalPlayerPrefix(std::format(
"Assigned server playerId: {} (world-state host).",
*playerId));
} else if (const auto hostPlayerId = F4T::WorldStateSync::GetWorldStateHostPlayerId()) {
LogInfoWithLocalPlayerPrefix(std::format(
"Assigned server playerId: {} (world-state host is player {}).",
*playerId,
*hostPlayerId));
} else {
LogInfoWithLocalPlayerPrefix(std::format("Assigned server playerId: {}", *playerId));
}
}
void HandleWorldStateHostPacket(const Json& a_packet)
{
const auto hostPlayerId = ReadPlayerId(a_packet, "worldStateHostPlayerId");
if (!hostPlayerId) {
LogThrottledWarning(
"world_state_host_missing",
"Ignoring worldStateHost packet without a valid worldStateHostPlayerId.");
return;
}
F4T::WorldStateSync::SetWorldStateHostPlayerId(*hostPlayerId);
}
std::optional<std::uint32_t> ParseHexFormId(std::string_view a_hex)
{
if (a_hex.empty()) {
return std::nullopt;
}
std::uint32_t value = 0;
const auto* begin = a_hex.data();
const auto* end = begin + a_hex.size();
const auto result = std::from_chars(begin, end, value, 16);
if (result.ec != std::errc{} || result.ptr != end) {
return std::nullopt;
}
return value;
}
void HandleWorldStatePacket(const Json& a_packet)
{
const auto gameHour = ReadDouble(a_packet, "gameHour");
const auto gameDaysPassed = ReadDouble(a_packet, "gameDaysPassed");
if (!gameHour || !gameDaysPassed) {
LogThrottledWarning(
"world_state_missing_fields",
"Ignoring worldState packet missing required gameHour or gameDaysPassed.");
return;
}
if (!std::isfinite(*gameHour) || !std::isfinite(*gameDaysPassed)) {
LogThrottledWarning("world_state_invalid_numbers", "Ignoring worldState packet with non-finite time values.");
return;
}
F4T::WorldStateSync::PendingWorldState pendingState{
static_cast<float>(*gameHour),
static_cast<float>(*gameDaysPassed),
std::nullopt,
false
};
const auto weatherFormIdText = ReadString(a_packet, "weatherFormId");
if (weatherFormIdText && !weatherFormIdText->empty()) {
if (const auto weatherFormId = ParseHexFormId(*weatherFormIdText)) {
pendingState.weatherFormId = *weatherFormId;
pendingState.hasWeatherFormId = true;
} else if (ShouldLogWarning("world_state_weather_parse")) {
LogWarningWithLocalPlayerPrefix(std::format(
"Ignoring invalid weatherFormId '{}' in worldState packet.",
*weatherFormIdText));
}
}
if (ShouldLogRemoteUpdate(1)) {
if (pendingState.hasWeatherFormId && pendingState.weatherFormId) {
LogInfoWithLocalPlayerPrefix(std::format(
"Queued host worldState: gameHour={:.2f}, gameDaysPassed={:.3f}, weatherFormId=0x{:08X}.",
pendingState.gameHour,
pendingState.gameDaysPassed,
*pendingState.weatherFormId));
} else {
LogInfoWithLocalPlayerPrefix(std::format(
"Queued host worldState: gameHour={:.2f}, gameDaysPassed={:.3f}, weatherFormId=<none>.",
pendingState.gameHour,
pendingState.gameDaysPassed));
}
}
F4T::WorldStateSync::SetPendingWorldState(std::move(pendingState));
}
void HandleTransformPacket(const Json& a_packet)
@@ -494,6 +597,10 @@ namespace
HandleWelcomePacket(packet);
} else if (*packetType == "transform") {
HandleTransformPacket(packet);
} else if (*packetType == "worldState") {
HandleWorldStatePacket(packet);
} else if (*packetType == "worldStateHost") {
HandleWorldStateHostPacket(packet);
} else if (*packetType == "disconnect") {
HandleDisconnectPacket(packet);
} else {
@@ -657,6 +764,7 @@ namespace F4T::Networking
F4T::RemotePlayerState::ClearAssignedPlayerId();
F4T::RemotePlayerState::ClearRemotePlayers();
F4T::WorldStateSync::Reset();
g_lastRemoteUpdateLogTimes.clear();
g_lastRemoteMovementStateLogTimes.clear();
StartReceiveThread();
@@ -713,6 +821,7 @@ namespace F4T::Networking
StopReceiveThread();
F4T::RemotePlayerState::ClearAssignedPlayerId();
F4T::RemotePlayerState::ClearRemotePlayers();
F4T::WorldStateSync::Reset();
g_lastRemoteUpdateLogTimes.clear();
g_lastRemoteMovementStateLogTimes.clear();
@@ -860,4 +969,76 @@ namespace F4T::Networking
return true;
}
bool SendWorldStatePacket(
float a_gameHour,
float a_gameDaysPassed,
bool a_hasWeatherFormId,
std::uint32_t a_weatherFormId)
{
if (!IsConnectedToServer()) {
return false;
}
if (!std::isfinite(a_gameHour) || !std::isfinite(a_gameDaysPassed)) {
return false;
}
const auto clientTime =
std::chrono::duration<double>(std::chrono::system_clock::now().time_since_epoch()).count();
std::array<char, 256> 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",
a_gameHour,
a_gameDaysPassed,
a_weatherFormId,
clientTime);
} else {
packetSize = std::snprintf(
packet.data(),
packet.size(),
"{\"type\":\"worldState\",\"gameHour\":%.3f,\"gameDaysPassed\":%.3f,\"clientTime\":%.3f}\n",
a_gameHour,
a_gameDaysPassed,
clientTime);
}
if (packetSize <= 0 || static_cast<std::size_t>(packetSize) >= packet.size()) {
LogWarningWithLocalPlayerPrefix("Could not format Commonwealth Online worldState packet.");
return false;
}
int bytesSent = 0;
{
const std::scoped_lock lock(g_socketMutex);
if (g_socket == INVALID_SOCKET) {
return false;
}
bytesSent = send(g_socket, packet.data(), packetSize, 0);
}
if (bytesSent == SOCKET_ERROR) {
const auto error = WSAGetLastError();
if (error != WSAEWOULDBLOCK) {
LogWarningWithLocalPlayerPrefix("Lost connection to Commonwealth Online local server.");
CloseSocket();
}
return false;
}
if (bytesSent != packetSize) {
LogWarningWithLocalPlayerPrefix("Could not send full Commonwealth Online worldState packet.");
CloseSocket();
return false;
}
return true;
}
}
+397
View File
@@ -0,0 +1,397 @@
#include "pch.h"
#include "F4TWorldStateSync.h"
#include "F4TNetworking.h"
#include "F4TRemotePlayerState.h"
#include "RE/C/Calendar.h"
#include "RE/P/PlayerCharacter.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 <cmath>
#include <format>
#include <mutex>
#include <optional>
#include <string>
#include <unordered_map>
namespace
{
constexpr auto kWorldStateHeartbeatInterval = 1s;
constexpr float kGameHourSendThreshold = 0.05F;
constexpr float kGameDaysPassedSendThreshold = 0.001F;
constexpr float kGameHourApplyThreshold = 0.05F;
constexpr float kGameDaysPassedApplyThreshold = 0.001F;
constexpr auto kWorldStateLogInterval = 2s;
std::mutex g_pendingMutex;
bool g_hasPendingWorldState{ false };
F4T::WorldStateSync::PendingWorldState g_pendingWorldState{};
struct LastSentWorldState
{
float gameHour{ -1.0F };
float gameDaysPassed{ -1.0F };
std::optional<std::uint32_t> weatherFormId;
bool hasWeatherFormId{ false };
bool initialized{ false };
};
struct LastAppliedWorldState
{
float gameHour{ -1.0F };
float gameDaysPassed{ -1.0F };
std::optional<std::uint32_t> weatherFormId;
bool hasWeatherFormId{ false };
bool initialized{ false };
};
LastSentWorldState g_lastSentWorldState{};
LastAppliedWorldState g_lastAppliedWorldState{};
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;
std::string GetLocalPlayerLogMessage(std::string_view a_message)
{
return F4T::Networking::GetLocalPlayerLogPrefix() + " " + std::string(a_message);
}
void LogInfo(std::string_view a_message)
{
const auto message = GetLocalPlayerLogMessage(a_message);
REX::INFO(std::string_view{ message });
}
void LogWarning(std::string_view a_message)
{
const auto message = GetLocalPlayerLogMessage(a_message);
REX::WARN(std::string_view{ message });
}
bool ShouldLog(const std::string& a_key)
{
const auto now = std::chrono::steady_clock::now();
const auto lastLog = g_lastLogTimes.find(a_key);
if (lastLog != g_lastLogTimes.end() && now - lastLog->second < kWorldStateLogInterval) {
return false;
}
g_lastLogTimes[a_key] = now;
return true;
}
bool IsLocalPlayerExterior()
{
const auto* player = RE::PlayerCharacter::GetSingleton();
if (!player) {
return false;
}
const auto* playerCell = player->GetParentCell();
return playerCell && playerCell->IsExterior();
}
bool TryReadCalendarValues(float& a_outGameHour, float& a_outGameDaysPassed)
{
const auto* calendar = RE::Calendar::GetSingleton();
if (!calendar || !calendar->gameHour || !calendar->gameDaysPassed) {
return false;
}
a_outGameHour = calendar->gameHour->GetValue();
a_outGameDaysPassed = calendar->gameDaysPassed->GetValue();
return std::isfinite(a_outGameHour) && std::isfinite(a_outGameDaysPassed);
}
std::optional<std::uint32_t> TryReadExteriorWeatherFormId()
{
const auto* sky = RE::Sky::GetSingleton();
if (!sky || sky->mode == RE::Sky::Mode::kInterior) {
return std::nullopt;
}
const auto* currentWeather = sky->currentWeather;
if (!currentWeather) {
return std::nullopt;
}
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)
{
if (!g_lastSentWorldState.initialized) {
return true;
}
if (std::abs(a_gameHour - g_lastSentWorldState.gameHour) > kGameHourSendThreshold ||
std::abs(a_gameDaysPassed - g_lastSentWorldState.gameDaysPassed) > kGameDaysPassedSendThreshold) {
return true;
}
if (a_hasWeatherFormId != g_lastSentWorldState.hasWeatherFormId ||
a_weatherFormId != g_lastSentWorldState.weatherFormId) {
return true;
}
return g_lastHostSendTime.time_since_epoch().count() == 0 ||
a_now - g_lastHostSendTime >= kWorldStateHeartbeatInterval;
}
void ApplyTimeState(float a_gameHour, float a_gameDaysPassed)
{
auto* calendar = RE::Calendar::GetSingleton();
if (!calendar || !calendar->gameHour || !calendar->gameDaysPassed) {
return;
}
const auto localGameHour = calendar->gameHour->GetValue();
const auto localGameDaysPassed = calendar->gameDaysPassed->GetValue();
if (std::abs(localGameHour - a_gameHour) <= kGameHourApplyThreshold &&
std::abs(localGameDaysPassed - a_gameDaysPassed) <= kGameDaysPassedApplyThreshold) {
return;
}
calendar->gameHour->value = a_gameHour;
calendar->gameDaysPassed->value = a_gameDaysPassed;
if (auto* sky = RE::Sky::GetSingleton()) {
sky->currentGameHour = a_gameHour;
}
if (ShouldLog("world_state_time_apply")) {
LogInfo(std::format(
"Applied host world time: gameHour={:.2f}, gameDaysPassed={:.3f}.",
a_gameHour,
a_gameDaysPassed));
}
}
void ApplyWeatherState(bool a_hasWeatherFormId, std::optional<std::uint32_t> a_weatherFormId)
{
if (!IsLocalPlayerExterior()) {
return;
}
auto* sky = RE::Sky::GetSingleton();
if (!sky) {
return;
}
if (!a_hasWeatherFormId || !a_weatherFormId) {
return;
}
auto* weather = RE::TESForm::GetFormByID<RE::TESWeather>(*a_weatherFormId);
if (!weather) {
if (ShouldLog("world_state_weather_lookup_failed")) {
LogWarning(std::format(
"Could not resolve host weather form 0x{:08X} for world-state apply.",
*a_weatherFormId));
}
return;
}
if (g_lastAppliedWorldState.initialized &&
g_lastAppliedWorldState.hasWeatherFormId &&
g_lastAppliedWorldState.weatherFormId == a_weatherFormId &&
sky->overrideWeather == weather) {
return;
}
sky->ForceWeather(weather, true);
if (ShouldLog("world_state_weather_apply")) {
LogInfo(std::format("Applied host weather form 0x{:08X}.", *a_weatherFormId));
}
}
void SeedHostCaptureFromLocalState()
{
float gameHour = 0.0F;
float gameDaysPassed = 0.0F;
if (!TryReadCalendarValues(gameHour, gameDaysPassed)) {
g_lastSentWorldState = {};
g_lastHostSendTime = std::chrono::steady_clock::now();
return;
}
const auto weatherFormId = TryReadExteriorWeatherFormId();
g_lastSentWorldState.gameHour = gameHour;
g_lastSentWorldState.gameDaysPassed = gameDaysPassed;
g_lastSentWorldState.hasWeatherFormId = weatherFormId.has_value();
g_lastSentWorldState.weatherFormId = weatherFormId;
g_lastSentWorldState.initialized = true;
g_lastHostSendTime = std::chrono::steady_clock::now();
}
}
namespace F4T::WorldStateSync
{
bool IsWorldStateHost()
{
const auto assignedPlayerId = RemotePlayerState::GetAssignedPlayerId();
return assignedPlayerId && g_worldStateHostPlayerId && *assignedPlayerId == *g_worldStateHostPlayerId;
}
std::optional<std::uint32_t> GetWorldStateHostPlayerId()
{
return g_worldStateHostPlayerId;
}
void SetWorldStateHostPlayerId(std::uint32_t a_hostPlayerId)
{
const auto assignedPlayerId = RemotePlayerState::GetAssignedPlayerId();
const bool wasHost =
assignedPlayerId && g_worldStateHostPlayerId && *assignedPlayerId == *g_worldStateHostPlayerId;
const bool hostChanged = !g_worldStateHostPlayerId || *g_worldStateHostPlayerId != a_hostPlayerId;
g_worldStateHostPlayerId = a_hostPlayerId;
const bool isNowHost = assignedPlayerId && *assignedPlayerId == a_hostPlayerId;
if (isNowHost && (!wasHost || hostChanged)) {
{
const std::scoped_lock lock(g_pendingMutex);
g_hasPendingWorldState = false;
g_pendingWorldState = {};
}
SeedHostCaptureFromLocalState();
if (ShouldLog("world_state_host_promoted")) {
LogInfo(std::format(
"Promoted to world-state host (hostPlayerId={}). Continuing with current local time and weather.",
a_hostPlayerId));
}
return;
}
if (!isNowHost && wasHost) {
g_lastSentWorldState = {};
g_lastHostSendTime = {};
if (ShouldLog("world_state_host_demoted")) {
LogInfo(std::format("No longer world-state host; new host is player {}.", a_hostPlayerId));
}
return;
}
if (!isNowHost && hostChanged && ShouldLog("world_state_host_changed")) {
LogInfo(std::format("World-state host is now player {}.", a_hostPlayerId));
}
}
void Reset()
{
{
const std::scoped_lock lock(g_pendingMutex);
g_hasPendingWorldState = false;
g_pendingWorldState = {};
}
g_lastSentWorldState = {};
g_lastAppliedWorldState = {};
g_lastHostSendTime = {};
g_worldStateHostPlayerId = std::nullopt;
g_lastLogTimes.clear();
}
void SetPendingWorldState(PendingWorldState a_state)
{
if (IsWorldStateHost()) {
return;
}
{
const std::scoped_lock lock(g_pendingMutex);
g_pendingWorldState = std::move(a_state);
g_hasPendingWorldState = true;
}
}
void PollHostCapture()
{
if (!Networking::IsConnectedToServer() || !IsWorldStateHost()) {
return;
}
float gameHour = 0.0F;
float gameDaysPassed = 0.0F;
if (!TryReadCalendarValues(gameHour, gameDaysPassed)) {
return;
}
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)) {
return;
}
if (!Networking::SendWorldStatePacket(
gameHour,
gameDaysPassed,
hasWeatherFormId,
hasWeatherFormId ? *weatherFormId : 0U)) {
return;
}
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")) {
if (hasWeatherFormId) {
LogInfo(std::format(
"Sent worldState: gameHour={:.2f}, gameDaysPassed={:.3f}, weatherFormId=0x{:08X}.",
gameHour,
gameDaysPassed,
*weatherFormId));
} else {
LogInfo(std::format(
"Sent worldState: gameHour={:.2f}, gameDaysPassed={:.3f}, weatherFormId=<none>.",
gameHour,
gameDaysPassed));
}
}
}
void ApplyPendingState()
{
if (!Networking::IsConnectedToServer() || IsWorldStateHost()) {
return;
}
PendingWorldState pendingState{};
{
const std::scoped_lock lock(g_pendingMutex);
if (!g_hasPendingWorldState) {
return;
}
pendingState = g_pendingWorldState;
g_hasPendingWorldState = false;
}
ApplyTimeState(pendingState.gameHour, pendingState.gameDaysPassed);
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;
}
}
+3
View File
@@ -6,6 +6,7 @@
#include "F4TProxyActorController.h"
#include "F4TProxyAnimationSync.h"
#include "F4TProxyPuppet.h"
#include "F4TWorldStateSync.h"
#include "RE/A/ACTOR_STANCE.h"
#include "RE/B/BSFixedString.h"
@@ -654,6 +655,8 @@ namespace
// check on the game thread after load, while separate send and log gates keep updates useful.
taskInterface->AddTaskPermanent([]() {
PollLocalPlayerTransform();
F4T::WorldStateSync::PollHostCapture();
F4T::WorldStateSync::ApplyPendingState();
F4T::ProxyActorController::Update();
F4T::SessionLaunch::Update();
});