Add fake-client dev scripts and GUI actions

Add developer-only fake-client scripts and UI actions for testing proxy behavior. Files updated: server/fake_player.py, server/dev_server_app.py, server/README.md, docs/dev-log.md. New scripts/actions: Walk Circle, Jump Once, Toggle Sneak, Leave Cell (Left Cell), Return To Cell, and Teleport Test. UI: added buttons and handlers in the DevServerWindow to trigger these actions. fake_player: implemented script/state handling, one-shot jump thread, teleport toggle, sneak flag, walk-circle movement, cell change transforms, a socket send lock, and protection for one-shot/action state. README and dev log updated with usage, testing notes, and known issues. Basic headless smoke tests and py_compile checks were performed; full in-game Fallout 4/F4SE validation remains manual.
This commit is contained in:
2026-06-02 15:46:59 +12:00
parent 0b73b1eff1
commit 0e67209ab1
4 changed files with 485 additions and 18 deletions
+33 -10
View File
@@ -55,6 +55,19 @@ Select a fake client row to use the movement controls:
inside the real player. While moving, it sends normal transform packets at
about 10 Hz with `isMoving=true`; when it reaches the stop distance, it
returns to low-rate idle keep-alive transforms.
- `Walk Circle`: makes the selected fake client move continuously around a
simple circle in `F4TTestCell01`, sending normal moving transforms at about
10 Hz.
- `Jump Once`: sends a short normal-movement jump arc with `isJumping=true`,
then restores the fake client's ground Z and resumes the current script.
- `Toggle Sneak`: toggles the fake client's persistent `isSneaking` flag. It
does not move the fake client by itself.
- `Leave Cell`: sends a `cell_change` transform to fake cell `DEADBEEF` and
keeps the fake client connected in a low-rate `Left Cell` mode.
- `Return To Cell`: sends a `cell_change` transform back to `0B000F99`, restores
`worldspaceId=""`, and returns the fake client to `Idle`.
- `Teleport Test`: sends one `movementType=teleport` transform to a
deterministic test-cell offset, then returns the fake client to `Idle`.
These controls are developer test tooling only. They do not change the network
protocol, server relay behavior, or Fallout 4 plugin behavior.
@@ -123,8 +136,9 @@ python dev_server_app.py
Click `Start Server`, then run `python fake_client.py` or launch Fallout 4
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 `Set Idle` or `Walk To Player` to control its script. Click
`- Remove Selected` to disconnect it cleanly.
client row and click `Set Idle`, `Walk To Player`, `Walk Circle`, `Jump Once`,
`Toggle Sneak`, `Leave Cell`, `Return To Cell`, or `Teleport Test` to control
its dev script/action. Click `- Remove Selected` to disconnect it cleanly.
Expected behavior:
@@ -143,6 +157,9 @@ Expected behavior:
for cell `0B000F99`.
- GUI fake clients can be switched between `Idle` and `Walk To Player` scripts
from the Fake Clients tab.
- GUI fake clients can also run `Walk Circle`, `Left Cell`, and one-shot jump,
sneak toggle, return-to-cell, and teleport test actions from the Fake Clients
tab.
- Walk To Player uses `FalloutTogetherServer.get_clients()` snapshots to find
the lowest non-fake client with a valid last transform, then walks gradually
toward that player with a simple straight-line movement test.
@@ -185,17 +202,21 @@ 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. Select the fake client, click `Set Idle`, and confirm the
Script column stays or changes to `Idle`. Click `Walk To Player` with no real
Fallout 4 client connected and confirm the GUI logs that no real player target
is available. Click `- Remove Selected` and confirm it disconnects.
assigned player ID. Select the fake client, click each Fake Clients action, and
confirm the Script, Cell ID, Position, and console log output update as expected.
Click `Walk To Player` with no real Fallout 4 client connected and confirm the
GUI logs that no real player target is available. 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
remote transforms or disconnect packets when another client is connected. In
`F4TTestCell01`, select a GUI fake client and click `Walk To Player`; the fake
client should walk smoothly toward the real player's latest transform offset and
then stop near that offset.
then stop near that offset. Test `Walk Circle`, `Jump Once`, `Toggle Sneak`,
`Leave Cell`, `Return To Cell`, and `Teleport Test` while watching the proxy to
confirm movement, jump/sneak state, cell mismatch holding, return, and teleport
snap behavior.
## Implemented
@@ -217,6 +238,8 @@ then stop near that offset.
- Fake Clients tab movement controls for `Set Idle` and `Walk To Player`
- GUI-managed fake clients that can walk gradually toward a real Fallout 4
client using normal transform packets
- Fake Clients tab dev scripts/actions for `Jump Once`, `Toggle Sneak`,
`Walk Circle`, `Leave Cell`, `Return To Cell`, and `Teleport Test`
## Working In Fake Client Only
@@ -227,9 +250,9 @@ then stop near that offset.
## Planned Later
- Additional fake client scripts such as Walk Circle, Walk Square, Follow
Player, Jump Loop, Sneak Toggle, Teleport Test, Leave Cell / Return, and
Disconnect After Delay
- Additional fake client scripts such as Walk Square, Follow Player, Sprint
Toggle, Weapon Drawn Toggle, Disconnect After Delay, and multi-fake-player
choreography
- Gameplay synchronization
- Authentication
- Public matchmaking
+98 -3
View File
@@ -184,9 +184,38 @@ class DevServerWindow(QMainWindow):
self.walk_to_player_button.clicked.connect(self._set_selected_fake_client_walk_to_player)
action_layout.addWidget(self.walk_to_player_button)
self.walk_circle_button = QPushButton("Walk Circle")
self.walk_circle_button.clicked.connect(self._set_selected_fake_client_walk_circle)
action_layout.addWidget(self.walk_circle_button)
action_layout.addStretch()
layout.addLayout(action_layout)
script_action_layout = QHBoxLayout()
self.jump_once_button = QPushButton("Jump Once")
self.jump_once_button.clicked.connect(self._trigger_selected_fake_client_jump_once)
script_action_layout.addWidget(self.jump_once_button)
self.toggle_sneak_button = QPushButton("Toggle Sneak")
self.toggle_sneak_button.clicked.connect(self._toggle_selected_fake_client_sneak)
script_action_layout.addWidget(self.toggle_sneak_button)
self.leave_cell_button = QPushButton("Leave Cell")
self.leave_cell_button.clicked.connect(self._trigger_selected_fake_client_leave_cell)
script_action_layout.addWidget(self.leave_cell_button)
self.return_to_cell_button = QPushButton("Return To Cell")
self.return_to_cell_button.clicked.connect(self._trigger_selected_fake_client_return_to_cell)
script_action_layout.addWidget(self.return_to_cell_button)
self.teleport_test_button = QPushButton("Teleport Test")
self.teleport_test_button.clicked.connect(self._trigger_selected_fake_client_teleport_test)
script_action_layout.addWidget(self.teleport_test_button)
script_action_layout.addStretch()
layout.addLayout(script_action_layout)
self.fake_clients_table = QTableWidget()
self.fake_clients_table.setColumnCount(6)
self.fake_clients_table.setHorizontalHeaderLabels(
@@ -198,9 +227,9 @@ class DevServerWindow(QMainWindow):
self.fake_clients_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
layout.addWidget(self.fake_clients_table)
# TODO: Add future fake-client scripts: Walk Circle, Walk Square,
# Follow Player, Jump Loop, Sneak Toggle, Teleport Test,
# Leave Cell / Return, and Disconnect After Delay.
# TODO: Add future fake-client scripts: Walk Square, Follow Player,
# Sprint Toggle, Weapon Drawn Toggle, Disconnect After Delay, and
# Multi-fake-player choreography.
return tab
@@ -264,6 +293,72 @@ class DevServerWindow(QMainWindow):
self._refresh_fake_clients_table()
def _set_selected_fake_client_walk_circle(self) -> None:
client_key = self._get_selected_fake_client_key_for_control()
if client_key is None:
return
if not self.fake_player_manager.set_client_walk_circle(client_key):
self._append_log("Select a fake client first.")
return
self._refresh_fake_clients_table()
def _trigger_selected_fake_client_jump_once(self) -> None:
client_key = self._get_selected_fake_client_key_for_control()
if client_key is None:
return
if not self.fake_player_manager.trigger_client_jump_once(client_key):
self._append_log("Select a fake client first.")
return
self._refresh_fake_clients_table()
def _toggle_selected_fake_client_sneak(self) -> None:
client_key = self._get_selected_fake_client_key_for_control()
if client_key is None:
return
if not self.fake_player_manager.toggle_client_sneak(client_key):
self._append_log("Select a fake client first.")
return
self._refresh_fake_clients_table()
def _trigger_selected_fake_client_leave_cell(self) -> None:
client_key = self._get_selected_fake_client_key_for_control()
if client_key is None:
return
if not self.fake_player_manager.trigger_client_leave_cell(client_key):
self._append_log("Select a fake client first.")
return
self._refresh_fake_clients_table()
def _trigger_selected_fake_client_return_to_cell(self) -> None:
client_key = self._get_selected_fake_client_key_for_control()
if client_key is None:
return
if not self.fake_player_manager.trigger_client_return_to_cell(client_key):
self._append_log("Select a fake client first.")
return
self._refresh_fake_clients_table()
def _trigger_selected_fake_client_teleport_test(self) -> None:
client_key = self._get_selected_fake_client_key_for_control()
if client_key is None:
return
if not self.fake_player_manager.trigger_client_teleport_test(client_key):
self._append_log("Select a fake client first.")
return
self._refresh_fake_clients_table()
def _get_selected_fake_client_key_for_control(self) -> int | None:
client_key = self._get_selected_fake_client_key()
if client_key is None:
+288 -5
View File
@@ -13,6 +13,8 @@ HOST = "127.0.0.1"
PORT = 7777
SCRIPT_IDLE = "Idle"
SCRIPT_WALK_TO_PLAYER = "Walk To Player"
SCRIPT_WALK_CIRCLE = "Walk Circle"
SCRIPT_LEFT_CELL = "Left Cell"
STATUS_CONNECTING = "Connecting"
STATUS_CONNECTED = "Connected"
STATUS_DISCONNECTED = "Disconnected"
@@ -22,16 +24,25 @@ WALK_TO_PLAYER_SEND_RATE_HZ = 10.0
WALK_TO_PLAYER_SPEED = 180.0
WALK_TO_PLAYER_STOP_DISTANCE = 100.0
WALK_TO_PLAYER_OFFSET_X = 150.0
WALK_CIRCLE_RADIUS = 300.0
WALK_CIRCLE_SPEED = 180.0
WALK_CIRCLE_SEND_RATE_HZ = 10.0
JUMP_DURATION_SECONDS = 0.8
JUMP_HEIGHT = 120.0
JUMP_SEND_RATE_HZ = 10.0
NO_TARGET_LOG_INTERVAL_SECONDS = 5.0
SOCKET_TIMEOUT_SECONDS = 0.05
STOP_JOIN_TIMEOUT_SECONDS = 2.0
IDLE_CELL_ID = "0B000F99"
IDLE_WORLDSPACE_ID = ""
LEFT_CELL_ID = "DEADBEEF"
IDLE_X = -1792.0
IDLE_Y = -1344.0
IDLE_Z = 0.0
IDLE_ANGLE_Z = 3.93
TELEPORT_TEST_OFFSET_X = 900.0
TELEPORT_TEST_OFFSET_Y = 450.0
ClientSnapshotProvider = Callable[[], list[dict[str, Any]]]
WalkTargetProvider = Callable[[int], dict[str, Any] | None]
@@ -57,10 +68,13 @@ class FakePlayerClient:
self.worldspace_id = IDLE_WORLDSPACE_ID
self.position = (IDLE_X, IDLE_Y, IDLE_Z)
self.angle_z = IDLE_ANGLE_Z
self.is_sneaking = False
self.is_jumping = False
self._log_callback = log_callback
self._walk_target_provider = walk_target_provider
self._lock = threading.RLock()
self._send_lock = threading.Lock()
self._stop_event = threading.Event()
self._script_changed_event = threading.Event()
self._thread: threading.Thread | None = None
@@ -71,6 +85,11 @@ class FakePlayerClient:
self._last_no_target_log_at = 0.0
self._last_target_player_id: int | None = None
self._reached_target_player_id: int | None = None
self._walk_circle_center = self.position
self._walk_circle_phase = 0.0
self._one_shot_active = False
self._jump_thread: threading.Thread | None = None
self._teleport_count = 0
def start(self) -> None:
with self._lock:
@@ -91,6 +110,10 @@ class FakePlayerClient:
if thread is not None and thread is not threading.current_thread():
thread.join(timeout=STOP_JOIN_TIMEOUT_SECONDS)
jump_thread = self._jump_thread
if jump_thread is not None and jump_thread is not threading.current_thread():
jump_thread.join(timeout=STOP_JOIN_TIMEOUT_SECONDS)
with self._lock:
if self._status != STATUS_ERROR:
self._status = STATUS_DISCONNECTED
@@ -101,6 +124,103 @@ class FakePlayerClient:
def set_walk_to_player(self) -> None:
self._set_script(SCRIPT_WALK_TO_PLAYER)
def set_walk_circle(self) -> None:
with self._lock:
self.cell_id = IDLE_CELL_ID
self.worldspace_id = IDLE_WORLDSPACE_ID
self._walk_circle_center = self.position
self._walk_circle_phase = 0.0
self._set_script(SCRIPT_WALK_CIRCLE)
def trigger_jump_once(self) -> None:
with self._lock:
if self._one_shot_active:
return
self._one_shot_active = True
previous_script = self.script
thread = threading.Thread(
target=self._run_jump_once,
args=(previous_script,),
name=f"{self.name} Jump Once",
daemon=True,
)
self._jump_thread = thread
self._log(f"{self.name} triggered Jump Once.")
thread.start()
def toggle_sneak(self) -> bool:
with self._lock:
self.is_sneaking = not self.is_sneaking
is_sneaking = self.is_sneaking
self._script_changed_event.set()
self._log(f"{self.name} toggled sneak: {str(is_sneaking).lower()}.")
return is_sneaking
def trigger_leave_cell(self) -> None:
with self._lock:
self.script = SCRIPT_LEFT_CELL
self.cell_id = LEFT_CELL_ID
self.worldspace_id = IDLE_WORLDSPACE_ID
self._last_no_target_log_at = 0.0
self._last_target_player_id = None
self._reached_target_player_id = None
self._script_changed_event.set()
self._send_immediate_transform(
movement_type="cell_change",
is_moving=False,
movement_speed=0.0,
)
self._log(f"{self.name} left test cell.")
def trigger_return_to_cell(self) -> None:
with self._lock:
self.script = SCRIPT_IDLE
self.cell_id = IDLE_CELL_ID
self.worldspace_id = IDLE_WORLDSPACE_ID
self.position = (IDLE_X, IDLE_Y, IDLE_Z)
self.angle_z = IDLE_ANGLE_Z
self._last_no_target_log_at = 0.0
self._last_target_player_id = None
self._reached_target_player_id = None
self._script_changed_event.set()
self._send_immediate_transform(
movement_type="cell_change",
is_moving=False,
movement_speed=0.0,
)
self._log(f"{self.name} returned to test cell.")
def trigger_teleport_test(self) -> None:
with self._lock:
self.script = SCRIPT_IDLE
self.cell_id = IDLE_CELL_ID
self.worldspace_id = IDLE_WORLDSPACE_ID
current_x, current_y, current_z = self.position
direction = -1.0 if self._teleport_count % 2 else 1.0
self._teleport_count += 1
self.position = (
current_x + (TELEPORT_TEST_OFFSET_X * direction),
current_y + (TELEPORT_TEST_OFFSET_Y * direction),
current_z,
)
self._last_no_target_log_at = 0.0
self._last_target_player_id = None
self._reached_target_player_id = None
self._script_changed_event.set()
self._send_immediate_transform(
movement_type="teleport",
is_moving=False,
movement_speed=0.0,
)
self._log(f"{self.name} triggered Teleport Test.")
def get_snapshot(self) -> dict[str, Any]:
with self._lock:
return {
@@ -218,8 +338,13 @@ class FakePlayerClient:
def _send_script_transform(self, connection: socket.socket, delta_seconds: float) -> float:
script = self._get_script()
if self._is_one_shot_active():
return SOCKET_TIMEOUT_SECONDS
if script == SCRIPT_WALK_TO_PLAYER:
return self._send_walk_to_player_transform(connection, delta_seconds)
if script == SCRIPT_WALK_CIRCLE:
return self._send_walk_circle_transform(connection, delta_seconds)
self._send_transform(connection, is_moving=False, movement_speed=0.0)
return IDLE_SEND_INTERVAL_SECONDS
@@ -283,12 +408,117 @@ class FakePlayerClient:
self._send_transform(connection, is_moving=is_moving, movement_speed=movement_speed)
return walk_interval if is_moving else IDLE_SEND_INTERVAL_SECONDS
def _send_transform(self, connection: socket.socket, *, is_moving: bool, movement_speed: float) -> None:
def _send_walk_circle_transform(self, connection: socket.socket, delta_seconds: float) -> float:
walk_interval = 1.0 / WALK_CIRCLE_SEND_RATE_HZ
step_delta = max(delta_seconds, walk_interval)
angular_speed = WALK_CIRCLE_SPEED / WALK_CIRCLE_RADIUS
with self._lock:
self.cell_id = IDLE_CELL_ID
self.worldspace_id = IDLE_WORLDSPACE_ID
center_x, center_y, center_z = self._walk_circle_center
self._walk_circle_phase += angular_speed * step_delta
phase = self._walk_circle_phase
new_x = center_x + (WALK_CIRCLE_RADIUS * math.cos(phase))
new_y = center_y + (WALK_CIRCLE_RADIUS * math.sin(phase))
new_z = center_z
tangent_x = -math.sin(phase)
tangent_y = math.cos(phase)
self.position = (new_x, new_y, new_z)
self.angle_z = math.atan2(tangent_x, tangent_y)
self._send_transform(connection, is_moving=True, movement_speed=WALK_CIRCLE_SPEED)
return walk_interval
def _run_jump_once(self, previous_script: str) -> None:
interval_seconds = 1.0 / JUMP_SEND_RATE_HZ
steps = max(2, int(JUMP_DURATION_SECONDS * JUMP_SEND_RATE_HZ))
with self._lock:
ground_x, ground_y, ground_z = self.position
try:
for step_index in range(steps + 1):
if self._stop_event.is_set():
return
progress = step_index / steps
height = JUMP_HEIGHT * (1.0 - abs((2.0 * progress) - 1.0))
with self._lock:
self.position = (ground_x, ground_y, ground_z + height)
self.is_jumping = True
self._send_immediate_transform(
movement_type="normal",
is_moving=False,
movement_speed=0.0,
is_jumping=True,
)
time.sleep(interval_seconds)
finally:
with self._lock:
self.position = (ground_x, ground_y, ground_z)
self.is_jumping = False
self._one_shot_active = False
if self.script == previous_script and previous_script not in (
SCRIPT_WALK_TO_PLAYER,
SCRIPT_WALK_CIRCLE,
SCRIPT_LEFT_CELL,
):
self.script = SCRIPT_IDLE
self._script_changed_event.set()
self._send_immediate_transform(
movement_type="normal",
is_moving=False,
movement_speed=0.0,
is_jumping=False,
)
def _send_immediate_transform(
self,
*,
movement_type: str,
is_moving: bool,
movement_speed: float,
is_jumping: bool | None = None,
) -> None:
with self._lock:
connection = self._socket
is_connected = self._status == STATUS_CONNECTED
if connection is None or not is_connected:
return
try:
self._send_transform(
connection,
movement_type=movement_type,
is_moving=is_moving,
movement_speed=movement_speed,
is_jumping=is_jumping,
)
except OSError as error:
if not self._stop_event.is_set():
self._set_error(str(error))
self._log(f"{self.name} error: {error}")
def _send_transform(
self,
connection: socket.socket,
*,
is_moving: bool,
movement_speed: float,
movement_type: str = "normal",
is_jumping: bool | None = None,
) -> None:
with self._lock:
x, y, z = self.position
angle_z = self.angle_z
cell_id = self.cell_id
worldspace_id = self.worldspace_id
is_sneaking = self.is_sneaking
jumping = self.is_jumping if is_jumping is None else is_jumping
packet = {
"type": "transform",
@@ -296,19 +526,20 @@ class FakePlayerClient:
"y": y,
"z": z,
"angleZ": angle_z,
"movementType": "normal",
"movementType": movement_type,
"cellId": cell_id,
"worldspaceId": worldspace_id,
"clientTime": time.time(),
"isMoving": is_moving,
"isSprinting": False,
"isSneaking": False,
"isJumping": False,
"isSneaking": is_sneaking,
"isJumping": jumping,
"weaponDrawn": False,
"movementSpeed": movement_speed,
}
encoded = json.dumps(packet, separators=(",", ":")).encode("utf-8") + b"\n"
connection.sendall(encoded)
with self._send_lock:
connection.sendall(encoded)
def _get_script(self) -> str:
with self._lock:
@@ -335,6 +566,10 @@ class FakePlayerClient:
with self._lock:
return self._status == STATUS_CONNECTED
def _is_one_shot_active(self) -> bool:
with self._lock:
return self._one_shot_active
def _set_error(self, message: str) -> None:
with self._lock:
self._status = STATUS_ERROR
@@ -422,6 +657,54 @@ class FakePlayerManager:
client.set_walk_to_player()
return True
def set_client_walk_circle(self, client_key: int) -> bool:
client = self._get_client(client_key)
if client is None:
return False
client.set_walk_circle()
return True
def trigger_client_jump_once(self, client_key: int) -> bool:
client = self._get_client(client_key)
if client is None:
return False
client.trigger_jump_once()
return True
def toggle_client_sneak(self, client_key: int) -> bool:
client = self._get_client(client_key)
if client is None:
return False
client.toggle_sneak()
return True
def trigger_client_leave_cell(self, client_key: int) -> bool:
client = self._get_client(client_key)
if client is None:
return False
client.trigger_leave_cell()
return True
def trigger_client_return_to_cell(self, client_key: int) -> bool:
client = self._get_client(client_key)
if client is None:
return False
client.trigger_return_to_cell()
return True
def trigger_client_teleport_test(self, client_key: int) -> bool:
client = self._get_client(client_key)
if client is None:
return False
client.trigger_teleport_test()
return True
def stop_all(self) -> None:
with self._lock:
clients = list(self._clients.values())