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:
+19
-20
@@ -3,27 +3,27 @@
|
||||
## Current Architecture
|
||||
|
||||
```text
|
||||
Fallout 4 Plugin
|
||||
Fallout 4 Plugin ↔ Local Python Server ↔ Other Clients
|
||||
↓
|
||||
Local Python Server
|
||||
↓
|
||||
Fake Client Remote Player State
|
||||
Plugin Remote Player State
|
||||
```
|
||||
|
||||
The current system is one-way from the Fallout 4 plugin to the fake client:
|
||||
The current system is still a local prototype, but the Fallout 4 plugin now has
|
||||
both send and receive paths:
|
||||
|
||||
1. The Fallout 4 plugin reads local player transform data.
|
||||
2. The plugin sends transform packets to the local Python server on
|
||||
`127.0.0.1:7777`.
|
||||
3. The server assigns `playerId` values and sends `welcome` packets.
|
||||
4. The server adds `playerId` and `serverTime` to transform packets.
|
||||
5. The server broadcasts transform packets to other connected clients.
|
||||
6. `server/fake_client.py` stores remote player state by `playerId`.
|
||||
7. The server broadcasts `disconnect` packets when clients disconnect.
|
||||
8. The fake client removes disconnected players from its remote player table.
|
||||
4. The plugin receives its `welcome` packet and stores its assigned `playerId`.
|
||||
5. The server adds `playerId` and `serverTime` to transform packets.
|
||||
6. The server broadcasts transform packets to other connected clients.
|
||||
7. The plugin and `server/fake_client.py` store remote player state by `playerId`.
|
||||
8. The server broadcasts `disconnect` packets when clients disconnect.
|
||||
9. The plugin and fake client remove disconnected players from their remote
|
||||
player tables.
|
||||
|
||||
The Fallout 4 plugin does not yet receive remote transforms from the server.
|
||||
There is no plugin receive loop and no remote actor spawning yet.
|
||||
The Fallout 4 plugin still does not spawn or move remote actors.
|
||||
|
||||
## Main Components
|
||||
|
||||
@@ -34,11 +34,11 @@ The native plugin is responsible for:
|
||||
- Loading into Fallout 4
|
||||
- Reading local player state
|
||||
- Sending local player data to the server
|
||||
- Receiving server packets on a background thread
|
||||
- Storing assigned and remote `playerId` state internally
|
||||
|
||||
Planned later:
|
||||
|
||||
- Receiving remote player data
|
||||
- Storing remote player state inside the plugin
|
||||
- Applying remote player updates safely
|
||||
- Spawning or moving remote actors
|
||||
|
||||
@@ -79,24 +79,23 @@ The Creation Kit side is planned later and may be responsible for:
|
||||
|
||||
## Threading Model
|
||||
|
||||
This is planned for the Fallout 4 receiving client. It is not implemented yet.
|
||||
|
||||
Networking should run separately from game update logic.
|
||||
|
||||
Recommended approach:
|
||||
Current approach:
|
||||
|
||||
```text
|
||||
Networking thread receives packets
|
||||
Networking thread stores remote player state
|
||||
Game update reads remote player state
|
||||
Game update applies actor movement
|
||||
Future game update applies actor movement
|
||||
```
|
||||
|
||||
The networking thread should avoid directly modifying Fallout 4 game objects unless the operation is known to be safe.
|
||||
The networking receive thread does not directly modify Fallout 4 actors or game
|
||||
objects. It parses newline-separated JSON, updates plain C++ remote-player
|
||||
state, and leaves actor spawning or movement for a later game-thread milestone.
|
||||
|
||||
## Out Of Scope For Current Prototype
|
||||
|
||||
- Fallout 4 receive-loop processing
|
||||
- Remote actor spawning
|
||||
- Combat sync
|
||||
- Quest sync
|
||||
|
||||
@@ -483,6 +483,39 @@ Player position: X=2048.00, Y=2048.00, Z=0.00, AngleZ=0.00
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-31 - Plugin Receive State Implemented
|
||||
|
||||
### What Changed
|
||||
|
||||
- Added plugin-side receiving for server `welcome`, `transform`, and `disconnect` packets.
|
||||
- Added internal remote player state storage keyed by server-assigned `playerId`.
|
||||
- Added defensive packet parsing so malformed or unknown packets are ignored without crashing the plugin.
|
||||
- Kept remote actor spawning and movement out of scope.
|
||||
|
||||
### What Worked
|
||||
|
||||
- The plugin now stores its assigned server `playerId`.
|
||||
- Remote transforms can be stored internally without touching Fallout 4 actors or game objects.
|
||||
- Disconnect packets remove remote player state.
|
||||
- `xmake build` succeeds.
|
||||
|
||||
### What Broke
|
||||
|
||||
- Nothing recorded.
|
||||
|
||||
### Notes
|
||||
|
||||
- The receive loop runs on a background networking thread.
|
||||
- Remote transform logging is throttled so `Fallout4Together.log` remains readable.
|
||||
- Actor spawning, actor movement, animation sync, combat sync, inventory sync, quest sync, and settlement sync remain planned later.
|
||||
|
||||
### Next Steps
|
||||
|
||||
- Test with Fallout 4 launched through F4SE against `server/server.py`.
|
||||
- Add a controlled game-thread reader for remote player snapshots before any actor spawning work.
|
||||
|
||||
---
|
||||
|
||||
## Entry Template
|
||||
|
||||
Use this format for future updates:
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
{
|
||||
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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -20,12 +20,13 @@ Current behavior:
|
||||
broadcast to other connected clients.
|
||||
- `disconnect` is sent by the server when a client disconnects.
|
||||
|
||||
The Fallout 4 plugin currently sends transform packets only. It does not yet
|
||||
receive or process server packets.
|
||||
The Fallout 4 plugin sends transform packets and receives `welcome`,
|
||||
`transform`, and `disconnect` packets. Received transforms are stored as plain
|
||||
remote-player state only.
|
||||
|
||||
## Working In Fake Client Only
|
||||
## Current Receivers
|
||||
|
||||
`server/fake_client.py` currently receives:
|
||||
The Fallout 4 plugin and `server/fake_client.py` currently receive:
|
||||
|
||||
```text
|
||||
welcome
|
||||
@@ -33,8 +34,8 @@ transform
|
||||
disconnect
|
||||
```
|
||||
|
||||
The fake client stores broadcast transform packets in an in-memory remote player
|
||||
state table and removes entries when disconnect packets arrive.
|
||||
The plugin and fake client store broadcast transform packets in in-memory remote
|
||||
player state tables and remove entries when disconnect packets arrive.
|
||||
|
||||
## Planned Later
|
||||
|
||||
|
||||
+16
-12
@@ -40,8 +40,8 @@ playerId Server-assigned player ID for this connection.
|
||||
serverTime Server timestamp when the welcome packet was created.
|
||||
```
|
||||
|
||||
The Fallout 4 plugin does not yet read this packet. `server/fake_client.py`
|
||||
currently receives and prints it.
|
||||
The Fallout 4 plugin and `server/fake_client.py` receive this packet. The plugin
|
||||
stores the assigned `playerId` for filtering its own future transform echoes.
|
||||
|
||||
### Transform Packet
|
||||
|
||||
@@ -117,20 +117,24 @@ playerId Server-assigned player ID that disconnected.
|
||||
serverTime Server timestamp when the disconnect packet was created.
|
||||
```
|
||||
|
||||
`server/fake_client.py` currently uses this packet to remove remote players from
|
||||
its in-memory state table.
|
||||
The Fallout 4 plugin and `server/fake_client.py` use this packet to remove
|
||||
remote players from their in-memory state tables.
|
||||
|
||||
## Working In Fake Client Only
|
||||
## Receiver Behavior
|
||||
|
||||
- Receiving `welcome` packets.
|
||||
- Receiving broadcast `transform` packets from other clients.
|
||||
- Storing remote player state by `playerId`.
|
||||
- Removing remote player state when `disconnect` packets arrive.
|
||||
The Fallout 4 plugin and fake client both receive:
|
||||
|
||||
```text
|
||||
welcome
|
||||
transform
|
||||
disconnect
|
||||
```
|
||||
|
||||
The plugin receive loop runs on a background networking thread. It stores plain
|
||||
remote-player state only and does not spawn actors or touch Fallout 4 game
|
||||
objects.
|
||||
|
||||
## Planned Later
|
||||
|
||||
- Fallout 4 plugin receive loop.
|
||||
- Plugin-side parsing of `welcome`, `transform`, and `disconnect` packets.
|
||||
- Plugin-side remote player state storage.
|
||||
- Remote actor spawning.
|
||||
- Gameplay synchronization.
|
||||
|
||||
+14
-19
@@ -6,23 +6,24 @@ The current implementation is still a local networking prototype.
|
||||
## Current Implemented Flow
|
||||
|
||||
```text
|
||||
Fallout 4 Plugin
|
||||
Fallout 4 Plugin ↔ Local Python Server ↔ Other Clients
|
||||
↓
|
||||
Local Python Server
|
||||
↓
|
||||
Fake Client Remote Player State
|
||||
Plugin Remote Player State
|
||||
```
|
||||
|
||||
The Fallout 4 plugin currently reads the local player's transform and sends it
|
||||
to the local Python server. The server assigns a `playerId`, adds `serverTime`,
|
||||
and broadcasts transform packets to other connected clients.
|
||||
|
||||
The Fallout 4 plugin does not yet receive remote transforms from the server.
|
||||
The current receiver is `server/fake_client.py`.
|
||||
The Fallout 4 plugin also receives server packets on a background networking
|
||||
thread. It stores its assigned `playerId`, stores remote transform state by
|
||||
remote `playerId`, and removes remote state when disconnect packets arrive.
|
||||
`server/fake_client.py` remains a lightweight test receiver for the same packet
|
||||
lifecycle.
|
||||
|
||||
## Current Remote Player State Model
|
||||
|
||||
The fake client stores remote players by `playerId`.
|
||||
The plugin and fake client store remote players by `playerId`.
|
||||
|
||||
Each remote player state entry tracks:
|
||||
|
||||
@@ -40,13 +41,16 @@ serverTime
|
||||
lastReceivedLocalTime
|
||||
```
|
||||
|
||||
This model is a prototype for the eventual Fallout 4 receiving client. The goal
|
||||
is to prove the data shape and lifecycle before adding a plugin receive loop or
|
||||
spawning remote actors.
|
||||
This model proves the data shape and lifecycle before spawning remote actors.
|
||||
|
||||
## Implemented
|
||||
|
||||
- The plugin sends local player transform packets.
|
||||
- The plugin receives `welcome` packets and stores its assigned `playerId`.
|
||||
- The plugin receives broadcast transform packets.
|
||||
- The plugin ignores transform packets for its own assigned `playerId`.
|
||||
- The plugin stores remote player state by `playerId`.
|
||||
- The plugin receives `disconnect` packets and removes remote player state.
|
||||
- The server assigns incrementing `playerId` values.
|
||||
- The server sends `welcome` packets.
|
||||
- The server adds `playerId` and `serverTime` to transform packets.
|
||||
@@ -55,19 +59,10 @@ spawning remote actors.
|
||||
- The server broadcasts `disconnect` packets.
|
||||
- The fake client removes disconnected remote players from its state table.
|
||||
|
||||
## Working In Fake Client Only
|
||||
|
||||
- Receiving broadcast transform packets.
|
||||
- Tracking remote player state.
|
||||
- Handling disconnect cleanup.
|
||||
|
||||
No Fallout 4 remote actor is spawned yet.
|
||||
|
||||
## Planned Later
|
||||
|
||||
- Add a Fallout 4 plugin receive loop.
|
||||
- Parse `welcome`, `transform`, and `disconnect` packets in the plugin.
|
||||
- Store remote player state inside the plugin.
|
||||
- Add interpolation or smoothing after plugin-side state exists.
|
||||
- Spawn and move remote actors only after the receive loop is stable.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user