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
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
`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
should continue launching normally.
@@ -49,6 +55,9 @@ should continue launching normally.
- `client_session.py`: `ClientSession` state model for connected clients.
- `dev_server_app.py`: PySide6 developer GUI that starts/stops the reusable
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
disconnect packets without launching a second Fallout 4 instance.
- `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
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:
@@ -111,6 +122,9 @@ Expected behavior:
sender.
- `fake_client.py` receives broadcast transform packets.
- `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
`disconnect` packet with the departed `playerId` to the remaining clients.
- `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:
```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:
@@ -148,6 +162,9 @@ python dev_server_app.py
```
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
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
- Log callbacks for future server console UI
- 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
+72 -26
View File
@@ -4,7 +4,7 @@ import sys
import time
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.QtWidgets import (
QApplication,
@@ -14,7 +14,6 @@ from PySide6.QtWidgets import (
QHeaderView,
QLabel,
QMainWindow,
QMessageBox,
QPlainTextEdit,
QPushButton,
QTableWidget,
@@ -24,6 +23,7 @@ from PySide6.QtWidgets import (
QWidget,
)
from fake_player import FakePlayerManager
from server_core import FalloutTogetherServer
@@ -42,6 +42,11 @@ class DevServerWindow(QMainWindow):
self.log_bridge = ServerLogBridge()
self.log_bridge.message_received.connect(self._append_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] = {}
@@ -57,6 +62,7 @@ class DevServerWindow(QMainWindow):
def closeEvent(self, event: QCloseEvent) -> None:
self.refresh_timer.stop()
self.fake_player_manager.stop_all()
self.server.remove_log_listener(self._handle_server_log)
self.server.stop()
event.accept()
@@ -157,11 +163,11 @@ class DevServerWindow(QMainWindow):
button_layout = QHBoxLayout()
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)
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.addStretch()
@@ -178,22 +184,9 @@ class DevServerWindow(QMainWindow):
self.fake_clients_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
layout.addWidget(self.fake_clients_table)
placeholder = [
"Fake Client 1",
"-",
"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.
# TODO: Add future fake-client scripts: Walk Circle, Walk Square,
# Jump Loop, Sneak Toggle, Teleport Test, Leave Cell / Return, and
# Disconnect After Delay.
return tab
@@ -209,6 +202,7 @@ class DevServerWindow(QMainWindow):
def _stop_server(self) -> None:
if self.server.is_running():
self.fake_player_manager.stop_all()
self.server.stop()
self._append_log("Server stopped.")
@@ -217,16 +211,38 @@ class DevServerWindow(QMainWindow):
def _copy_console(self) -> None:
QApplication.clipboard().setText(self.console.toPlainText())
def _show_fake_clients_not_implemented(self) -> None:
QMessageBox.information(
self,
"Fake Clients",
"Fake client spawning is not implemented yet.",
)
def _add_fake_client(self) -> None:
if not self.server.is_running():
self._append_log("Start the server before adding fake clients.")
return
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:
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:
timestamp = time.strftime("%H:%M:%S", time.localtime())
self.console.appendPlainText(f"[{timestamp}] {message}")
@@ -250,6 +266,7 @@ class DevServerWindow(QMainWindow):
self.stop_button.setEnabled(is_running)
self._refresh_clients_table(clients)
self._refresh_fake_clients_table()
def _refresh_clients_table(self, clients: list[dict[str, Any]]) -> None:
self.clients_table.setRowCount(len(clients))
@@ -281,6 +298,27 @@ class DevServerWindow(QMainWindow):
for column, value in enumerate(values):
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
def _create_table_item(value: str) -> QTableWidgetItem:
return QTableWidgetItem(value)
@@ -316,6 +354,14 @@ class DevServerWindow(QMainWindow):
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:
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}")