Add plugin receive loop & remote player state

Add a networking receive loop and in-plugin remote-player storage. Introduces F4TRemotePlayerState (header + implementation) to track assigned playerId and remote player snapshots with thread-safe access. Expands F4TNetworking to start/stop a background receive thread, parse newline-separated JSON packets (welcome, transform, disconnect), validate fields, update remote state, throttle logs, and handle socket/thread synchronization. Add nlohmann_json to xmake and plugin build. Update docs and dev log to reflect the new receive behavior and current prototype scope.
This commit is contained in:
2026-05-31 20:10:41 +12:00
parent d724d7404c
commit 62635f4226
10 changed files with 581 additions and 70 deletions
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include <chrono>
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
namespace F4T::RemotePlayerState
{
struct RemotePlayerState
{
std::uint32_t playerId{};
float x{};
float y{};
float z{};
float angleZ{};
std::string movementType;
std::string cellId;
std::string worldspaceId;
std::optional<double> clientTime;
std::optional<double> serverTime;
std::chrono::steady_clock::time_point lastReceivedLocalTime{};
};
void SetAssignedPlayerId(std::uint32_t a_playerId);
std::optional<std::uint32_t> GetAssignedPlayerId();
bool IsAssignedPlayerId(std::uint32_t a_playerId);
void ClearAssignedPlayerId();
void UpdateRemotePlayer(RemotePlayerState a_state);
bool RemoveRemotePlayer(std::uint32_t a_playerId);
void ClearRemotePlayers();
std::vector<RemotePlayerState> GetRemotePlayerSnapshot();
}
+13 -7
View File
@@ -21,7 +21,8 @@ Expected Install Path: Data/F4SE/Plugins/Fallout4Together.dll
## Current Goal
The current goal is to build an empty plugin and confirm it loads through F4SE.
The current goal is to build the F4SE plugin, confirm it loads through F4SE, and
verify the local networking prototype against `server/server.py`.
This means the plugin should be able to:
@@ -30,16 +31,17 @@ This means the plugin should be able to:
* Be copied into `Data/F4SE/Plugins/`
* Load when Fallout 4 is launched through F4SE
* Create or write to a `Fallout4Together.log` file
* Connect to the local server on `127.0.0.1:7777`
* Send local transform packets
* Receive `welcome`, `transform`, and `disconnect` packets
* Store remote player state internally without spawning actors
## Not Included Yet
The plugin does not currently include:
* Networking
* Actor sync
* Player transform sync
* Creation Kit test cell integration
* Server connection
* Remote player spawning
* Combat sync
* Quest sync
@@ -103,9 +105,13 @@ Use this checklist for the first plugin test:
* [ ] Game reaches the main menu without crashing
* [ ] Plugin log file is created
* [ ] Plugin log confirms that the plugin loaded
* [ ] Local server is running before gameplay networking tests
* [ ] Plugin log shows `Assigned server playerId: N`
* [ ] Plugin logs remote player updates from another connected client
* [ ] Plugin logs remote player removal after a disconnect
## Notes
Do not start multiplayer networking work until the empty plugin can build and load consistently.
The first real technical milestone is not player sync. The first milestone is simply proving that Fallout 4 can load `Fallout4Together.dll` through F4SE.
Remote actor spawning is intentionally not part of the current networking
prototype. The receive loop stores plain remote-player state first so later
actor work can be added from a stable data source.
+369 -6
View File
@@ -1,15 +1,26 @@
#include "pch.h"
#include "F4TNetworking.h"
#include "F4TRemotePlayerState.h"
#include <WinSock2.h>
#include <WS2tcpip.h>
#include <array>
#include <atomic>
#include <charconv>
#include <chrono>
#include <cstddef>
#include <cstdio>
#include <limits>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <thread>
#include <unordered_map>
#include <nlohmann/json.hpp>
namespace
{
@@ -17,19 +28,37 @@ namespace
constexpr auto kLocalServerPort = 7777;
constexpr auto kReconnectCooldown = 10s;
constexpr auto kConnectTimeoutMilliseconds = 100;
constexpr auto kReceiveIdleSleep = 25ms;
constexpr auto kRemoteUpdateLogInterval = 1s;
constexpr auto kWarningLogInterval = 2s;
constexpr auto kMaximumReceiveBufferSize = 64uz * 1024uz;
SOCKET g_socket = INVALID_SOCKET;
std::mutex g_socketMutex;
bool g_winsockInitialized = false;
auto g_lastConnectAttempt = std::chrono::steady_clock::time_point{};
std::atomic_bool g_receiveThreadRunning{ false };
std::thread g_receiveThread;
std::unordered_map<std::uint32_t, std::chrono::steady_clock::time_point> g_lastRemoteUpdateLogTimes;
std::unordered_map<std::string, std::chrono::steady_clock::time_point> g_lastWarningLogTimes;
using Json = nlohmann::json;
void CloseSocket()
{
const std::scoped_lock lock(g_socketMutex);
if (g_socket != INVALID_SOCKET) {
closesocket(g_socket);
g_socket = INVALID_SOCKET;
}
}
SOCKET GetSocket()
{
const std::scoped_lock lock(g_socketMutex);
return g_socket;
}
bool EnsureWinsockInitialized()
{
if (g_winsockInitialized) {
@@ -92,6 +121,321 @@ namespace
g_lastConnectAttempt = now;
return true;
}
bool ShouldLogWarning(std::string a_key)
{
const auto now = std::chrono::steady_clock::now();
const auto lastLog = g_lastWarningLogTimes.find(a_key);
if (lastLog != g_lastWarningLogTimes.end() && now - lastLog->second < kWarningLogInterval) {
return false;
}
g_lastWarningLogTimes[std::move(a_key)] = now;
return true;
}
void LogThrottledWarning(const std::string& a_key, const char* a_message)
{
if (ShouldLogWarning(a_key)) {
REX::WARN("{}", a_message);
}
}
void TrimPacketLine(std::string& a_line)
{
while (!a_line.empty() && (a_line.back() == '\r' || a_line.back() == ' ' || a_line.back() == '\t')) {
a_line.pop_back();
}
}
std::optional<std::uint32_t> ReadPlayerId(const Json& a_packet)
{
const auto playerId = a_packet.find("playerId");
if (playerId == a_packet.end()) {
return std::nullopt;
}
try {
if (playerId->is_number_integer()) {
const auto value = playerId->get<std::int64_t>();
if (value >= 0 && value <= (std::numeric_limits<std::uint32_t>::max)()) {
return static_cast<std::uint32_t>(value);
}
}
if (playerId->is_string()) {
const auto text = playerId->get<std::string>();
std::uint32_t value = 0;
const auto* begin = text.data();
const auto* end = begin + text.size();
const auto result = std::from_chars(begin, end, value);
if (result.ec == std::errc{} && result.ptr == end) {
return value;
}
}
} catch (const std::exception&) {
return std::nullopt;
}
return std::nullopt;
}
std::optional<double> ReadDouble(const Json& a_packet, const char* a_fieldName)
{
const auto field = a_packet.find(a_fieldName);
if (field == a_packet.end()) {
return std::nullopt;
}
try {
if (field->is_number()) {
return field->get<double>();
}
if (field->is_string()) {
const auto text = field->get<std::string>();
std::size_t parsedCharacters = 0;
const auto value = std::stod(text, std::addressof(parsedCharacters));
if (parsedCharacters == text.size()) {
return value;
}
}
} catch (const std::exception&) {
return std::nullopt;
}
return std::nullopt;
}
std::optional<std::string> ReadString(const Json& a_packet, const char* a_fieldName)
{
const auto field = a_packet.find(a_fieldName);
if (field == a_packet.end() || !field->is_string()) {
return std::nullopt;
}
return field->get<std::string>();
}
bool ShouldLogRemoteUpdate(std::uint32_t a_playerId)
{
const auto now = std::chrono::steady_clock::now();
const auto lastLog = g_lastRemoteUpdateLogTimes.find(a_playerId);
if (lastLog != g_lastRemoteUpdateLogTimes.end() && now - lastLog->second < kRemoteUpdateLogInterval) {
return false;
}
g_lastRemoteUpdateLogTimes[a_playerId] = now;
return true;
}
void HandleWelcomePacket(const Json& a_packet)
{
const auto playerId = ReadPlayerId(a_packet);
if (!playerId) {
LogThrottledWarning("welcome_playerId", "Ignoring welcome packet without a valid playerId.");
return;
}
F4T::RemotePlayerState::SetAssignedPlayerId(*playerId);
REX::INFO("Assigned server playerId: {}", *playerId);
}
void HandleTransformPacket(const Json& a_packet)
{
const auto playerId = ReadPlayerId(a_packet);
const auto x = ReadDouble(a_packet, "x");
const auto y = ReadDouble(a_packet, "y");
const auto z = ReadDouble(a_packet, "z");
const auto angleZ = ReadDouble(a_packet, "angleZ");
const auto movementType = ReadString(a_packet, "movementType");
const auto cellId = ReadString(a_packet, "cellId");
const auto worldspaceId = ReadString(a_packet, "worldspaceId");
if (!playerId || !x || !y || !z || !angleZ || !movementType || !cellId || !worldspaceId) {
LogThrottledWarning("transform_invalid", "Ignoring transform packet with missing or invalid required fields.");
return;
}
if (F4T::RemotePlayerState::IsAssignedPlayerId(*playerId)) {
return;
}
F4T::RemotePlayerState::RemotePlayerState remoteState{
*playerId,
static_cast<float>(*x),
static_cast<float>(*y),
static_cast<float>(*z),
static_cast<float>(*angleZ),
*movementType,
*cellId,
*worldspaceId,
ReadDouble(a_packet, "clientTime"),
ReadDouble(a_packet, "serverTime"),
std::chrono::steady_clock::now()
};
F4T::RemotePlayerState::UpdateRemotePlayer(remoteState);
// TODO: A later milestone can read this plain state from the game thread and
// spawn or move remote actors. The networking thread must not touch actors.
if (ShouldLogRemoteUpdate(*playerId)) {
REX::INFO(
"Remote player {} updated: X={:.2f}, Y={:.2f}, Z={:.2f}, AngleZ={:.2f}, Cell={}, Worldspace={}",
remoteState.playerId,
remoteState.x,
remoteState.y,
remoteState.z,
remoteState.angleZ,
remoteState.cellId,
remoteState.worldspaceId);
}
}
void HandleDisconnectPacket(const Json& a_packet)
{
const auto playerId = ReadPlayerId(a_packet);
if (!playerId) {
LogThrottledWarning("disconnect_playerId", "Ignoring disconnect packet without a valid playerId.");
return;
}
g_lastRemoteUpdateLogTimes.erase(*playerId);
if (F4T::RemotePlayerState::RemoveRemotePlayer(*playerId)) {
REX::INFO("Remote player {} disconnected and was removed.", *playerId);
} else {
REX::INFO("Received disconnect for unknown remote player {}.", *playerId);
}
}
void HandleUnknownPacket(const Json& a_packet)
{
const auto type = ReadString(a_packet, "type").value_or("<missing>");
if (ShouldLogWarning("unknown_" + type)) {
REX::WARN("Ignoring unknown server packet type: {}", type);
}
}
void HandlePacketLine(const std::string& a_line)
{
if (a_line.empty()) {
return;
}
Json packet;
try {
packet = Json::parse(a_line);
} catch (const std::exception& a_error) {
if (ShouldLogWarning("json_parse")) {
REX::WARN("Ignoring invalid JSON packet from server: {}", a_error.what());
}
return;
}
if (!packet.is_object()) {
LogThrottledWarning("json_object", "Ignoring server packet that was not a JSON object.");
return;
}
const auto packetType = ReadString(packet, "type");
if (!packetType) {
LogThrottledWarning("packet_type", "Ignoring server packet without a string type field.");
return;
}
if (*packetType == "welcome") {
HandleWelcomePacket(packet);
} else if (*packetType == "transform") {
HandleTransformPacket(packet);
} else if (*packetType == "disconnect") {
HandleDisconnectPacket(packet);
} else {
HandleUnknownPacket(packet);
}
}
void ProcessReceiveBuffer(std::string& a_receiveBuffer)
{
std::size_t newlinePosition = 0;
while ((newlinePosition = a_receiveBuffer.find('\n')) != std::string::npos) {
auto line = a_receiveBuffer.substr(0, newlinePosition);
a_receiveBuffer.erase(0, newlinePosition + 1);
TrimPacketLine(line);
HandlePacketLine(line);
}
if (a_receiveBuffer.size() > kMaximumReceiveBufferSize) {
a_receiveBuffer.clear();
LogThrottledWarning("receive_buffer", "Cleared oversized server receive buffer.");
}
}
void ReceiveLoop()
{
std::array<char, 4096> receiveChunk{};
std::string receiveBuffer;
while (g_receiveThreadRunning.load()) {
int bytesReceived = 0;
{
const std::scoped_lock lock(g_socketMutex);
if (g_socket == INVALID_SOCKET) {
break;
}
bytesReceived = recv(g_socket, receiveChunk.data(), static_cast<int>(receiveChunk.size()), 0);
}
if (bytesReceived > 0) {
receiveBuffer.append(receiveChunk.data(), static_cast<std::size_t>(bytesReceived));
ProcessReceiveBuffer(receiveBuffer);
continue;
}
if (bytesReceived == 0) {
REX::WARN("Fallout 4 Together local server closed the connection.");
CloseSocket();
break;
}
const auto error = WSAGetLastError();
if (error == WSAEWOULDBLOCK) {
std::this_thread::sleep_for(kReceiveIdleSleep);
continue;
}
if (g_receiveThreadRunning.load()) {
REX::WARN("Lost connection to Fallout 4 Together local server while receiving.");
}
CloseSocket();
break;
}
g_receiveThreadRunning.store(false);
}
void StopReceiveThread()
{
g_receiveThreadRunning.store(false);
CloseSocket();
if (g_receiveThread.joinable() && g_receiveThread.get_id() != std::this_thread::get_id()) {
g_receiveThread.join();
}
}
void StartReceiveThread()
{
if (g_receiveThread.joinable()) {
g_receiveThread.join();
}
// Receiving server packets is deliberately isolated from Fallout 4 objects.
// This thread only parses network data and updates plain remote-player state.
g_receiveThreadRunning.store(true);
g_receiveThread = std::thread(ReceiveLoop);
}
}
namespace F4T::Networking
@@ -146,16 +490,26 @@ namespace F4T::Networking
return false;
}
g_socket = localSocket;
{
const std::scoped_lock lock(g_socketMutex);
g_socket = localSocket;
}
F4T::RemotePlayerState::ClearAssignedPlayerId();
F4T::RemotePlayerState::ClearRemotePlayers();
g_lastRemoteUpdateLogTimes.clear();
StartReceiveThread();
REX::INFO("Connected to Fallout 4 Together local server.");
// TODO: Read and log the server welcome packet once the plugin has a small
// non-blocking receive path. For now the server owns player IDs entirely.
return true;
}
void DisconnectFromLocalServer()
{
CloseSocket();
StopReceiveThread();
F4T::RemotePlayerState::ClearAssignedPlayerId();
F4T::RemotePlayerState::ClearRemotePlayers();
g_lastRemoteUpdateLogTimes.clear();
if (g_winsockInitialized) {
WSACleanup();
@@ -165,7 +519,7 @@ namespace F4T::Networking
bool IsConnectedToServer()
{
return g_socket != INVALID_SOCKET;
return GetSocket() != INVALID_SOCKET;
}
bool SendTransformPacket(
@@ -244,7 +598,16 @@ namespace F4T::Networking
packetSize += appended;
const auto bytesSent = send(g_socket, packet.data(), packetSize, 0);
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) {
+73
View File
@@ -0,0 +1,73 @@
#include "pch.h"
#include "F4TRemotePlayerState.h"
#include <mutex>
#include <optional>
#include <unordered_map>
#include <vector>
namespace
{
std::mutex g_stateMutex;
std::optional<std::uint32_t> g_assignedPlayerId;
std::unordered_map<std::uint32_t, F4T::RemotePlayerState::RemotePlayerState> g_remotePlayers;
}
namespace F4T::RemotePlayerState
{
void SetAssignedPlayerId(std::uint32_t a_playerId)
{
const std::scoped_lock lock(g_stateMutex);
g_assignedPlayerId = a_playerId;
}
std::optional<std::uint32_t> GetAssignedPlayerId()
{
const std::scoped_lock lock(g_stateMutex);
return g_assignedPlayerId;
}
bool IsAssignedPlayerId(std::uint32_t a_playerId)
{
const std::scoped_lock lock(g_stateMutex);
return g_assignedPlayerId.has_value() && *g_assignedPlayerId == a_playerId;
}
void ClearAssignedPlayerId()
{
const std::scoped_lock lock(g_stateMutex);
g_assignedPlayerId.reset();
}
void UpdateRemotePlayer(RemotePlayerState a_state)
{
const std::scoped_lock lock(g_stateMutex);
g_remotePlayers[a_state.playerId] = std::move(a_state);
}
bool RemoveRemotePlayer(std::uint32_t a_playerId)
{
const std::scoped_lock lock(g_stateMutex);
return g_remotePlayers.erase(a_playerId) > 0;
}
void ClearRemotePlayers()
{
const std::scoped_lock lock(g_stateMutex);
g_remotePlayers.clear();
}
std::vector<RemotePlayerState> GetRemotePlayerSnapshot()
{
const std::scoped_lock lock(g_stateMutex);
std::vector<RemotePlayerState> snapshot;
snapshot.reserve(g_remotePlayers.size());
for (const auto& [_, player] : g_remotePlayers) {
snapshot.push_back(player);
}
return snapshot;
}
}
+2
View File
@@ -11,6 +11,7 @@ set_warnings("allextra")
-- add common rules
add_rules("mode.debug", "mode.releasedbg")
add_rules("plugin.vsxmake.autoupdate")
add_requires("nlohmann_json")
-- define targets
target("Fallout4Together")
@@ -26,5 +27,6 @@ target("Fallout4Together")
add_headerfiles("include/**.h")
add_includedirs("src")
add_includedirs("include")
add_packages("nlohmann_json")
add_syslinks("ws2_32")
set_pcxxheader("src/pch.h")