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
+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
{
struct PlayerTransform
@@ -95,8 +99,14 @@ namespace
}
// 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);
F4T::Networking::SendTransformPacket(
currentTransform.x,
currentTransform.y,
currentTransform.z,
currentTransform.angleZ);
lastTransform = currentTransform;
lastLogTime = currentTime;
}
@@ -149,6 +159,12 @@ F4SE_PLUGIN_LOAD(const F4SE::LoadInterface* a_f4se)
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();
if (!messaging || !messaging->RegisterListener(OnF4SEMessage)) {
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_files("src/**.cpp")
add_headerfiles("src/**.h")
add_headerfiles("include/**.h")
add_includedirs("src")
add_includedirs("include")
add_syslinks("ws2_32")
set_pcxxheader("src/pch.h")