Add serverWorldState packet and GUI controls

Introduce a server-authoritative world-state packet to control weather and time from the dev server GUI. Adds protocol docs and message type (serverWorldState), a new Weather/Time tab in dev_server_app.py, server-side broadcasting in server_core.py, and preset utilities in server/world_state_presets.py. Plugin changes (F4TNetworking.cpp, F4TWorldStateSync.*) queue and apply incoming serverWorldState on the game thread, run `set gamehour` for all clients, and run `fw <8-digit-id>` on the assigned player 1 then force the host to relay a worldState snapshot. Also includes parsing/validation of hex form IDs, logging improvements, and minimal fake client handling. This ensures consistent server-driven time/weather and documents usage and presets.
This commit is contained in:
2026-06-23 23:03:34 +12:00
parent 70933db6e5
commit 4c17e70620
14 changed files with 757 additions and 3 deletions
+9
View File
@@ -10,10 +10,19 @@ For testing notes, milestone summaries, known issues, and next steps, see [`docs
## [Unreleased] ## [Unreleased]
### Added ### Added
- Server-authoritative weather and time control via `serverWorldState` packets from the dev server GUI.
- **Weather / Time** tab in `server/dev_server_app.py` with preset buttons for `fw` weather form IDs and `set gamehour to HHmm` time values.
- `server/world_state_presets.py` with shared weather and time preset definitions.
- `protocol/server-world-state.md` documenting the new packet type.
- 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. - 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. - `timeSync` tracking in `server/fake_client.py` host world-state snapshot output.
### Fixed
- Server GUI weather now runs `fw <8-digit-form-id>` on player 1 only (not editor names), scheduled on the game thread.
- Weather `serverWorldState` packets are broadcast to all clients so player 1's game instance always receives them.
### Changed ### Changed
- Weather presets store the exact `fw` console argument (8-digit hex); edit `server/world_state_presets.py` only if a preset ID fails in your console.
- World time no longer re-applies on every `worldState` heartbeat; weather heartbeats continue at ~1 Hz while time stays in sync during normal gameplay. - 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/).
+3 -1
View File
@@ -33,7 +33,9 @@ both send and receive paths:
host sends `worldState` packets with `gameHour`, `gameDaysPassed`, optional host sends `worldState` packets with `gameHour`, `gameDaysPassed`, optional
`weatherFormId`, and optional `timeSync`. Weather heartbeats run at ~1 Hz; `weatherFormId`, and optional `timeSync`. Weather heartbeats run at ~1 Hz;
time is pushed with `timeSync: true` on connect, after time freezes (menus, time is pushed with `timeSync: true` on connect, after time freezes (menus,
loads, saves), and on large time jumps. Promoted hosts continue with their loads, saves), and on large time jumps. The dev server GUI can also broadcast
`serverWorldState` to all clients for admin weather (`fw`) and time
(`set gamehour to HHmm`) commands. Promoted hosts continue with their
already-synced local time and weather so remaining clients should not see a jump. 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
+131
View File
@@ -9,6 +9,137 @@ failed experiments, successful tests, and next steps.
--- ---
## 2026-06-23 - GUI Weather Uses Numeric `fw` ID
### Summary
Fixed dev server GUI weather by matching manual console behaviour: player 1 runs `fw <8-digit-hex-form-id>` on the game thread instead of `fw <editorName>`. Weather packets are broadcast to all clients; only player 1 executes.
### Files Changed
- `plugin/src/F4TWorldStateSync.cpp`
- `plugin/src/F4TNetworking.cpp`
- `plugin/include/F4TWorldStateSync.h`
- `server/world_state_presets.py`
- `server/server_core.py`
- `server/dev_server_app.py`
- `protocol/server-world-state.md`
- `changelog.md`
### Details
- `weatherConsoleArg` is the exact `fw` argument (8-digit hex, e.g. `00148c45`).
- `weatherFormId` is used for host `worldState` relay after `fw` runs.
- Preset IDs in `world_state_presets.py` may differ from what works in a given load order; edit them to match console-tested IDs.
### Testing
- User confirmed `fw` works with a numeric form ID (e.g. `fw 0000015E`) but not with editor names like `fw CommonwealthRain`.
- Plugin rebuild and GUI retest pending.
### Known Issues
- None currently known.
### Next Steps
- Rebuild plugin, connect game client as player 1 (first to connect), click a weather preset outdoors.
- Check `CommonwealthOnline.log` for `Player 1 executed server weather command: fw ...`.
- Update `WEATHER_PRESETS` if needed to use IDs verified in your console.
---
## 2026-06-23 - Host-Console Weather Relay
### Summary
Server GUI weather now runs only on client 1: the host executes `fw <editorId>` as if typed in the console, then immediately relays `worldState` so other clients sync through the existing host weather path.
### Files Changed
- `plugin/src/F4TWorldStateSync.cpp`
- `server/server_core.py`
- `server/dev_server_app.py`
- `protocol/server-world-state.md`
- `changelog.md`
### Details
- Non-host clients ignore weather fields in `serverWorldState`.
- Server sends weather packets only to the connected world-state host.
- Host force-broadcasts `worldState` with the requested `weatherFormId` after running `fw`.
### Testing
- Not re-tested in-game in this session.
- Recommended: two clients outdoors, client 1 connected first; set Rain from GUI; confirm host log shows `fw CommonwealthRain` and client 2 receives host `worldState`.
### Known Issues
- None currently known.
### Next Steps
- In-game verification that host console `fw` works from plugin `ExecuteCommand`.
---
## 2026-06-23 - Server Weather Apply Fix
### Summary
Fixed dev server GUI weather not applying and clients drifting to different weather after a server time change.
### Files Changed
- `plugin/src/F4TWorldStateSync.cpp`
- `plugin/include/F4TWorldStateSync.h`
- `plugin/src/F4TNetworking.cpp`
- `server/server_core.py`
- `server/world_state_presets.py`
- `server/dev_server_app.py`
- `protocol/server-world-state.md`
- `changelog.md`
### Details
- Weather now uses `Sky::ForceWeather` with hex `weatherFormId` instead of `fw` via `ExecuteCommand`.
- Server broadcasts the full snapshot on every GUI change so time buttons also re-send weather.
- Host weather capture state updates after server weather apply to avoid stale host heartbeats.
### Testing
- Not re-tested in-game in this session.
- Recommended: two clients outdoors, set weather from GUI, then change time and confirm both stay on the same weather.
### Known Issues
- None currently known.
### Next Steps
- Verify Glowing Sea form ID (`001B9779`) in-game; adjust preset if needed.
---
## 2026-06-23 - Server Weather and Time GUI
### Summary
Added server-authoritative weather and time control from the dev server GUI. A new **Weather / Time** tab broadcasts `serverWorldState` packets to all connected clients, which apply `fw <editorId>` for weather and `set gamehour to HHmm` for time.
### Files Changed
- `server/server_core.py`
- `server/dev_server_app.py`
- `server/world_state_presets.py`
- `server/fake_client.py`
- `plugin/include/F4TWorldStateSync.h`
- `plugin/src/F4TWorldStateSync.cpp`
- `plugin/src/F4TNetworking.cpp`
- `protocol/server-world-state.md`
- `protocol/message-types.md`
- `docs/protocol.md`
- `changelog.md`
### Details
- Dev server stores the latest requested time/weather and broadcasts to every connected client.
- Plugin queues `serverWorldState` on the network thread and runs console commands on the game thread.
- Weather presets use Commonwealth editor IDs (`CommonwealthClear`, `CommonwealthRain`, etc.) because `setweather` does not work on Fallout 4.
- Time presets use HHmm values such as `1900` for 7:00 PM.
### Testing
- Not run in-game in this session.
- Recommended: start dev server GUI, connect one or two game clients outdoors, use Weather / Time tab buttons, verify `CommonwealthOnline.log` shows applied server commands and in-game weather/time changes.
### Known Issues
- None currently known.
### Next Steps
- In-game verification with two clients and mixed interior/exterior weather apply behaviour.
---
## 2026-06-23 - Event-Driven Time Sync ## 2026-06-23 - Event-Driven Time Sync
### Summary ### Summary
+1
View File
@@ -16,6 +16,7 @@ Long-term versions may move to a binary protocol if performance becomes a proble
- `transform` - `transform`
- `worldState` - `worldState`
- `worldStateHost` - `worldStateHost`
- `serverWorldState`
- `disconnect` - `disconnect`
- `ping` - `ping`
- `pong` - `pong`
+8
View File
@@ -15,11 +15,19 @@ namespace F4T::WorldStateSync
bool applyTime{ false }; bool applyTime{ false };
}; };
struct PendingServerWorldState
{
std::optional<std::string> timeHHmm;
std::optional<std::string> weatherFormId;
std::optional<std::string> weatherConsoleArg;
};
bool IsWorldStateHost(); bool IsWorldStateHost();
std::optional<std::uint32_t> GetWorldStateHostPlayerId(); std::optional<std::uint32_t> GetWorldStateHostPlayerId();
void SetWorldStateHostPlayerId(std::uint32_t a_hostPlayerId); void SetWorldStateHostPlayerId(std::uint32_t a_hostPlayerId);
void Reset(); void Reset();
void SetPendingWorldState(PendingWorldState a_state); void SetPendingWorldState(PendingWorldState a_state);
void SetPendingServerWorldState(PendingServerWorldState a_state);
void PollHostCapture(); void PollHostCapture();
void ApplyPendingState(); void ApplyPendingState();
} }
+51
View File
@@ -359,6 +359,55 @@ namespace
return value; return value;
} }
void HandleServerWorldStatePacket(const Json& a_packet)
{
F4T::WorldStateSync::PendingServerWorldState pendingState{};
const auto timeHHmm = ReadString(a_packet, "timeHHmm");
if (timeHHmm && !timeHHmm->empty()) {
pendingState.timeHHmm = *timeHHmm;
}
const auto weatherConsoleArg = ReadString(a_packet, "weatherConsoleArg");
if (weatherConsoleArg && !weatherConsoleArg->empty()) {
pendingState.weatherConsoleArg = *weatherConsoleArg;
}
const auto weatherFormId = ReadString(a_packet, "weatherFormId");
if (weatherFormId && !weatherFormId->empty()) {
pendingState.weatherFormId = *weatherFormId;
}
if (!pendingState.timeHHmm && !pendingState.weatherConsoleArg && !pendingState.weatherFormId) {
LogThrottledWarning(
"server_world_state_empty",
"Ignoring serverWorldState packet without timeHHmm or weather fields.");
return;
}
if (ShouldLogRemoteUpdate(1)) {
std::string details;
if (pendingState.timeHHmm) {
details += std::format("timeHHmm={}", *pendingState.timeHHmm);
}
if (pendingState.weatherConsoleArg) {
if (!details.empty()) {
details += ", ";
}
details += std::format("weatherConsoleArg={}", *pendingState.weatherConsoleArg);
}
if (pendingState.weatherFormId) {
if (!details.empty()) {
details += ", ";
}
details += std::format("weatherFormId={}", *pendingState.weatherFormId);
}
LogInfoWithLocalPlayerPrefix(std::format("Queued serverWorldState: {}.", details));
}
F4T::WorldStateSync::SetPendingServerWorldState(std::move(pendingState));
}
void HandleWorldStatePacket(const Json& a_packet) void HandleWorldStatePacket(const Json& a_packet)
{ {
const auto gameHour = ReadDouble(a_packet, "gameHour"); const auto gameHour = ReadDouble(a_packet, "gameHour");
@@ -603,6 +652,8 @@ namespace
HandleTransformPacket(packet); HandleTransformPacket(packet);
} else if (*packetType == "worldState") { } else if (*packetType == "worldState") {
HandleWorldStatePacket(packet); HandleWorldStatePacket(packet);
} else if (*packetType == "serverWorldState") {
HandleServerWorldStatePacket(packet);
} else if (*packetType == "worldStateHost") { } else if (*packetType == "worldStateHost") {
HandleWorldStateHostPacket(packet); HandleWorldStateHostPacket(packet);
} else if (*packetType == "disconnect") { } else if (*packetType == "disconnect") {
+211 -1
View File
@@ -5,8 +5,11 @@
#include "F4TNetworking.h" #include "F4TNetworking.h"
#include "F4TRemotePlayerState.h" #include "F4TRemotePlayerState.h"
#include "F4SE/Interfaces.h"
#include "RE/B/BGSSaveLoadManager.h" #include "RE/B/BGSSaveLoadManager.h"
#include "RE/C/Calendar.h" #include "RE/C/Calendar.h"
#include "RE/C/Console.h"
#include "RE/L/LoadingMenu.h" #include "RE/L/LoadingMenu.h"
#include "RE/M/Main.h" #include "RE/M/Main.h"
#include "RE/M/MainMenu.h" #include "RE/M/MainMenu.h"
@@ -21,6 +24,7 @@
#include "RE/U/UI.h" #include "RE/U/UI.h"
#include <cmath> #include <cmath>
#include <charconv>
#include <format> #include <format>
#include <mutex> #include <mutex>
#include <optional> #include <optional>
@@ -39,6 +43,8 @@ namespace
std::mutex g_pendingMutex; std::mutex g_pendingMutex;
bool g_hasPendingWorldState{ false }; bool g_hasPendingWorldState{ false };
F4T::WorldStateSync::PendingWorldState g_pendingWorldState{}; F4T::WorldStateSync::PendingWorldState g_pendingWorldState{};
bool g_hasPendingServerWorldState{ false };
F4T::WorldStateSync::PendingServerWorldState g_pendingServerWorldState{};
struct LastSentWorldState struct LastSentWorldState
{ {
@@ -172,6 +178,102 @@ namespace
return currentWeather->GetFormID(); return currentWeather->GetFormID();
} }
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;
}
bool ForceHostWorldStateBroadcast(std::optional<std::uint32_t> a_forcedWeatherFormId, bool a_sendTime)
{
if (!F4T::WorldStateSync::IsWorldStateHost() || !F4T::Networking::IsConnectedToServer()) {
return false;
}
float gameHour = 0.0F;
float gameDaysPassed = 0.0F;
if (!TryReadCalendarValues(gameHour, gameDaysPassed)) {
return false;
}
bool hasWeatherFormId = false;
std::uint32_t weatherFormId = 0U;
if (a_forcedWeatherFormId) {
hasWeatherFormId = true;
weatherFormId = *a_forcedWeatherFormId;
} else if (const auto currentWeatherFormId = TryReadExteriorWeatherFormId()) {
hasWeatherFormId = true;
weatherFormId = *currentWeatherFormId;
}
if (!F4T::Networking::SendWorldStatePacket(
gameHour,
gameDaysPassed,
hasWeatherFormId,
weatherFormId,
a_sendTime)) {
return false;
}
if (a_sendTime) {
g_lastSentWorldState.gameHour = gameHour;
g_lastSentWorldState.gameDaysPassed = gameDaysPassed;
}
g_lastSentWorldState.hasWeatherFormId = hasWeatherFormId;
g_lastSentWorldState.weatherFormId = hasWeatherFormId ? std::optional<std::uint32_t>{ weatherFormId } : std::nullopt;
g_lastSentWorldState.initialized = true;
g_lastHostSendTime = std::chrono::steady_clock::now();
if (ShouldLog("server_world_state_host_relay")) {
if (hasWeatherFormId) {
LogInfo(std::format(
"Relayed host worldState after server weather command: gameHour={:.2f}, weatherFormId=0x{:08X}.",
gameHour,
weatherFormId));
} else {
LogInfo(std::format(
"Relayed host worldState after server weather command: gameHour={:.2f}, weatherFormId=<none>.",
gameHour));
}
}
return true;
}
std::optional<std::string> BuildHostWeatherConsoleArg(
const std::optional<std::string>& a_weatherConsoleArg,
const std::optional<std::string>& a_weatherFormId)
{
if (a_weatherConsoleArg && !a_weatherConsoleArg->empty()) {
return *a_weatherConsoleArg;
}
if (a_weatherFormId && !a_weatherFormId->empty()) {
if (const auto parsed = ParseHexFormId(*a_weatherFormId)) {
return std::format("{:08x}", *parsed);
}
}
return std::nullopt;
}
bool IsAssignedPlayerOne()
{
const auto assignedPlayerId = F4T::RemotePlayerState::GetAssignedPlayerId();
return assignedPlayerId && *assignedPlayerId == 1U;
}
bool ShouldSendTimeState(float a_gameHour, float a_gameDaysPassed) bool ShouldSendTimeState(float a_gameHour, float a_gameDaysPassed)
{ {
if (!g_lastSentWorldState.initialized) { if (!g_lastSentWorldState.initialized) {
@@ -287,6 +389,78 @@ namespace
g_lastSentWorldState.initialized = false; g_lastSentWorldState.initialized = false;
g_lastHostSendTime = std::chrono::steady_clock::now(); g_lastHostSendTime = std::chrono::steady_clock::now();
} }
void ApplyServerWorldState(const F4T::WorldStateSync::PendingServerWorldState& a_state)
{
if (a_state.timeHHmm && !a_state.timeHHmm->empty()) {
const auto command = std::format("set gamehour to {}", *a_state.timeHHmm);
RE::Console::ExecuteCommand(command.c_str());
float gameHour = 0.0F;
float gameDaysPassed = 0.0F;
if (TryReadCalendarValues(gameHour, gameDaysPassed)) {
g_latestHostTime.gameHour = gameHour;
g_latestHostTime.gameDaysPassed = gameDaysPassed;
g_latestHostTime.initialized = true;
g_lastAppliedWorldState.gameHour = gameHour;
g_lastAppliedWorldState.gameDaysPassed = gameDaysPassed;
g_lastAppliedWorldState.initialized = true;
if (F4T::WorldStateSync::IsWorldStateHost()) {
g_lastSentWorldState.gameHour = gameHour;
g_lastSentWorldState.gameDaysPassed = gameDaysPassed;
g_lastSentWorldState.initialized = true;
}
}
if (ShouldLog("server_world_state_time_apply")) {
LogInfo(std::format("Applied server time command: {}", command));
}
}
const bool hasWeatherRequest =
(a_state.weatherConsoleArg && !a_state.weatherConsoleArg->empty()) ||
(a_state.weatherFormId && !a_state.weatherFormId->empty());
if (!hasWeatherRequest) {
return;
}
if (!IsAssignedPlayerOne()) {
if (ShouldLog("server_world_state_weather_skip")) {
LogInfo("Ignored server weather command on non-player-1 client.");
}
return;
}
const auto fwArg = BuildHostWeatherConsoleArg(a_state.weatherConsoleArg, a_state.weatherFormId);
if (!fwArg) {
if (ShouldLog("server_world_state_weather_lookup_failed")) {
LogWarning("Could not build server weather fw argument.");
}
return;
}
const auto command = std::format("fw {}", *fwArg);
RE::Console::ExecuteCommand(command.c_str());
if (ShouldLog("server_world_state_weather_apply")) {
LogInfo(std::format("Player 1 executed server weather command: {}", command));
}
std::optional<std::uint32_t> forcedWeatherFormId;
if (a_state.weatherFormId && !a_state.weatherFormId->empty()) {
forcedWeatherFormId = ParseHexFormId(*a_state.weatherFormId);
} else if (const auto parsed = ParseHexFormId(*fwArg)) {
forcedWeatherFormId = parsed;
}
if (F4T::WorldStateSync::IsWorldStateHost()) {
ForceHostWorldStateBroadcast(forcedWeatherFormId, false);
} else if (ShouldLog("server_world_state_weather_no_relay")) {
LogWarning(
"Player 1 applied server weather but is not world-state host; other clients may not sync.");
}
}
} }
namespace F4T::WorldStateSync namespace F4T::WorldStateSync
@@ -348,6 +522,8 @@ namespace F4T::WorldStateSync
const std::scoped_lock lock(g_pendingMutex); const std::scoped_lock lock(g_pendingMutex);
g_hasPendingWorldState = false; g_hasPendingWorldState = false;
g_pendingWorldState = {}; g_pendingWorldState = {};
g_hasPendingServerWorldState = false;
g_pendingServerWorldState = {};
} }
g_lastSentWorldState = {}; g_lastSentWorldState = {};
@@ -372,6 +548,21 @@ namespace F4T::WorldStateSync
} }
} }
void SetPendingServerWorldState(PendingServerWorldState a_state)
{
const auto* taskInterface = F4SE::GetTaskInterface();
if (!taskInterface) {
const std::scoped_lock lock(g_pendingMutex);
g_pendingServerWorldState = std::move(a_state);
g_hasPendingServerWorldState = true;
return;
}
taskInterface->AddTask([state = std::move(a_state)]() mutable {
ApplyServerWorldState(state);
});
}
void PollHostCapture() void PollHostCapture()
{ {
if (!Networking::IsConnectedToServer() || !IsWorldStateHost()) { if (!Networking::IsConnectedToServer() || !IsWorldStateHost()) {
@@ -436,7 +627,26 @@ namespace F4T::WorldStateSync
void ApplyPendingState() void ApplyPendingState()
{ {
if (!Networking::IsConnectedToServer() || IsWorldStateHost()) { if (!Networking::IsConnectedToServer()) {
return;
}
PendingServerWorldState pendingServerState{};
bool hasPendingServerState = false;
{
const std::scoped_lock lock(g_pendingMutex);
if (g_hasPendingServerWorldState) {
pendingServerState = g_pendingServerWorldState;
g_hasPendingServerWorldState = false;
hasPendingServerState = true;
}
}
if (hasPendingServerState) {
ApplyServerWorldState(pendingServerState);
}
if (IsWorldStateHost()) {
return; return;
} }
+1
View File
@@ -9,6 +9,7 @@ The first protocol should be simple and focused on player transform sync.
- Welcome packet - Welcome packet
- Transform packet - Transform packet
- World state packet (host time and weather) — see [`world-state.md`](world-state.md) - World state packet (host time and weather) — see [`world-state.md`](world-state.md)
- Server world state packet (dev GUI weather/time) — see [`server-world-state.md`](server-world-state.md)
- Disconnect packet - Disconnect packet
## Future Packet Types ## Future Packet Types
+5 -1
View File
@@ -11,6 +11,7 @@ welcome
transform transform
worldState worldState
worldStateHost worldStateHost
serverWorldState
disconnect disconnect
``` ```
@@ -23,10 +24,12 @@ Current behavior:
server, and broadcast to other connected clients. server, and broadcast to other connected clients.
- `worldStateHost` is sent in `welcome` and when the host disconnects to - `worldStateHost` is sent in `welcome` and when the host disconnects to
reassign host to the lowest remaining `playerId`. reassign host to the lowest remaining `playerId`.
- `serverWorldState` is sent by the server to all connected clients when the
dev server GUI sets weather or time.
- `disconnect` is sent by the server when a client disconnects. - `disconnect` is sent by the server when a client disconnects.
The Fallout 4 plugin sends transform packets and receives `welcome`, The Fallout 4 plugin sends transform packets and receives `welcome`,
`transform`, `worldState`, and `disconnect` packets. Received transforms are `transform`, `worldState`, `serverWorldState`, and `disconnect` packets. Received transforms are
stored as plain remote-player state. Non-host clients apply received world state stored as plain remote-player state. Non-host clients apply received world state
on the game thread. on the game thread.
@@ -39,6 +42,7 @@ welcome
transform transform
worldState worldState
worldStateHost worldStateHost
serverWorldState
disconnect disconnect
``` ```
+66
View File
@@ -0,0 +1,66 @@
# Server World State Packet
Server-authoritative weather and time control from the dev server GUI.
## Authority
- **Time** (`timeHHmm`): sent to **all** clients; each runs `set gamehour to HHmm`.
- **Weather** (`weatherConsoleArg`): sent to **all** clients, but only **player 1** runs `fw <8-digit-id>`. Player 1 should also be the world-state host so the usual `worldState` relay updates other clients.
## Packet Format
Time-only (all clients):
```json
{
"type": "serverWorldState",
"timeHHmm": "1900",
"serverTime": 1780212128.301
}
```
Weather (all clients receive; only player 1 acts):
```json
{
"type": "serverWorldState",
"weatherConsoleArg": "00148c45",
"weatherFormId": "00148C45",
"serverTime": 1780212128.301
}
```
## Field Definitions
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | string | yes | Must be `"serverWorldState"` |
| `timeHHmm` | string | no | 24-hour clock value for `set gamehour to HHmm` |
| `weatherConsoleArg` | string | no | Exact argument for `fw` on player 1 (8-digit lowercase hex) |
| `weatherFormId` | string | no | Relay form ID used in host `worldState` broadcast |
| `serverTime` | number (double) | no | Relay timestamp |
## Client Apply Rules
- **Time:** all clients apply `set gamehour to HHmm` on the game thread.
- **Weather:** only the client with assigned `playerId == 1` runs `fw <weatherConsoleArg>`.
- Weather commands are scheduled on the F4SE game thread from the network receive path.
- After player 1 runs `fw`, the world-state host immediately relays `worldState` with `weatherFormId`.
Edit `server/world_state_presets.py` so each preset uses the exact `fw` ID that works in your console (for example `0000015e`).
## Preset Weather Console Args
| Label | Default `fw` arg |
|-------|------------------|
| Clear | `00148c3c` |
| Cloudy | `00148c42` |
| Overcast | `00148c43` |
| Fog | `00148c44` |
| Rain | `00148c45` |
| Radstorm | `00148c47` |
| Glowing Sea | `001b9779` |
## Backward Compatibility
Older clients that do not handle `serverWorldState` ignore the packet safely.
+96
View File
@@ -10,6 +10,7 @@ from PySide6.QtWidgets import (
QApplication, QApplication,
QAbstractItemView, QAbstractItemView,
QGridLayout, QGridLayout,
QGroupBox,
QHBoxLayout, QHBoxLayout,
QHeaderView, QHeaderView,
QLabel, QLabel,
@@ -25,6 +26,7 @@ from PySide6.QtWidgets import (
from fake_player import FakePlayerManager from fake_player import FakePlayerManager
from server_core import FalloutTogetherServer from server_core import FalloutTogetherServer
from world_state_presets import TIME_PRESETS, WEATHER_PRESETS, format_hhmm_label
REFRESH_INTERVAL_MS = 500 REFRESH_INTERVAL_MS = 500
@@ -50,6 +52,7 @@ class DevServerWindow(QMainWindow):
) )
self.stat_labels: dict[str, QLabel] = {} self.stat_labels: dict[str, QLabel] = {}
self.world_state_labels: dict[str, QLabel] = {}
self.selected_fake_client_key: int | None = None self.selected_fake_client_key: int | None = None
self.setWindowTitle("Commonwealth Online Dev Server") self.setWindowTitle("Commonwealth Online Dev Server")
@@ -73,6 +76,7 @@ class DevServerWindow(QMainWindow):
tabs = QTabWidget() tabs = QTabWidget()
tabs.addTab(self._create_server_console_tab(), "Server Console") tabs.addTab(self._create_server_console_tab(), "Server Console")
tabs.addTab(self._create_fake_clients_tab(), "Fake Clients") tabs.addTab(self._create_fake_clients_tab(), "Fake Clients")
tabs.addTab(self._create_weather_time_tab(), "Weather / Time")
return tabs return tabs
def _create_server_console_tab(self) -> QWidget: def _create_server_console_tab(self) -> QWidget:
@@ -158,6 +162,76 @@ class DevServerWindow(QMainWindow):
layout.addStretch() layout.addStretch()
return layout return layout
def _create_weather_time_tab(self) -> QWidget:
tab = QWidget()
layout = QVBoxLayout(tab)
status_group = QGroupBox("Server World State")
status_layout = QGridLayout(status_group)
status_fields = [
("time", "Current time (HHmm)"),
("weather", "Current weather"),
]
for index, (key, label_text) in enumerate(status_fields):
label = QLabel(f"{label_text}:")
value = QLabel("Not set")
value.setWordWrap(True)
self.world_state_labels[key] = value
status_layout.addWidget(label, index, 0)
status_layout.addWidget(value, index, 1)
layout.addWidget(status_group)
weather_group = QGroupBox("Set Weather")
weather_layout = QGridLayout(weather_group)
for index, (label, fw_console_arg) in enumerate(WEATHER_PRESETS):
button = QPushButton(label)
button.setToolTip(f"Runs `fw {fw_console_arg}` on player 1.")
button.clicked.connect(
lambda _checked=False, console_arg=fw_console_arg: self._set_server_weather(console_arg)
)
weather_layout.addWidget(button, index // 3, index % 3)
layout.addWidget(weather_group)
time_group = QGroupBox("Set Time (set gamehour to HHmm)")
time_layout = QGridLayout(time_group)
for index, (label, hhmm) in enumerate(TIME_PRESETS):
display = f"{label} ({format_hhmm_label(hhmm)})"
button = QPushButton(display)
button.setToolTip(f"Runs `set gamehour to {hhmm}` on all connected clients.")
button.clicked.connect(lambda _checked=False, value=hhmm: self._set_server_time(value))
time_layout.addWidget(button, index // 3, index % 3)
layout.addWidget(time_group)
hint = QLabel(
"Time is sent to all clients. Weather runs `fw <8-digit-id>` on player 1 only, "
"then player 1 relays weather to other clients. Edit preset IDs in "
"server/world_state_presets.py to match what works in your console."
)
hint.setWordWrap(True)
layout.addWidget(hint)
layout.addStretch()
return tab
def _set_server_weather(self, fw_console_arg: str) -> None:
if not self.server.is_running():
self._append_log("Start the server before setting weather.")
return
if self.server.set_server_weather(fw_console_arg):
self._append_log(f"Requested server weather command: fw {fw_console_arg}")
self._refresh_server_state()
def _set_server_time(self, hhmm: str) -> None:
if not self.server.is_running():
self._append_log("Start the server before setting time.")
return
if self.server.set_server_time(hhmm):
self._append_log(f"Requested server time: {hhmm}")
self._refresh_server_state()
def _create_fake_clients_tab(self) -> QWidget: def _create_fake_clients_tab(self) -> QWidget:
tab = QWidget() tab = QWidget()
layout = QVBoxLayout(tab) layout = QVBoxLayout(tab)
@@ -442,6 +516,28 @@ class DevServerWindow(QMainWindow):
self._refresh_clients_table(clients) self._refresh_clients_table(clients)
self._refresh_fake_clients_table() self._refresh_fake_clients_table()
self._refresh_world_state_labels()
def _refresh_world_state_labels(self) -> None:
world_state = self.server.get_server_world_state()
time_label = self.world_state_labels.get("time")
weather_label = self.world_state_labels.get("weather")
if time_label is not None:
hhmm = world_state.get("timeHHmm")
if hhmm:
time_label.setText(f"{hhmm} ({format_hhmm_label(hhmm)})")
else:
time_label.setText("Not set")
if weather_label is not None:
console_arg = world_state.get("weatherConsoleArg")
form_id = world_state.get("weatherFormId")
if console_arg:
suffix = f" (relay 0x{form_id})" if form_id else ""
weather_label.setText(f"fw {console_arg}{suffix}")
else:
weather_label.setText("Not set")
def _refresh_clients_table(self, clients: list[dict[str, Any]]) -> None: def _refresh_clients_table(self, clients: list[dict[str, Any]]) -> None:
self.clients_table.setRowCount(len(clients)) self.clients_table.setRowCount(len(clients))
+2
View File
@@ -260,6 +260,8 @@ def handle_packet(packet: dict[str, Any]) -> None:
elif packet_type == "worldState": elif packet_type == "worldState":
log(f"Received worldState: {packet}") log(f"Received worldState: {packet}")
update_host_world_state(packet) update_host_world_state(packet)
elif packet_type == "serverWorldState":
log(f"Received serverWorldState: {packet}")
elif packet_type == "worldStateHost": elif packet_type == "worldStateHost":
log(f"Received worldStateHost: {packet}") log(f"Received worldStateHost: {packet}")
update_world_state_host(packet) update_world_state_host(packet)
+107
View File
@@ -9,6 +9,10 @@ from typing import Any
from client_session import ClientSession from client_session import ClientSession
from lan_discovery import DISCOVERY_PORT, LanDiscoveryResponder from lan_discovery import DISCOVERY_PORT, LanDiscoveryResponder
from world_state_presets import (
normalize_fw_console_arg,
relay_weather_form_id,
)
HOST = "0.0.0.0" HOST = "0.0.0.0"
@@ -42,6 +46,7 @@ class FalloutTogetherServer:
self._started_at: float | None = None self._started_at: float | None = None
self._next_player_id = 1 self._next_player_id = 1
self._world_state_host_player_id: int | None = None self._world_state_host_player_id: int | None = None
self._server_world_state: dict[str, str] = {}
self._stats: dict[str, int] = { self._stats: dict[str, int] = {
"clientsConnected": 0, "clientsConnected": 0,
@@ -54,6 +59,7 @@ class FalloutTogetherServer:
"worldStatePacketsReceived": 0, "worldStatePacketsReceived": 0,
"worldStatePacketsBroadcast": 0, "worldStatePacketsBroadcast": 0,
"worldStateHostPacketsBroadcast": 0, "worldStateHostPacketsBroadcast": 0,
"serverWorldStatePacketsBroadcast": 0,
"disconnectPacketsBroadcast": 0, "disconnectPacketsBroadcast": 0,
} }
@@ -137,6 +143,43 @@ class FalloutTogetherServer:
return stats return stats
def get_server_world_state(self) -> dict[str, str]:
with self._lock:
return dict(self._server_world_state)
def set_server_time(self, hhmm: str) -> bool:
text = str(hhmm).strip()
if not text.isdigit() or len(text) > 4:
self._log(f"Rejected invalid server time HHmm value: {hhmm!r}")
return False
normalized = text.zfill(4)
hours = int(normalized[:2])
minutes = int(normalized[2:])
if hours > 23 or minutes > 59:
self._log(f"Rejected out-of-range server time HHmm value: {hhmm!r}")
return False
with self._lock:
self._server_world_state["timeHHmm"] = normalized
self._broadcast_server_world_state()
return True
def set_server_weather(self, fw_console_arg: str) -> bool:
try:
normalized_fw_arg = normalize_fw_console_arg(fw_console_arg)
except ValueError:
self._log(f"Rejected invalid server weather console id: {fw_console_arg!r}")
return False
with self._lock:
self._server_world_state["weatherConsoleArg"] = normalized_fw_arg
self._server_world_state["weatherFormId"] = relay_weather_form_id(normalized_fw_arg)
self._broadcast_server_world_state()
return True
def add_log_listener(self, callback: Callable[[str], None]) -> None: def add_log_listener(self, callback: Callable[[str], None]) -> None:
with self._log_lock: with self._log_lock:
if callback not in self._log_listeners: if callback not in self._log_listeners:
@@ -405,6 +448,70 @@ class FalloutTogetherServer:
f"gameHour={game_hour}, gameDaysPassed={game_days_passed}{weather_detail}" f"gameHour={game_hour}, gameDaysPassed={game_days_passed}{weather_detail}"
) )
def _broadcast_server_world_state(self) -> None:
with self._lock:
if not self._running:
self._log("Cannot broadcast server world state while the server is stopped.")
return
recipients = list(self._clients.values())
snapshot = dict(self._server_world_state)
time_hhmm = snapshot.get("timeHHmm")
weather_console_arg = snapshot.get("weatherConsoleArg")
weather_form_id = snapshot.get("weatherFormId")
if not time_hhmm and not weather_console_arg:
self._log("Server world state broadcast skipped: no time or weather set.")
return
server_time = time.time()
failed_recipients: list[ClientSession] = []
successful_sends = 0
if time_hhmm:
time_packet: dict[str, Any] = {
"type": "serverWorldState",
"timeHHmm": time_hhmm,
"serverTime": server_time,
}
for recipient in recipients:
try:
self._send_packet(recipient, time_packet, broadcast=True)
successful_sends += 1
except OSError:
failed_recipients.append(recipient)
self._log(
f"Broadcast serverWorldState time to {len(recipients)} client(s): timeHHmm={time_hhmm}"
)
if weather_console_arg:
weather_packet: dict[str, Any] = {
"type": "serverWorldState",
"weatherConsoleArg": weather_console_arg,
"serverTime": server_time,
}
if weather_form_id:
weather_packet["weatherFormId"] = weather_form_id
for recipient in recipients:
try:
self._send_packet(recipient, weather_packet, broadcast=True)
successful_sends += 1
except OSError:
failed_recipients.append(recipient)
self._log(
"Broadcast serverWorldState weather command to "
f"{len(recipients)} client(s): fw {weather_console_arg}"
)
with self._lock:
self._stats["serverWorldStatePacketsBroadcast"] += successful_sends
for recipient in failed_recipients:
self._disconnect_client(recipient)
def _reassign_world_state_host(self) -> None: def _reassign_world_state_host(self) -> None:
with self._lock: with self._lock:
if not self._clients: if not self._clients:
+66
View File
@@ -0,0 +1,66 @@
"""Preset weather and time values for the dev server Weather/Time tab."""
from __future__ import annotations
# Each preset is (label, fw_console_arg).
# `fw_console_arg` is passed directly to the in-game `fw` command on player 1
# (8-digit hex form ID, not an editor name like CommonwealthRain).
WEATHER_PRESETS: list[tuple[str, str]] = [
("Clear", "00148c3c"),
("Cloudy", "00148c42"),
("Overcast", "00148c43"),
("Fog", "00148c44"),
("Rain", "00148c45"),
("Radstorm", "00148c47"),
("Glowing Sea", "001b9779"),
]
# HHmm values for the in-game `set gamehour to HHmm` console command.
TIME_PRESETS: list[tuple[str, str]] = [
("Midnight", "0000"),
("Dawn", "0600"),
("Morning", "0900"),
("Noon", "1200"),
("Afternoon", "1500"),
("Evening", "1800"),
("Dusk (7 PM)", "1900"),
("Night", "2200"),
]
def normalize_fw_console_arg(value: str) -> str:
text = str(value).strip().lower()
if text.startswith("0x"):
text = text[2:]
return f"{int(text, 16):08x}"
def relay_weather_form_id(fw_console_arg: str) -> str:
return f"{int(fw_console_arg, 16):08X}"
def hhmm_to_game_hour(hhmm: str) -> float | None:
text = str(hhmm).strip()
if not text.isdigit() or len(text) > 4:
return None
padded = text.zfill(4)
hours = int(padded[:2])
minutes = int(padded[2:])
if hours > 23 or minutes > 59:
return None
return hours + (minutes / 60.0)
def format_hhmm_label(hhmm: str) -> str:
text = str(hhmm).strip().zfill(4)
hours = int(text[:2])
minutes = int(text[2:])
suffix = "AM" if hours < 12 else "PM"
display_hour = hours % 12
if display_hour == 0:
display_hour = 12
if minutes:
return f"{display_hour}:{minutes:02d} {suffix}"
return f"{display_hour} {suffix}"