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
+19 -20
View File
@@ -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
+33
View File
@@ -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:
+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")
+7 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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.