Add server broadcasts, fake client, timestamps
Enable transform broadcast testing and time syncing: server now assigns incrementing player IDs, sends a welcome packet with playerId and serverTime, and appends serverTime/playerId to incoming transform packets before broadcasting them to all other connected clients. Added thread-safe client tracking, send_packet helper, client removal on send errors, and improved logging. Plugin now includes a clientTime timestamp in transform packets. Added server/fake_client.py to receive and print welcome and broadcasted transform packets, and updated server README and dev-log with usage and test notes.
This commit is contained in:
@@ -278,3 +278,41 @@ Player position: X=2048.00, Y=2048.00, Z=0.00, AngleZ=0.00
|
|||||||
- Add server-side client IDs.
|
- Add server-side client IDs.
|
||||||
- Make the server broadcast transform packets to other connected clients.
|
- Make the server broadcast transform packets to other connected clients.
|
||||||
- Create a fake client to receive broadcast packets.
|
- Create a fake client to receive broadcast packets.
|
||||||
|
## 2026-05-31
|
||||||
|
|
||||||
|
### What Changed
|
||||||
|
|
||||||
|
- Updated the local server to assign incrementing player IDs.
|
||||||
|
- Added welcome packets for newly connected clients.
|
||||||
|
- Added server-side timestamps to transform packets.
|
||||||
|
- Added server broadcast support for transform packets.
|
||||||
|
- Added a receiver-only fake client.
|
||||||
|
- Added client-side timestamps to plugin transform packets.
|
||||||
|
- Updated server documentation with the fake-client test flow.
|
||||||
|
|
||||||
|
### What Worked
|
||||||
|
|
||||||
|
- `server/server.py` compiles with `python -m py_compile`.
|
||||||
|
- `server/fake_client.py` compiles with `python -m py_compile`.
|
||||||
|
- The server accepts multiple clients.
|
||||||
|
- The server assigns player IDs.
|
||||||
|
- The server broadcasts transform packets to clients other than the sender.
|
||||||
|
- The fake client receives broadcast transform packets.
|
||||||
|
- The plugin still builds successfully with `xmake build`.
|
||||||
|
|
||||||
|
### What Broke
|
||||||
|
|
||||||
|
- Nothing recorded.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- The plugin does not yet read welcome packets from the server.
|
||||||
|
- Server-to-plugin receive handling is intentionally left as a TODO.
|
||||||
|
- The fake client exists so transform broadcast can be tested before running two Fallout 4 clients.
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
|
||||||
|
- Run the full test with Fallout 4, the server, and the fake client.
|
||||||
|
- Confirm the fake client receives live transform packets from the Fallout 4 plugin.
|
||||||
|
- Add protocol documentation for `playerId`, `clientTime`, and `serverTime`.
|
||||||
|
- Begin planning remote player state storage on the receiving client.
|
||||||
@@ -148,6 +148,8 @@ namespace F4T::Networking
|
|||||||
|
|
||||||
g_socket = localSocket;
|
g_socket = localSocket;
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,16 +183,20 @@ namespace F4T::Networking
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const auto clientTime =
|
||||||
|
std::chrono::duration<double>(std::chrono::system_clock::now().time_since_epoch()).count();
|
||||||
|
|
||||||
std::array<char, 320> packet{};
|
std::array<char, 320> packet{};
|
||||||
auto packetSize = std::snprintf(
|
auto packetSize = std::snprintf(
|
||||||
packet.data(),
|
packet.data(),
|
||||||
packet.size(),
|
packet.size(),
|
||||||
"{\"type\":\"transform\",\"x\":%.2f,\"y\":%.2f,\"z\":%.2f,\"angleZ\":%.2f,\"movementType\":\"%s\"",
|
"{\"type\":\"transform\",\"x\":%.2f,\"y\":%.2f,\"z\":%.2f,\"angleZ\":%.2f,\"movementType\":\"%s\",\"clientTime\":%.3f",
|
||||||
a_x,
|
a_x,
|
||||||
a_y,
|
a_y,
|
||||||
a_z,
|
a_z,
|
||||||
a_angleZ,
|
a_angleZ,
|
||||||
a_movementType ? a_movementType : "normal");
|
a_movementType ? a_movementType : "normal",
|
||||||
|
clientTime);
|
||||||
|
|
||||||
if (packetSize <= 0 || static_cast<std::size_t>(packetSize) >= packet.size()) {
|
if (packetSize <= 0 || static_cast<std::size_t>(packetSize) >= packet.size()) {
|
||||||
REX::WARN("Could not format Fallout 4 Together transform packet.");
|
REX::WARN("Could not format Fallout 4 Together transform packet.");
|
||||||
|
|||||||
+39
-2
@@ -4,7 +4,8 @@ This folder is for the external multiplayer test server.
|
|||||||
|
|
||||||
The current server is a tiny local-only TCP test server. It listens on
|
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
|
`127.0.0.1:7777`, accepts one or more clients, reads newline-separated JSON
|
||||||
packets, and prints received player transform packets.
|
packets, prints received player transform packets, and broadcasts transform
|
||||||
|
packets to every other connected client.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
@@ -20,11 +21,47 @@ should print matching `transform` packets.
|
|||||||
If the server is not running, the plugin should log a warning and Fallout 4
|
If the server is not running, the plugin should log a warning and Fallout 4
|
||||||
should continue launching normally.
|
should continue launching normally.
|
||||||
|
|
||||||
## First Server Goals
|
## Broadcast Test
|
||||||
|
|
||||||
|
Terminal 1:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
python server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Terminal 2:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
python fake_client.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Then launch Fallout 4 through F4SE and move the player.
|
||||||
|
|
||||||
|
Expected result:
|
||||||
|
|
||||||
|
- The server assigns a `playerId` to each connected client and sends each client
|
||||||
|
a `welcome` packet.
|
||||||
|
- The fake client prints its `welcome` packet.
|
||||||
|
- The server prints transform packets received from Fallout 4.
|
||||||
|
- The server adds `playerId` and `serverTime` to each transform packet.
|
||||||
|
- The server broadcasts those transform packets to connected clients except the
|
||||||
|
sender.
|
||||||
|
- `fake_client.py` receives and prints the broadcast transform packets.
|
||||||
|
|
||||||
|
The fake client exists so broadcast behavior can be tested before coordinating a
|
||||||
|
second Fallout 4/F4SE instance. It is only a receiver for now and does not send
|
||||||
|
movement.
|
||||||
|
|
||||||
|
## Current Server Goals
|
||||||
|
|
||||||
- Start local server
|
- Start local server
|
||||||
- Accept client connections
|
- Accept client connections
|
||||||
- Receive transform packets
|
- Receive transform packets
|
||||||
|
- Assign server-owned player IDs
|
||||||
|
- Add server timestamps
|
||||||
|
- Broadcast transforms to other connected clients
|
||||||
- Handle disconnects
|
- Handle disconnects
|
||||||
|
|
||||||
## Not Planned Yet
|
## Not Planned Yet
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
HOST = "127.0.0.1"
|
||||||
|
PORT = 7777
|
||||||
|
|
||||||
|
|
||||||
|
def log(message: str) -> None:
|
||||||
|
print(message, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_packet(packet: dict[str, Any]) -> None:
|
||||||
|
packet_type = packet.get("type")
|
||||||
|
if packet_type == "welcome":
|
||||||
|
log(f"Welcome packet: {packet}")
|
||||||
|
elif packet_type == "transform":
|
||||||
|
log(f"Received transform: {packet}")
|
||||||
|
else:
|
||||||
|
log(f"Received packet: {packet}")
|
||||||
|
|
||||||
|
|
||||||
|
def handle_line(line: str) -> None:
|
||||||
|
if not line:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
packet = json.loads(line)
|
||||||
|
except json.JSONDecodeError as error:
|
||||||
|
log(f"Invalid JSON from server: {error}: {line}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not isinstance(packet, dict):
|
||||||
|
log(f"Invalid packet from server: expected JSON object: {packet}")
|
||||||
|
return
|
||||||
|
|
||||||
|
handle_packet(packet)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
# This fake client lets us verify server broadcast behavior before trying to
|
||||||
|
# run and coordinate a second Fallout 4/F4SE instance.
|
||||||
|
with socket.create_connection((HOST, PORT)) as connection:
|
||||||
|
log(f"Connected to Fallout 4 Together server at {HOST}:{PORT}")
|
||||||
|
|
||||||
|
buffer = ""
|
||||||
|
while True:
|
||||||
|
chunk = connection.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
log("Server disconnected.")
|
||||||
|
break
|
||||||
|
|
||||||
|
buffer += chunk.decode("utf-8", errors="replace")
|
||||||
|
while "\n" in buffer:
|
||||||
|
line, buffer = buffer.split("\n", 1)
|
||||||
|
handle_line(line.strip())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except ConnectionRefusedError:
|
||||||
|
log(f"Could not connect to Fallout 4 Together server at {HOST}:{PORT}. Is server.py running?")
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
log("\nFake client stopped.")
|
||||||
|
except OSError as error:
|
||||||
|
log(f"Disconnected from server: {error}")
|
||||||
+97
-11
@@ -3,6 +3,8 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import socket
|
import socket
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
@@ -10,19 +12,66 @@ HOST = "127.0.0.1"
|
|||||||
PORT = 7777
|
PORT = 7777
|
||||||
ACCEPT_TIMEOUT_SECONDS = 0.5
|
ACCEPT_TIMEOUT_SECONDS = 0.5
|
||||||
|
|
||||||
|
clients_lock = threading.Lock()
|
||||||
|
clients: dict[socket.socket, "Client"] = {}
|
||||||
|
next_player_id = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Client:
|
||||||
|
connection: socket.socket
|
||||||
|
address: tuple[str, int]
|
||||||
|
player_id: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def label(self) -> str:
|
||||||
|
return f"{self.address[0]}:{self.address[1]}"
|
||||||
|
|
||||||
|
|
||||||
def log(message: str) -> None:
|
def log(message: str) -> None:
|
||||||
print(message, flush=True)
|
print(message, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def assign_client(connection: socket.socket, address: tuple[str, int]) -> Client:
|
||||||
|
global next_player_id
|
||||||
|
|
||||||
|
with clients_lock:
|
||||||
|
# The server owns player IDs so early clients do not need to coordinate
|
||||||
|
# identity with each other or know anything about remote players yet.
|
||||||
|
client = Client(connection=connection, address=address, player_id=next_player_id)
|
||||||
|
next_player_id += 1
|
||||||
|
clients[connection] = client
|
||||||
|
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def remove_client(client: Client) -> None:
|
||||||
|
with clients_lock:
|
||||||
|
clients.pop(client.connection, None)
|
||||||
|
|
||||||
|
|
||||||
|
def send_packet(connection: socket.socket, packet: dict[str, Any]) -> None:
|
||||||
|
encoded = json.dumps(packet, separators=(",", ":")).encode("utf-8") + b"\n"
|
||||||
|
connection.sendall(encoded)
|
||||||
|
|
||||||
|
|
||||||
def handle_client(connection: socket.socket, address: tuple[str, int]) -> None:
|
def handle_client(connection: socket.socket, address: tuple[str, int]) -> None:
|
||||||
client = f"{address[0]}:{address[1]}"
|
client = assign_client(connection, address)
|
||||||
log(f"Client connected: {client}")
|
log(f"Client connected: {client.label} (player {client.player_id})")
|
||||||
|
|
||||||
with connection:
|
with connection:
|
||||||
buffer = ""
|
buffer = ""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
send_packet(
|
||||||
|
connection,
|
||||||
|
{
|
||||||
|
"type": "welcome",
|
||||||
|
"playerId": client.player_id,
|
||||||
|
"serverTime": time.time(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
chunk = connection.recv(4096)
|
chunk = connection.recv(4096)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
@@ -33,27 +82,64 @@ def handle_client(connection: socket.socket, address: tuple[str, int]) -> None:
|
|||||||
line, buffer = buffer.split("\n", 1)
|
line, buffer = buffer.split("\n", 1)
|
||||||
handle_line(client, line.strip())
|
handle_line(client, line.strip())
|
||||||
except ConnectionResetError:
|
except ConnectionResetError:
|
||||||
log(f"Client disconnected unexpectedly: {client}")
|
log(f"Client disconnected unexpectedly: {client.label} (player {client.player_id})")
|
||||||
|
except OSError as error:
|
||||||
|
log(f"Client connection error: {client.label} (player {client.player_id}): {error}")
|
||||||
finally:
|
finally:
|
||||||
log(f"Client disconnected: {client}")
|
remove_client(client)
|
||||||
|
log(f"Client disconnected: {client.label} (player {client.player_id})")
|
||||||
|
|
||||||
|
|
||||||
def handle_line(client: str, line: str) -> None:
|
def handle_line(client: Client, line: str) -> None:
|
||||||
if not line:
|
if not line:
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
packet = json.loads(line)
|
packet = json.loads(line)
|
||||||
except json.JSONDecodeError as error:
|
except json.JSONDecodeError as error:
|
||||||
log(f"Invalid JSON from {client}: {error}: {line}")
|
log(f"Invalid JSON from {client.label}: {error}: {line}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if not isinstance(packet, dict):
|
||||||
|
log(f"Invalid packet from {client.label}: expected JSON object: {packet}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if packet.get("type") == "transform":
|
||||||
|
packet["playerId"] = client.player_id
|
||||||
|
packet["serverTime"] = time.time()
|
||||||
|
|
||||||
print_packet(client, packet)
|
print_packet(client, packet)
|
||||||
|
|
||||||
|
if packet.get("type") == "transform":
|
||||||
|
broadcast_transform(client, packet)
|
||||||
|
|
||||||
def print_packet(client: str, packet: dict[str, Any]) -> None:
|
|
||||||
|
def broadcast_transform(sender: Client, packet: dict[str, Any]) -> None:
|
||||||
|
with clients_lock:
|
||||||
|
recipients = [client for client in clients.values() if client.connection != sender.connection]
|
||||||
|
|
||||||
|
failed_recipients: list[Client] = []
|
||||||
|
for recipient in recipients:
|
||||||
|
try:
|
||||||
|
# Transforms go to every other client only. The sender already knows
|
||||||
|
# its own movement; echoing it back would create duplicate local state.
|
||||||
|
send_packet(recipient.connection, packet)
|
||||||
|
except OSError:
|
||||||
|
failed_recipients.append(recipient)
|
||||||
|
|
||||||
|
for recipient in failed_recipients:
|
||||||
|
remove_client(recipient)
|
||||||
|
try:
|
||||||
|
recipient.connection.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
log(f"Broadcast transform from player {sender.player_id} to {len(recipients) - len(failed_recipients)} other client(s)")
|
||||||
|
|
||||||
|
|
||||||
|
def print_packet(client: Client, packet: dict[str, Any]) -> None:
|
||||||
if packet.get("type") != "transform":
|
if packet.get("type") != "transform":
|
||||||
log(f"Packet from {client}: {packet}")
|
log(f"Packet from {client.label}: {packet}")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -62,12 +148,12 @@ def print_packet(client: str, packet: dict[str, Any]) -> None:
|
|||||||
z = float(packet["z"])
|
z = float(packet["z"])
|
||||||
angle_z = float(packet["angleZ"])
|
angle_z = float(packet["angleZ"])
|
||||||
except (KeyError, TypeError, ValueError):
|
except (KeyError, TypeError, ValueError):
|
||||||
log(f"Malformed transform packet from {client}: {packet}")
|
log(f"Malformed transform packet from {client.label}: {packet}")
|
||||||
return
|
return
|
||||||
|
|
||||||
movement_type = packet.get("movementType", "normal")
|
movement_type = packet.get("movementType", "normal")
|
||||||
extra_fields = []
|
extra_fields = []
|
||||||
for field_name in ("cellId", "worldspaceId"):
|
for field_name in ("playerId", "cellId", "worldspaceId", "clientTime", "serverTime"):
|
||||||
if field_name in packet:
|
if field_name in packet:
|
||||||
extra_fields.append(f"{field_name}={packet[field_name]}")
|
extra_fields.append(f"{field_name}={packet[field_name]}")
|
||||||
|
|
||||||
@@ -77,7 +163,7 @@ def print_packet(client: str, packet: dict[str, Any]) -> None:
|
|||||||
|
|
||||||
log(
|
log(
|
||||||
"Transform from "
|
"Transform from "
|
||||||
f"{client}: x={x:.2f}, y={y:.2f}, z={z:.2f}, angleZ={angle_z:.2f}, "
|
f"{client.label}: x={x:.2f}, y={y:.2f}, z={z:.2f}, angleZ={angle_z:.2f}, "
|
||||||
f"movementType={movement_type}{extra_details}"
|
f"movementType={movement_type}{extra_details}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user