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
|
## Current Architecture
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Fallout 4 Plugin
|
Fallout 4 Plugin ↔ Local Python Server ↔ Other Clients
|
||||||
↓
|
↓
|
||||||
Local Python Server
|
Plugin Remote Player State
|
||||||
↓
|
|
||||||
Fake Client 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.
|
1. The Fallout 4 plugin reads local player transform data.
|
||||||
2. The plugin sends transform packets to the local Python server on
|
2. The plugin sends transform packets to the local Python server on
|
||||||
`127.0.0.1:7777`.
|
`127.0.0.1:7777`.
|
||||||
3. The server assigns `playerId` values and sends `welcome` packets.
|
3. The server assigns `playerId` values and sends `welcome` packets.
|
||||||
4. The server adds `playerId` and `serverTime` to transform packets.
|
4. The plugin receives its `welcome` packet and stores its assigned `playerId`.
|
||||||
5. The server broadcasts transform packets to other connected clients.
|
5. The server adds `playerId` and `serverTime` to transform packets.
|
||||||
6. `server/fake_client.py` stores remote player state by `playerId`.
|
6. The server broadcasts transform packets to other connected clients.
|
||||||
7. The server broadcasts `disconnect` packets when clients disconnect.
|
7. The plugin and `server/fake_client.py` store remote player state by `playerId`.
|
||||||
8. The fake client removes disconnected players from its remote player table.
|
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.
|
The Fallout 4 plugin still does not spawn or move remote actors.
|
||||||
There is no plugin receive loop and no remote actor spawning yet.
|
|
||||||
|
|
||||||
## Main Components
|
## Main Components
|
||||||
|
|
||||||
@@ -34,11 +34,11 @@ The native plugin is responsible for:
|
|||||||
- Loading into Fallout 4
|
- Loading into Fallout 4
|
||||||
- Reading local player state
|
- Reading local player state
|
||||||
- Sending local player data to the server
|
- Sending local player data to the server
|
||||||
|
- Receiving server packets on a background thread
|
||||||
|
- Storing assigned and remote `playerId` state internally
|
||||||
|
|
||||||
Planned later:
|
Planned later:
|
||||||
|
|
||||||
- Receiving remote player data
|
|
||||||
- Storing remote player state inside the plugin
|
|
||||||
- Applying remote player updates safely
|
- Applying remote player updates safely
|
||||||
- Spawning or moving remote actors
|
- Spawning or moving remote actors
|
||||||
|
|
||||||
@@ -79,24 +79,23 @@ The Creation Kit side is planned later and may be responsible for:
|
|||||||
|
|
||||||
## Threading Model
|
## Threading Model
|
||||||
|
|
||||||
This is planned for the Fallout 4 receiving client. It is not implemented yet.
|
|
||||||
|
|
||||||
Networking should run separately from game update logic.
|
Networking should run separately from game update logic.
|
||||||
|
|
||||||
Recommended approach:
|
Current approach:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Networking thread receives packets
|
Networking thread receives packets
|
||||||
Networking thread stores remote player state
|
Networking thread stores remote player state
|
||||||
Game update reads 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
|
## Out Of Scope For Current Prototype
|
||||||
|
|
||||||
- Fallout 4 receive-loop processing
|
|
||||||
- Remote actor spawning
|
- Remote actor spawning
|
||||||
- Combat sync
|
- Combat sync
|
||||||
- Quest 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
|
## Entry Template
|
||||||
|
|
||||||
Use this format for future updates:
|
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
|
## 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:
|
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/`
|
* Be copied into `Data/F4SE/Plugins/`
|
||||||
* Load when Fallout 4 is launched through F4SE
|
* Load when Fallout 4 is launched through F4SE
|
||||||
* Create or write to a `Fallout4Together.log` file
|
* 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
|
## Not Included Yet
|
||||||
|
|
||||||
The plugin does not currently include:
|
The plugin does not currently include:
|
||||||
|
|
||||||
* Networking
|
|
||||||
* Actor sync
|
* Actor sync
|
||||||
* Player transform sync
|
|
||||||
* Creation Kit test cell integration
|
* Creation Kit test cell integration
|
||||||
* Server connection
|
|
||||||
* Remote player spawning
|
* Remote player spawning
|
||||||
* Combat sync
|
* Combat sync
|
||||||
* Quest sync
|
* Quest sync
|
||||||
@@ -103,9 +105,13 @@ Use this checklist for the first plugin test:
|
|||||||
* [ ] Game reaches the main menu without crashing
|
* [ ] Game reaches the main menu without crashing
|
||||||
* [ ] Plugin log file is created
|
* [ ] Plugin log file is created
|
||||||
* [ ] Plugin log confirms that the plugin loaded
|
* [ ] 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
|
## Notes
|
||||||
|
|
||||||
Do not start multiplayer networking work until the empty plugin can build and load consistently.
|
Remote actor spawning is intentionally not part of the current networking
|
||||||
|
prototype. The receive loop stores plain remote-player state first so later
|
||||||
The first real technical milestone is not player sync. The first milestone is simply proving that Fallout 4 can load `Fallout4Together.dll` through F4SE.
|
actor work can be added from a stable data source.
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
#include "pch.h"
|
#include "pch.h"
|
||||||
|
|
||||||
#include "F4TNetworking.h"
|
#include "F4TNetworking.h"
|
||||||
|
#include "F4TRemotePlayerState.h"
|
||||||
|
|
||||||
#include <WinSock2.h>
|
#include <WinSock2.h>
|
||||||
#include <WS2tcpip.h>
|
#include <WS2tcpip.h>
|
||||||
|
|
||||||
#include <array>
|
#include <array>
|
||||||
|
#include <atomic>
|
||||||
|
#include <charconv>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <limits>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <mutex>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
@@ -17,19 +28,37 @@ namespace
|
|||||||
constexpr auto kLocalServerPort = 7777;
|
constexpr auto kLocalServerPort = 7777;
|
||||||
constexpr auto kReconnectCooldown = 10s;
|
constexpr auto kReconnectCooldown = 10s;
|
||||||
constexpr auto kConnectTimeoutMilliseconds = 100;
|
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;
|
SOCKET g_socket = INVALID_SOCKET;
|
||||||
|
std::mutex g_socketMutex;
|
||||||
bool g_winsockInitialized = false;
|
bool g_winsockInitialized = false;
|
||||||
auto g_lastConnectAttempt = std::chrono::steady_clock::time_point{};
|
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()
|
void CloseSocket()
|
||||||
{
|
{
|
||||||
|
const std::scoped_lock lock(g_socketMutex);
|
||||||
if (g_socket != INVALID_SOCKET) {
|
if (g_socket != INVALID_SOCKET) {
|
||||||
closesocket(g_socket);
|
closesocket(g_socket);
|
||||||
g_socket = INVALID_SOCKET;
|
g_socket = INVALID_SOCKET;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SOCKET GetSocket()
|
||||||
|
{
|
||||||
|
const std::scoped_lock lock(g_socketMutex);
|
||||||
|
return g_socket;
|
||||||
|
}
|
||||||
|
|
||||||
bool EnsureWinsockInitialized()
|
bool EnsureWinsockInitialized()
|
||||||
{
|
{
|
||||||
if (g_winsockInitialized) {
|
if (g_winsockInitialized) {
|
||||||
@@ -92,6 +121,321 @@ namespace
|
|||||||
g_lastConnectAttempt = now;
|
g_lastConnectAttempt = now;
|
||||||
return true;
|
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
|
namespace F4T::Networking
|
||||||
@@ -146,16 +490,26 @@ namespace F4T::Networking
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const std::scoped_lock lock(g_socketMutex);
|
||||||
g_socket = localSocket;
|
g_socket = localSocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
F4T::RemotePlayerState::ClearAssignedPlayerId();
|
||||||
|
F4T::RemotePlayerState::ClearRemotePlayers();
|
||||||
|
g_lastRemoteUpdateLogTimes.clear();
|
||||||
|
StartReceiveThread();
|
||||||
|
|
||||||
REX::INFO("Connected to Fallout 4 Together local server.");
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void DisconnectFromLocalServer()
|
void DisconnectFromLocalServer()
|
||||||
{
|
{
|
||||||
CloseSocket();
|
StopReceiveThread();
|
||||||
|
F4T::RemotePlayerState::ClearAssignedPlayerId();
|
||||||
|
F4T::RemotePlayerState::ClearRemotePlayers();
|
||||||
|
g_lastRemoteUpdateLogTimes.clear();
|
||||||
|
|
||||||
if (g_winsockInitialized) {
|
if (g_winsockInitialized) {
|
||||||
WSACleanup();
|
WSACleanup();
|
||||||
@@ -165,7 +519,7 @@ namespace F4T::Networking
|
|||||||
|
|
||||||
bool IsConnectedToServer()
|
bool IsConnectedToServer()
|
||||||
{
|
{
|
||||||
return g_socket != INVALID_SOCKET;
|
return GetSocket() != INVALID_SOCKET;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool SendTransformPacket(
|
bool SendTransformPacket(
|
||||||
@@ -244,7 +598,16 @@ namespace F4T::Networking
|
|||||||
|
|
||||||
packetSize += appended;
|
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) {
|
if (bytesSent == SOCKET_ERROR) {
|
||||||
const auto error = WSAGetLastError();
|
const auto error = WSAGetLastError();
|
||||||
if (error != WSAEWOULDBLOCK) {
|
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 common rules
|
||||||
add_rules("mode.debug", "mode.releasedbg")
|
add_rules("mode.debug", "mode.releasedbg")
|
||||||
add_rules("plugin.vsxmake.autoupdate")
|
add_rules("plugin.vsxmake.autoupdate")
|
||||||
|
add_requires("nlohmann_json")
|
||||||
|
|
||||||
-- define targets
|
-- define targets
|
||||||
target("Fallout4Together")
|
target("Fallout4Together")
|
||||||
@@ -26,5 +27,6 @@ target("Fallout4Together")
|
|||||||
add_headerfiles("include/**.h")
|
add_headerfiles("include/**.h")
|
||||||
add_includedirs("src")
|
add_includedirs("src")
|
||||||
add_includedirs("include")
|
add_includedirs("include")
|
||||||
|
add_packages("nlohmann_json")
|
||||||
add_syslinks("ws2_32")
|
add_syslinks("ws2_32")
|
||||||
set_pcxxheader("src/pch.h")
|
set_pcxxheader("src/pch.h")
|
||||||
|
|||||||
@@ -20,12 +20,13 @@ Current behavior:
|
|||||||
broadcast to other connected clients.
|
broadcast to other connected clients.
|
||||||
- `disconnect` is sent by the server when a client disconnects.
|
- `disconnect` is sent by the server when a client disconnects.
|
||||||
|
|
||||||
The Fallout 4 plugin currently sends transform packets only. It does not yet
|
The Fallout 4 plugin sends transform packets and receives `welcome`,
|
||||||
receive or process server packets.
|
`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
|
```text
|
||||||
welcome
|
welcome
|
||||||
@@ -33,8 +34,8 @@ transform
|
|||||||
disconnect
|
disconnect
|
||||||
```
|
```
|
||||||
|
|
||||||
The fake client stores broadcast transform packets in an in-memory remote player
|
The plugin and fake client store broadcast transform packets in in-memory remote
|
||||||
state table and removes entries when disconnect packets arrive.
|
player state tables and remove entries when disconnect packets arrive.
|
||||||
|
|
||||||
## Planned Later
|
## 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.
|
serverTime Server timestamp when the welcome packet was created.
|
||||||
```
|
```
|
||||||
|
|
||||||
The Fallout 4 plugin does not yet read this packet. `server/fake_client.py`
|
The Fallout 4 plugin and `server/fake_client.py` receive this packet. The plugin
|
||||||
currently receives and prints it.
|
stores the assigned `playerId` for filtering its own future transform echoes.
|
||||||
|
|
||||||
### Transform Packet
|
### Transform Packet
|
||||||
|
|
||||||
@@ -117,20 +117,24 @@ playerId Server-assigned player ID that disconnected.
|
|||||||
serverTime Server timestamp when the disconnect packet was created.
|
serverTime Server timestamp when the disconnect packet was created.
|
||||||
```
|
```
|
||||||
|
|
||||||
`server/fake_client.py` currently uses this packet to remove remote players from
|
The Fallout 4 plugin and `server/fake_client.py` use this packet to remove
|
||||||
its in-memory state table.
|
remote players from their in-memory state tables.
|
||||||
|
|
||||||
## Working In Fake Client Only
|
## Receiver Behavior
|
||||||
|
|
||||||
- Receiving `welcome` packets.
|
The Fallout 4 plugin and fake client both receive:
|
||||||
- Receiving broadcast `transform` packets from other clients.
|
|
||||||
- Storing remote player state by `playerId`.
|
```text
|
||||||
- Removing remote player state when `disconnect` packets arrive.
|
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
|
## 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.
|
- Remote actor spawning.
|
||||||
- Gameplay synchronization.
|
- Gameplay synchronization.
|
||||||
|
|||||||
+14
-19
@@ -6,23 +6,24 @@ The current implementation is still a local networking prototype.
|
|||||||
## Current Implemented Flow
|
## Current Implemented Flow
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Fallout 4 Plugin
|
Fallout 4 Plugin ↔ Local Python Server ↔ Other Clients
|
||||||
↓
|
↓
|
||||||
Local Python Server
|
Plugin Remote Player State
|
||||||
↓
|
|
||||||
Fake Client Remote Player State
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The Fallout 4 plugin currently reads the local player's transform and sends it
|
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`,
|
to the local Python server. The server assigns a `playerId`, adds `serverTime`,
|
||||||
and broadcasts transform packets to other connected clients.
|
and broadcasts transform packets to other connected clients.
|
||||||
|
|
||||||
The Fallout 4 plugin does not yet receive remote transforms from the server.
|
The Fallout 4 plugin also receives server packets on a background networking
|
||||||
The current receiver is `server/fake_client.py`.
|
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
|
## 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:
|
Each remote player state entry tracks:
|
||||||
|
|
||||||
@@ -40,13 +41,16 @@ serverTime
|
|||||||
lastReceivedLocalTime
|
lastReceivedLocalTime
|
||||||
```
|
```
|
||||||
|
|
||||||
This model is a prototype for the eventual Fallout 4 receiving client. The goal
|
This model proves the data shape and lifecycle before spawning remote actors.
|
||||||
is to prove the data shape and lifecycle before adding a plugin receive loop or
|
|
||||||
spawning remote actors.
|
|
||||||
|
|
||||||
## Implemented
|
## Implemented
|
||||||
|
|
||||||
- The plugin sends local player transform packets.
|
- 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 assigns incrementing `playerId` values.
|
||||||
- The server sends `welcome` packets.
|
- The server sends `welcome` packets.
|
||||||
- The server adds `playerId` and `serverTime` to transform packets.
|
- The server adds `playerId` and `serverTime` to transform packets.
|
||||||
@@ -55,19 +59,10 @@ spawning remote actors.
|
|||||||
- The server broadcasts `disconnect` packets.
|
- The server broadcasts `disconnect` packets.
|
||||||
- The fake client removes disconnected remote players from its state table.
|
- 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.
|
No Fallout 4 remote actor is spawned yet.
|
||||||
|
|
||||||
## Planned Later
|
## 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.
|
- Add interpolation or smoothing after plugin-side state exists.
|
||||||
- Spawn and move remote actors only after the receive loop is stable.
|
- Spawn and move remote actors only after the receive loop is stable.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user