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:
+38
-3
@@ -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.
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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.");
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.")
|
||||||
Reference in New Issue
Block a user