Add GUI-managed fake TCP clients

Introduce GUI test tooling that spawns synthetic TCP clients for local relay testing. Adds server/fake_player.py implementing FakePlayerClient and FakePlayerManager which create TCP connections to 127.0.0.1:7777, parse newline-delimited JSON, receive server-assigned playerId, and send one idle transform per second for cell 0B000F99. Integrates the manager into server/dev_server_app.py: start/stop lifecycle handling, Add/Remove buttons, table snapshots, logging bridge, and clean shutdown of fake clients. Updates server/README.md and docs/dev-log.md with usage, testing notes, and known issues. Includes basic error handling and thread-safe snapshots; movement scripts beyond Idle and full in-game validation are noted as next steps.
This commit is contained in:
2026-06-02 14:43:01 +12:00
parent fff6680fe5
commit 9db37d3436
4 changed files with 440 additions and 29 deletions
+60
View File
@@ -978,6 +978,66 @@ Player position: X=2048.00, Y=2048.00, Z=0.00, AngleZ=0.00
--- ---
## 2026-06-02 - GUI Fake Client Manager
### Summary
Added GUI-managed synthetic TCP clients for local relay testing. Fake clients
connect through the normal server socket, receive a welcome `playerId`, send one
idle transform per second for `F4TTestCell01`, and disconnect cleanly from the
Fake Clients tab or when the GUI server stops.
### Files Changed
- `server/fake_player.py`
- `server/dev_server_app.py`
- `server/README.md`
- `docs/dev-log.md`
### Details
- Added `FakePlayerClient` and `FakePlayerManager` as GUI test tooling only.
- Fake clients use TCP `127.0.0.1:7777` and newline-separated JSON packets, so
the server sees them as normal external clients.
- The Fake Clients tab now supports `+ Add Fake Client` and
`- Remove Selected`.
- Fake-client lifecycle logs are routed to the GUI console with a
`[FakeClient]` prefix through the Qt signal bridge.
- Stopping or closing the GUI disconnects all fake clients before stopping the
server.
- The Fallout 4 plugin, network protocol, terminal server launcher, and
standalone `fake_client.py` were left unchanged.
### Testing
- Ran `python -m py_compile server.py server_core.py client_session.py
fake_client.py dev_server_app.py fake_player.py` from `server`; it succeeded.
- Ran `python server.py` from `server` and confirmed the terminal launcher
reached the expected listening state.
- Ran a headless fake-client smoke test on an alternate local port after
`127.0.0.1:7777` was temporarily held by another process. The fake client
received `playerId` 1, reached `Connected`, and the server recorded the idle
transform for `0B000F99`.
- Imported `dev_server_app.py` successfully to confirm the GUI module and
PySide6 dependency load in the current Python environment.
- Not yet run: interactive `python dev_server_app.py` GUI clicks.
- Not yet run: Fallout 4/F4SE validation that a real client receives the fake
client's idle transform in `F4TTestCell01`.
### Known Issues
- No movement scripts beyond `Idle` are implemented yet.
- Full Fallout 4/F4SE validation still needs to be run manually.
### Next Steps
- Add selectable fake-client movement scripts after the idle-client milestone is
validated.
- Consider fake-client controls for script start/stop once multiple scripts
exist.
---
## Entry Template ## Entry Template
Use this format for future updates: Use this format for future updates:
+21 -3
View File
@@ -38,6 +38,12 @@ Then launch Fallout 4 through F4SE and move the player. The plugin should send
local player transform packets, and this server should print matching local player transform packets, and this server should print matching
`transform` packets. `transform` packets.
In the developer GUI, the Fake Clients tab can create simple synthetic TCP
clients for local relay testing. Click `Start Server`, then click
`+ Add Fake Client`. Each fake client connects to `127.0.0.1:7777`, receives a
server-assigned `playerId`, appears in both GUI client tables, and sends one
idle transform per second for `F4TTestCell01`.
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.
@@ -49,6 +55,9 @@ should continue launching normally.
- `client_session.py`: `ClientSession` state model for connected clients. - `client_session.py`: `ClientSession` state model for connected clients.
- `dev_server_app.py`: PySide6 developer GUI that starts/stops the reusable - `dev_server_app.py`: PySide6 developer GUI that starts/stops the reusable
server core, displays logs, shows live stats, and lists connected clients. server core, displays logs, shows live stats, and lists connected clients.
- `fake_player.py`: GUI fake-player test tooling. It owns background synthetic
TCP clients that connect through the normal server socket and send idle
transform packets.
- `fake_client.py`: lightweight receiver for testing welcome, transform, and - `fake_client.py`: lightweight receiver for testing welcome, transform, and
disconnect packets without launching a second Fallout 4 instance. disconnect packets without launching a second Fallout 4 instance.
- `requirements.txt`: optional Python dependencies for the desktop developer - `requirements.txt`: optional Python dependencies for the desktop developer
@@ -97,7 +106,9 @@ python dev_server_app.py
``` ```
Click `Start Server`, then run `python fake_client.py` or launch Fallout 4 Click `Start Server`, then run `python fake_client.py` or launch Fallout 4
through F4SE. through F4SE. You can also click `+ Add Fake Client` to create a GUI-managed
synthetic client that sends an idle transform once per second. Select a fake
client row and click `- Remove Selected` to disconnect it cleanly.
Expected behavior: Expected behavior:
@@ -111,6 +122,9 @@ Expected behavior:
sender. sender.
- `fake_client.py` receives broadcast transform packets. - `fake_client.py` receives broadcast transform packets.
- `fake_client.py` stores remote player state by `playerId`. - `fake_client.py` stores remote player state by `playerId`.
- GUI fake clients connect as ordinary external TCP clients, receive a
`welcome` packet, store their assigned `playerId`, and send idle transforms
for cell `0B000F99`.
- When a client disconnects, the server removes that client and broadcasts a - When a client disconnects, the server removes that client and broadcasts a
`disconnect` packet with the departed `playerId` to the remaining clients. `disconnect` packet with the departed `playerId` to the remaining clients.
- `fake_client.py` removes disconnected players from its in-memory - `fake_client.py` removes disconnected players from its in-memory
@@ -125,7 +139,7 @@ does not spawn remote actors or send movement.
From inside this folder: From inside this folder:
```bash ```bash
python -m py_compile server.py server_core.py client_session.py fake_client.py dev_server_app.py python -m py_compile server.py server_core.py client_session.py fake_client.py dev_server_app.py fake_player.py
``` ```
Then run the terminal server: Then run the terminal server:
@@ -148,6 +162,9 @@ python dev_server_app.py
``` ```
Click `Start Server`, then connect `fake_client.py` or the Fallout 4 plugin. Click `Start Server`, then connect `fake_client.py` or the Fallout 4 plugin.
To validate GUI fake clients, click `+ Add Fake Client` and confirm the fake
client appears in the Fake Clients table and Connected Clients table with an
assigned player ID. Click `- Remove Selected` and confirm it disconnects.
For in-game validation, launch Fallout 4 through F4SE and confirm the plugin For in-game validation, launch Fallout 4 through F4SE and confirm the plugin
connects, receives a `welcome` packet, sends transforms, and receives relayed connects, receives a `welcome` packet, sends transforms, and receives relayed
@@ -168,7 +185,8 @@ remote transforms or disconnect packets when another client is connected.
- Reusable server core with thread-safe client/stat snapshots - Reusable server core with thread-safe client/stat snapshots
- Log callbacks for future server console UI - Log callbacks for future server console UI
- PySide6 developer server GUI with live logs, stats, connected-client table, - PySide6 developer server GUI with live logs, stats, connected-client table,
and a placeholder Fake Clients tab and a Fake Clients tab that can spawn idle synthetic TCP clients
- GUI-managed fake clients that receive welcome packets and send idle transforms
## Working In Fake Client Only ## Working In Fake Client Only
+72 -26
View File
@@ -4,7 +4,7 @@ import sys
import time import time
from typing import Any from typing import Any
from PySide6.QtCore import QObject, QTimer, Signal from PySide6.QtCore import QObject, Qt, QTimer, Signal
from PySide6.QtGui import QCloseEvent from PySide6.QtGui import QCloseEvent
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QApplication,
@@ -14,7 +14,6 @@ from PySide6.QtWidgets import (
QHeaderView, QHeaderView,
QLabel, QLabel,
QMainWindow, QMainWindow,
QMessageBox,
QPlainTextEdit, QPlainTextEdit,
QPushButton, QPushButton,
QTableWidget, QTableWidget,
@@ -24,6 +23,7 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from fake_player import FakePlayerManager
from server_core import FalloutTogetherServer from server_core import FalloutTogetherServer
@@ -42,6 +42,11 @@ class DevServerWindow(QMainWindow):
self.log_bridge = ServerLogBridge() self.log_bridge = ServerLogBridge()
self.log_bridge.message_received.connect(self._append_log) self.log_bridge.message_received.connect(self._append_log)
self.server.add_log_listener(self._handle_server_log) self.server.add_log_listener(self._handle_server_log)
self.fake_player_manager = FakePlayerManager(
host=self.server.host,
port=self.server.port,
log_callback=self._handle_fake_client_log,
)
self.stat_labels: dict[str, QLabel] = {} self.stat_labels: dict[str, QLabel] = {}
@@ -57,6 +62,7 @@ class DevServerWindow(QMainWindow):
def closeEvent(self, event: QCloseEvent) -> None: def closeEvent(self, event: QCloseEvent) -> None:
self.refresh_timer.stop() self.refresh_timer.stop()
self.fake_player_manager.stop_all()
self.server.remove_log_listener(self._handle_server_log) self.server.remove_log_listener(self._handle_server_log)
self.server.stop() self.server.stop()
event.accept() event.accept()
@@ -157,11 +163,11 @@ class DevServerWindow(QMainWindow):
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
self.add_fake_client_button = QPushButton("+ Add Fake Client") self.add_fake_client_button = QPushButton("+ Add Fake Client")
self.add_fake_client_button.clicked.connect(self._show_fake_clients_not_implemented) self.add_fake_client_button.clicked.connect(self._add_fake_client)
button_layout.addWidget(self.add_fake_client_button) button_layout.addWidget(self.add_fake_client_button)
self.remove_fake_client_button = QPushButton("- Remove Selected") self.remove_fake_client_button = QPushButton("- Remove Selected")
self.remove_fake_client_button.clicked.connect(self._show_fake_clients_not_implemented) self.remove_fake_client_button.clicked.connect(self._remove_selected_fake_client)
button_layout.addWidget(self.remove_fake_client_button) button_layout.addWidget(self.remove_fake_client_button)
button_layout.addStretch() button_layout.addStretch()
@@ -178,22 +184,9 @@ class DevServerWindow(QMainWindow):
self.fake_clients_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) self.fake_clients_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
layout.addWidget(self.fake_clients_table) layout.addWidget(self.fake_clients_table)
placeholder = [ # TODO: Add future fake-client scripts: Walk Circle, Walk Square,
"Fake Client 1", # Jump Loop, Sneak Toggle, Teleport Test, Leave Cell / Return, and
"-", # Disconnect After Delay.
"Not implemented yet",
"None",
"-",
"-",
]
self.fake_clients_table.setRowCount(1)
for column, value in enumerate(placeholder):
self.fake_clients_table.setItem(0, column, self._create_table_item(value))
# TODO: Add a fake client manager that can own multiple test clients.
# TODO: Add movement scripts once fake clients can be spawned safely.
# TODO: Wire add/remove fake client controls without duplicating networking logic.
# TODO: Add script controls for selecting and starting/stopping movement scripts.
return tab return tab
@@ -209,6 +202,7 @@ class DevServerWindow(QMainWindow):
def _stop_server(self) -> None: def _stop_server(self) -> None:
if self.server.is_running(): if self.server.is_running():
self.fake_player_manager.stop_all()
self.server.stop() self.server.stop()
self._append_log("Server stopped.") self._append_log("Server stopped.")
@@ -217,16 +211,38 @@ class DevServerWindow(QMainWindow):
def _copy_console(self) -> None: def _copy_console(self) -> None:
QApplication.clipboard().setText(self.console.toPlainText()) QApplication.clipboard().setText(self.console.toPlainText())
def _show_fake_clients_not_implemented(self) -> None: def _add_fake_client(self) -> None:
QMessageBox.information( if not self.server.is_running():
self, self._append_log("Start the server before adding fake clients.")
"Fake Clients", return
"Fake client spawning is not implemented yet.",
) self.fake_player_manager.add_client()
self._refresh_fake_clients_table()
def _remove_selected_fake_client(self) -> None:
selected_items = self.fake_clients_table.selectedItems()
if not selected_items:
self._append_log("No fake client selected.")
return
row = selected_items[0].row()
name_item = self.fake_clients_table.item(row, 0)
if name_item is None:
return
client_key = name_item.data(Qt.ItemDataRole.UserRole)
if client_key is None:
return
self.fake_player_manager.remove_client(int(client_key))
self._refresh_fake_clients_table()
def _handle_server_log(self, message: str) -> None: def _handle_server_log(self, message: str) -> None:
self.log_bridge.message_received.emit(message) self.log_bridge.message_received.emit(message)
def _handle_fake_client_log(self, message: str) -> None:
self.log_bridge.message_received.emit(message)
def _append_log(self, message: str) -> None: def _append_log(self, message: str) -> None:
timestamp = time.strftime("%H:%M:%S", time.localtime()) timestamp = time.strftime("%H:%M:%S", time.localtime())
self.console.appendPlainText(f"[{timestamp}] {message}") self.console.appendPlainText(f"[{timestamp}] {message}")
@@ -250,6 +266,7 @@ class DevServerWindow(QMainWindow):
self.stop_button.setEnabled(is_running) self.stop_button.setEnabled(is_running)
self._refresh_clients_table(clients) self._refresh_clients_table(clients)
self._refresh_fake_clients_table()
def _refresh_clients_table(self, clients: list[dict[str, Any]]) -> None: def _refresh_clients_table(self, clients: list[dict[str, Any]]) -> None:
self.clients_table.setRowCount(len(clients)) self.clients_table.setRowCount(len(clients))
@@ -281,6 +298,27 @@ class DevServerWindow(QMainWindow):
for column, value in enumerate(values): for column, value in enumerate(values):
self.clients_table.setItem(row, column, self._create_table_item(str(value))) self.clients_table.setItem(row, column, self._create_table_item(str(value)))
def _refresh_fake_clients_table(self) -> None:
snapshots = self.fake_player_manager.get_snapshots()
self.fake_clients_table.setRowCount(len(snapshots))
for row, snapshot in enumerate(snapshots):
player_id = snapshot.get("playerId")
values = [
snapshot.get("name", "-"),
player_id if player_id is not None else "-",
snapshot.get("status", "-"),
snapshot.get("script", "-"),
snapshot.get("cellId", "-"),
self._format_fake_position(snapshot.get("position")),
]
for column, value in enumerate(values):
item = self._create_table_item(str(value))
if column == 0:
item.setData(Qt.ItemDataRole.UserRole, snapshot.get("clientKey"))
self.fake_clients_table.setItem(row, column, item)
@staticmethod @staticmethod
def _create_table_item(value: str) -> QTableWidgetItem: def _create_table_item(value: str) -> QTableWidgetItem:
return QTableWidgetItem(value) return QTableWidgetItem(value)
@@ -316,6 +354,14 @@ class DevServerWindow(QMainWindow):
return f"{x:.2f}, {y:.2f}, {z:.2f}" return f"{x:.2f}, {y:.2f}, {z:.2f}"
@staticmethod
def _format_fake_position(position: Any) -> str:
try:
x, y, z = position
return f"{float(x):.1f}, {float(y):.1f}, {float(z):.1f}"
except (TypeError, ValueError):
return "-"
def main() -> int: def main() -> int:
app = QApplication(sys.argv) app = QApplication(sys.argv)
+287
View File
@@ -0,0 +1,287 @@
from __future__ import annotations
import json
import socket
import threading
import time
from collections.abc import Callable
from typing import Any
HOST = "127.0.0.1"
PORT = 7777
SCRIPT_IDLE = "Idle"
STATUS_CONNECTING = "Connecting"
STATUS_CONNECTED = "Connected"
STATUS_DISCONNECTED = "Disconnected"
STATUS_ERROR = "Error"
IDLE_SEND_INTERVAL_SECONDS = 1.0
SOCKET_TIMEOUT_SECONDS = 0.2
STOP_JOIN_TIMEOUT_SECONDS = 2.0
IDLE_CELL_ID = "0B000F99"
IDLE_WORLDSPACE_ID = ""
IDLE_X = -1792.0
IDLE_Y = -1344.0
IDLE_Z = 0.0
IDLE_ANGLE_Z = 3.93
class FakePlayerClient:
def __init__(
self,
client_key: int,
name: str,
*,
host: str = HOST,
port: int = PORT,
log_callback: Callable[[str], None] | None = None,
) -> None:
self.client_key = client_key
self.name = name
self.host = host
self.port = port
self.script = SCRIPT_IDLE
self.cell_id = IDLE_CELL_ID
self.position = (IDLE_X, IDLE_Y, IDLE_Z)
self._log_callback = log_callback
self._lock = threading.RLock()
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
self._socket: socket.socket | None = None
self._player_id: int | None = None
self._status = STATUS_DISCONNECTED
self._last_error: str | None = None
def start(self) -> None:
with self._lock:
if self._thread is not None and self._thread.is_alive():
return
self._stop_event.clear()
self._status = STATUS_CONNECTING
self._last_error = None
self._thread = threading.Thread(target=self._run, name=self.name, daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop_event.set()
self._close_socket()
thread = self._thread
if thread is not None and thread is not threading.current_thread():
thread.join(timeout=STOP_JOIN_TIMEOUT_SECONDS)
with self._lock:
if self._status != STATUS_ERROR:
self._status = STATUS_DISCONNECTED
def get_snapshot(self) -> dict[str, Any]:
with self._lock:
return {
"clientKey": self.client_key,
"name": self.name,
"playerId": self._player_id,
"status": self._status,
"script": self.script,
"cellId": self.cell_id,
"position": self.position,
"lastError": self._last_error,
}
def _run(self) -> None:
self._log(f"{self.name} connecting...")
try:
connection = socket.create_connection((self.host, self.port), timeout=5.0)
connection.settimeout(SOCKET_TIMEOUT_SECONDS)
with self._lock:
self._socket = connection
self._receive_and_send_loop(connection)
except (ConnectionRefusedError, TimeoutError, OSError) as error:
if not self._stop_event.is_set():
self._set_error(str(error))
self._log(f"{self.name} error: {error}")
finally:
self._close_socket()
with self._lock:
if self._status != STATUS_ERROR:
self._status = STATUS_DISCONNECTED
self._log(f"{self.name} disconnected.")
def _receive_and_send_loop(self, connection: socket.socket) -> None:
buffer = ""
next_transform_time = time.time()
while not self._stop_event.is_set():
chunk: bytes | None
try:
chunk = connection.recv(4096)
except socket.timeout:
chunk = None
except ConnectionResetError:
break
except OSError:
if not self._stop_event.is_set():
raise
break
if chunk is None:
pass
elif chunk:
buffer += chunk.decode("utf-8", errors="replace")
while "\n" in buffer:
line, buffer = buffer.split("\n", 1)
self._handle_line(line.strip())
else:
break
now = time.time()
if self._is_connected() and now >= next_transform_time:
self._send_idle_transform(connection)
next_transform_time = now + IDLE_SEND_INTERVAL_SECONDS
def _handle_line(self, line: str) -> None:
if not line:
return
try:
packet = json.loads(line)
except json.JSONDecodeError as error:
self._log(f"{self.name} received invalid JSON: {error}: {line}")
return
if not isinstance(packet, dict):
self._log(f"{self.name} received non-object packet: {packet}")
return
if packet.get("type") == "welcome":
self._handle_welcome(packet)
def _handle_welcome(self, packet: dict[str, Any]) -> None:
try:
player_id = int(packet["playerId"])
except (KeyError, TypeError, ValueError):
self._log(f"{self.name} received malformed welcome packet: {packet}")
return
with self._lock:
self._player_id = player_id
self._status = STATUS_CONNECTED
self._log(f"{self.name} assigned playerId {player_id}.")
def _send_idle_transform(self, connection: socket.socket) -> None:
packet = {
"type": "transform",
"x": IDLE_X,
"y": IDLE_Y,
"z": IDLE_Z,
"angleZ": IDLE_ANGLE_Z,
"movementType": "normal",
"cellId": IDLE_CELL_ID,
"worldspaceId": IDLE_WORLDSPACE_ID,
"clientTime": time.time(),
"isMoving": False,
"isSprinting": False,
"isSneaking": False,
"isJumping": False,
"weaponDrawn": False,
"movementSpeed": 0.0,
}
encoded = json.dumps(packet, separators=(",", ":")).encode("utf-8") + b"\n"
connection.sendall(encoded)
def _is_connected(self) -> bool:
with self._lock:
return self._status == STATUS_CONNECTED
def _set_error(self, message: str) -> None:
with self._lock:
self._status = STATUS_ERROR
self._last_error = message
def _close_socket(self) -> None:
with self._lock:
connection = self._socket
self._socket = None
if connection is None:
return
try:
connection.shutdown(socket.SHUT_RDWR)
except OSError:
pass
try:
connection.close()
except OSError:
pass
def _log(self, message: str) -> None:
if self._log_callback is None:
return
self._log_callback(message)
class FakePlayerManager:
def __init__(
self,
*,
host: str = HOST,
port: int = PORT,
log_callback: Callable[[str], None] | None = None,
) -> None:
self.host = host
self.port = port
self._log_callback = log_callback
self._lock = threading.RLock()
self._next_client_key = 1
self._clients: dict[int, FakePlayerClient] = {}
def add_client(self) -> FakePlayerClient:
with self._lock:
client_key = self._next_client_key
self._next_client_key += 1
client = FakePlayerClient(
client_key,
f"Fake Client {client_key}",
host=self.host,
port=self.port,
log_callback=self._log,
)
self._clients[client_key] = client
client.start()
return client
def remove_client(self, client_key: int) -> None:
with self._lock:
client = self._clients.pop(client_key, None)
if client is not None:
client.stop()
def stop_all(self) -> None:
with self._lock:
clients = list(self._clients.values())
self._clients.clear()
for client in clients:
client.stop()
def get_snapshots(self) -> list[dict[str, Any]]:
with self._lock:
clients = list(self._clients.values())
return [client.get_snapshot() for client in clients]
def _log(self, message: str) -> None:
if self._log_callback is None:
return
self._log_callback(f"[FakeClient] {message}")