Add local networking and test server

Introduce a localhost-only networking milestone and a tiny Python test server to verify packet flow from the plugin to an external process. Added F4T::Networking API (include and src) which uses non-blocking Winsock to connect to 127.0.0.1:7777, send JSON newline-separated transform packets, and handle reconnect/backoff and disconnects safely. Plugin changes: call ConnectToLocalServer() at load, register DisconnectFromLocalServer on exit, and send transform packets from main.cpp when logging player movement. Build: xmake updated to include new headers and link ws2_32. Server: server/server.py implements a simple multi-client TCP server that prints parsed transform packets; server/README.md and docs/dev-log.md updated to document the test setup and results. All networking failures are non-fatal so the game continues if the server isn't running.
This commit is contained in:
2026-05-31 18:50:38 +12:00
parent 88506e0415
commit b24ce17751
7 changed files with 395 additions and 7 deletions
+38 -3
View File
@@ -174,10 +174,9 @@ Use this format for future updates:
### Notes ### Notes
- The first successful player position log was: - The first successful player position log was:
```text
Player position: X=2048.00, Y=2048.00, Z=0.00, AngleZ=0.00 Player position: X=2048.00, Y=2048.00, Z=0.00, AngleZ=0.00
## 2026-05-31 ## 2026-05-31
### What Changed ### What Changed
@@ -206,4 +205,40 @@ Player position: X=2048.00, Y=2048.00, Z=0.00, AngleZ=0.00
- Create a local test server. - Create a local test server.
- Send player transform data from the plugin to the server. - Send player transform data from the plugin to the server.
- Keep the first network test local-only. - Keep the first network test local-only.
## 2026-05-31
### What Changed
- Added a local Python test server.
- Added basic networking from the Fallout 4 Together plugin to the local server.
- Sent player transform data from Fallout 4 to the server.
- Server now prints received transform packets.
### What Worked
- `server/server.py` runs successfully.
- Server listens on `127.0.0.1:7777`.
- Fallout 4 launches through F4SE.
- The plugin connects to the local server.
- Moving the player sends transform packets to the server.
- Server prints X, Y, Z, and AngleZ values.
- Server handles client disconnects safely.
### What Broke
- Nothing recorded.
### Notes
- First successful server connection used local client port `55912`.
- The server received live player transform data from Fallout 4.
- This confirms the basic data path from game plugin to external server.
### Next Steps
- Add a player ID or client ID to transform packets.
- Add timestamps to transform packets.
- Have the server echo/broadcast transform packets to connected clients.
- Add a fake test client before attempting a second Fallout 4 client.
+9
View File
@@ -0,0 +1,9 @@
#pragma once
namespace F4T::Networking
{
bool ConnectToLocalServer();
void DisconnectFromLocalServer();
bool IsConnectedToServer();
bool SendTransformPacket(float a_x, float a_y, float a_z, float a_angleZ);
}
+209
View File
@@ -0,0 +1,209 @@
#include "pch.h"
#include "F4TNetworking.h"
#include <WinSock2.h>
#include <WS2tcpip.h>
#include <array>
#include <chrono>
#include <cstddef>
#include <cstdio>
#include <memory>
namespace
{
constexpr auto kLocalServerHost = "127.0.0.1";
constexpr auto kLocalServerPort = 7777;
constexpr auto kReconnectCooldown = 10s;
constexpr auto kConnectTimeoutMilliseconds = 100;
SOCKET g_socket = INVALID_SOCKET;
bool g_winsockInitialized = false;
auto g_lastConnectAttempt = std::chrono::steady_clock::time_point{};
void CloseSocket()
{
if (g_socket != INVALID_SOCKET) {
closesocket(g_socket);
g_socket = INVALID_SOCKET;
}
}
bool EnsureWinsockInitialized()
{
if (g_winsockInitialized) {
return true;
}
WSADATA winsockData{};
const auto startupResult = WSAStartup(MAKEWORD(2, 2), std::addressof(winsockData));
if (startupResult != 0) {
REX::WARN("Could not initialize Winsock for Fallout 4 Together local networking.");
return false;
}
g_winsockInitialized = true;
return true;
}
bool SetNonBlocking(SOCKET a_socket)
{
u_long nonBlocking = 1;
return ioctlsocket(a_socket, FIONBIO, std::addressof(nonBlocking)) == 0;
}
bool TryFinishNonBlockingConnect(SOCKET a_socket)
{
fd_set writeSet{};
FD_ZERO(std::addressof(writeSet));
FD_SET(a_socket, std::addressof(writeSet));
timeval timeout{};
timeout.tv_sec = 0;
timeout.tv_usec = kConnectTimeoutMilliseconds * 1000;
const auto selectResult = select(0, nullptr, std::addressof(writeSet), nullptr, std::addressof(timeout));
if (selectResult <= 0) {
return false;
}
int socketError = 0;
int socketErrorSize = sizeof(socketError);
if (getsockopt(
a_socket,
SOL_SOCKET,
SO_ERROR,
reinterpret_cast<char*>(std::addressof(socketError)),
std::addressof(socketErrorSize)) != 0) {
return false;
}
return socketError == 0;
}
bool ShouldAttemptReconnect()
{
const auto now = std::chrono::steady_clock::now();
if (g_lastConnectAttempt.time_since_epoch().count() != 0 && now - g_lastConnectAttempt < kReconnectCooldown) {
return false;
}
g_lastConnectAttempt = now;
return true;
}
}
namespace F4T::Networking
{
bool ConnectToLocalServer()
{
if (IsConnectedToServer()) {
return true;
}
if (!ShouldAttemptReconnect()) {
return false;
}
if (!EnsureWinsockInitialized()) {
return false;
}
// Keep this first networking milestone local-only so it can prove the
// packet path without exposing any gameplay state to a remote server.
const auto localSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (localSocket == INVALID_SOCKET) {
REX::WARN("Could not create socket for Fallout 4 Together local server connection.");
return false;
}
if (!SetNonBlocking(localSocket)) {
closesocket(localSocket);
REX::WARN("Could not configure non-blocking socket for Fallout 4 Together local server connection.");
return false;
}
sockaddr_in serverAddress{};
serverAddress.sin_family = AF_INET;
serverAddress.sin_port = htons(kLocalServerPort);
if (inet_pton(AF_INET, kLocalServerHost, std::addressof(serverAddress.sin_addr)) != 1) {
closesocket(localSocket);
REX::WARN("Could not parse Fallout 4 Together local server address.");
return false;
}
const auto connectResult =
connect(localSocket, reinterpret_cast<const sockaddr*>(std::addressof(serverAddress)), sizeof(serverAddress));
const auto connected = connectResult == 0 ||
(WSAGetLastError() == WSAEWOULDBLOCK && TryFinishNonBlockingConnect(localSocket));
if (!connected) {
closesocket(localSocket);
// The server is a developer tool, not a runtime requirement. A failed
// connection should leave Fallout 4 running and simply disable packet sends.
REX::WARN("Could not connect to Fallout 4 Together local server at 127.0.0.1:7777");
return false;
}
g_socket = localSocket;
REX::INFO("Connected to Fallout 4 Together local server.");
return true;
}
void DisconnectFromLocalServer()
{
CloseSocket();
if (g_winsockInitialized) {
WSACleanup();
g_winsockInitialized = false;
}
}
bool IsConnectedToServer()
{
return g_socket != INVALID_SOCKET;
}
bool SendTransformPacket(float a_x, float a_y, float a_z, float a_angleZ)
{
if (!IsConnectedToServer() && !ConnectToLocalServer()) {
return false;
}
std::array<char, 192> packet{};
const auto packetSize = std::snprintf(
packet.data(),
packet.size(),
"{\"type\":\"transform\",\"x\":%.2f,\"y\":%.2f,\"z\":%.2f,\"angleZ\":%.2f}\n",
a_x,
a_y,
a_z,
a_angleZ);
if (packetSize <= 0 || static_cast<std::size_t>(packetSize) >= packet.size()) {
REX::WARN("Could not format Fallout 4 Together transform packet.");
return false;
}
const auto bytesSent = send(g_socket, packet.data(), packetSize, 0);
if (bytesSent == SOCKET_ERROR) {
const auto error = WSAGetLastError();
if (error != WSAEWOULDBLOCK) {
REX::WARN("Lost connection to Fallout 4 Together local server.");
CloseSocket();
}
return false;
}
if (bytesSent != packetSize) {
REX::WARN("Could not send full Fallout 4 Together transform packet.");
CloseSocket();
return false;
}
return true;
}
}
+17 -1
View File
@@ -1,3 +1,7 @@
#include "F4TNetworking.h"
#include <cstdlib>
namespace namespace
{ {
struct PlayerTransform struct PlayerTransform
@@ -95,8 +99,14 @@ namespace
} }
// Player transforms can change by tiny amounts every frame. Throttling keeps the // Player transforms can change by tiny amounts every frame. Throttling keeps the
// readout useful for debugging movement without flooding Fallout4Together.log. // readout useful for debugging movement without flooding Fallout4Together.log or
// turning this first local networking test into a packet every frame.
LogPlayerTransform(currentTransform); LogPlayerTransform(currentTransform);
F4T::Networking::SendTransformPacket(
currentTransform.x,
currentTransform.y,
currentTransform.z,
currentTransform.angleZ);
lastTransform = currentTransform; lastTransform = currentTransform;
lastLogTime = currentTime; lastLogTime = currentTime;
} }
@@ -149,6 +159,12 @@ F4SE_PLUGIN_LOAD(const F4SE::LoadInterface* a_f4se)
REX::INFO("Fallout 4 Together plugin loaded successfully."); REX::INFO("Fallout 4 Together plugin loaded successfully.");
// Networking is intentionally localhost-only for the first milestone. If the
// test server is not running, the game must continue normally and movement
// logging still provides useful debugging data.
F4T::Networking::ConnectToLocalServer();
std::atexit(F4T::Networking::DisconnectFromLocalServer);
const auto* messaging = F4SE::GetMessagingInterface(); const auto* messaging = F4SE::GetMessagingInterface();
if (!messaging || !messaging->RegisterListener(OnF4SEMessage)) { if (!messaging || !messaging->RegisterListener(OnF4SEMessage)) {
REX::WARN("Failed to register F4SE message listener; player position will not be logged."); REX::WARN("Failed to register F4SE message listener; player position will not be logged.");
+3
View File
@@ -23,5 +23,8 @@ target("Fallout4Together")
-- add src files -- add src files
add_files("src/**.cpp") add_files("src/**.cpp")
add_headerfiles("src/**.h") add_headerfiles("src/**.h")
add_headerfiles("include/**.h")
add_includedirs("src") add_includedirs("src")
add_includedirs("include")
add_syslinks("ws2_32")
set_pcxxheader("src/pch.h") set_pcxxheader("src/pch.h")
+19 -3
View File
@@ -2,19 +2,35 @@
This folder is for the external multiplayer test server. This folder is for the external multiplayer test server.
The server is responsible for managing connected clients and broadcasting player state. The current server is a tiny local-only TCP test server. It listens on
`127.0.0.1:7777`, accepts one or more clients, reads newline-separated JSON
packets, and prints received player transform packets.
## Run
```bash
cd server
python server.py
```
Then launch Fallout 4 through F4SE and move the player. The plugin should keep
logging player position changes to `Fallout4Together.log`, and this server
should print matching `transform` packets.
If the server is not running, the plugin should log a warning and Fallout 4
should continue launching normally.
## First Server Goals ## First Server Goals
- Start local server - Start local server
- Accept client connections - Accept client connections
- Assign player IDs
- Receive transform packets - Receive transform packets
- Broadcast transform packets
- Handle disconnects - Handle disconnects
## Not Planned Yet ## Not Planned Yet
- Remote actor spawning
- Gameplay synchronization
- Authentication - Authentication
- Public matchmaking - Public matchmaking
- Anti-cheat - Anti-cheat
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
import json
import socket
import threading
from typing import Any
HOST = "127.0.0.1"
PORT = 7777
ACCEPT_TIMEOUT_SECONDS = 0.5
def log(message: str) -> None:
print(message, flush=True)
def handle_client(connection: socket.socket, address: tuple[str, int]) -> None:
client = f"{address[0]}:{address[1]}"
log(f"Client connected: {client}")
with connection:
buffer = ""
try:
while True:
chunk = connection.recv(4096)
if not chunk:
break
buffer += chunk.decode("utf-8", errors="replace")
while "\n" in buffer:
line, buffer = buffer.split("\n", 1)
handle_line(client, line.strip())
except ConnectionResetError:
log(f"Client disconnected unexpectedly: {client}")
finally:
log(f"Client disconnected: {client}")
def handle_line(client: str, line: str) -> None:
if not line:
return
try:
packet = json.loads(line)
except json.JSONDecodeError as error:
log(f"Invalid JSON from {client}: {error}: {line}")
return
print_packet(client, packet)
def print_packet(client: str, packet: dict[str, Any]) -> None:
if packet.get("type") != "transform":
log(f"Packet from {client}: {packet}")
return
try:
x = float(packet["x"])
y = float(packet["y"])
z = float(packet["z"])
angle_z = float(packet["angleZ"])
except (KeyError, TypeError, ValueError):
log(f"Malformed transform packet from {client}: {packet}")
return
log(
"Transform from "
f"{client}: x={x:.2f}, y={y:.2f}, z={z:.2f}, angleZ={angle_z:.2f}"
)
def main() -> None:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_socket:
if hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
server_socket.bind((HOST, PORT))
server_socket.listen()
server_socket.settimeout(ACCEPT_TIMEOUT_SECONDS)
log(f"Fallout 4 Together local test server listening on {HOST}:{PORT}")
log("Waiting for newline-separated JSON transform packets...")
while True:
try:
connection, address = server_socket.accept()
except socket.timeout:
# Wake periodically so Ctrl+C is handled promptly in Windows terminals.
continue
thread = threading.Thread(target=handle_client, args=(connection, address), daemon=True)
thread.start()
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
log("\nServer stopped.")