Move placed proxy from remote-player snapshot

Enable game-thread proxy actor movement driven by a copied remote-player snapshot and update related networking/validation, docs, and test client.

- Plugin: add F4T::ProxyActorController::Update() (alias UpdateSafetyTest preserved) and implement remote-state-driven movement for the single placed proxy (F4TProxyRemotePlayer01REF) in F4TTestCell01. Selects lowest available playerId, validates same-cell/worldspace for movement, and caches represented playerId. Adds helper functions (ParseHexFormId, IsRemotePlayerInSameLocation, SelectRemotePlayerToRepresent, MoveProxyToRemotePlayer) and improved throttled logging.
- Networking: tighten transform packet required-field checking (requires type, playerId, x, y, z, angleZ, cellId), add GetMissingRequiredTransformField, make movementType/worldspaceId/clientTime/serverTime optional with sensible defaults, and switch LogThrottledWarning to accept string_view.
- main.cpp: call ProxyActorController::Update on game-thread tasks.
- fake_client.py: mirror new packet validation and provide defaults for optional fields.
- Docs/dev-log/protocol/setup: update architecture, dev log, protocol, and setup notes to describe the single-proxy remote-state milestone, required packet fields, and testing checklist.

This change keeps networking thread limited to parsing/updating plain remote-player state and moves all actor access to the game-thread controller. Dynamic spawning, multiple proxies, and interpolation remain out of scope.
This commit is contained in:
2026-05-31 22:21:39 +12:00
parent 49819813da
commit 46b2fa8b9b
9 changed files with 376 additions and 48 deletions
+11 -5
View File
@@ -22,8 +22,10 @@ both send and receive paths:
8. The server broadcasts `disconnect` packets when clients disconnect. 8. The server broadcasts `disconnect` packets when clients disconnect.
9. The plugin and fake client remove disconnected players from their remote 9. The plugin and fake client remove disconnected players from their remote
player tables. player tables.
10. On the game-thread update path, the plugin reads a copied remote-player
snapshot and moves the single placed proxy actor in `F4TTestCell01`.
The Fallout 4 plugin still does not spawn or move remote actors. The Fallout 4 plugin still does not dynamically spawn remote actors.
## Main Components ## Main Components
@@ -36,10 +38,11 @@ The native plugin is responsible for:
- Sending local player data to the server - Sending local player data to the server
- Receiving server packets on a background thread - Receiving server packets on a background thread
- Storing assigned and remote `playerId` state internally - Storing assigned and remote `playerId` state internally
- Moving the single placed test proxy actor from remote-player state on the game
thread
Planned later: Planned later:
- Applying remote player updates safely
- Spawning or moving remote actors - Spawning or moving remote actors
### External Server ### External Server
@@ -86,17 +89,20 @@ Current approach:
```text ```text
Networking thread receives packets Networking thread receives packets
Networking thread stores remote player state Networking thread stores remote player state
Game update reads remote player state Game update reads a copied remote player snapshot
Future game update applies actor movement Game update moves the placed test proxy actor
``` ```
The networking receive thread does not directly modify Fallout 4 actors or game The networking receive thread does not directly modify Fallout 4 actors or game
objects. It parses newline-separated JSON, updates plain C++ remote-player objects. It parses newline-separated JSON, updates plain C++ remote-player
state, and leaves actor spawning or movement for a later game-thread milestone. state, and leaves actor access to `F4TProxyActorController` on the game-thread
update path.
## Out Of Scope For Current Prototype ## Out Of Scope For Current Prototype
- Remote actor spawning - Remote actor spawning
- Multiple proxy actors
- Interpolation or smoothing
- Combat sync - Combat sync
- Quest sync - Quest sync
- Inventory sync - Inventory sync
+38 -1
View File
@@ -606,4 +606,41 @@ Use this format for future updates:
- Replace local-player-offset movement with remote player state movement. - Replace local-player-offset movement with remote player state movement.
- Only move the proxy when the remote player is in the same cell/worldspace. - Only move the proxy when the remote player is in the same cell/worldspace.
- Snap movement first. - Snap movement first.
- Add smoothing/interpolation later. - Add smoothing/interpolation later.
## 2026-05-31
### What Changed
- Changed the proxy actor controller plan from local-player-offset movement to
remote-state-driven movement.
- The placed proxy actor `F4TProxyRemotePlayer01REF` now represents the lowest
available remote `playerId` from a copied remote-player snapshot.
- Proxy movement remains game-thread-only and throttled to roughly 5 Hz.
- The networking receive thread still only parses packets and updates plain
remote-player state.
- Relaxed receive-side transform validation so interior/test-cell packets only
require `cellId`; missing `worldspaceId` is stored as an empty string.
- Added a same-test-cell proxy movement log for `F4TTestCell01`.
### What Worked
- Existing proxy actor lookup and safe handle caching were preserved.
- The local-player-offset safety movement remains available as a debug fallback
mode in code.
### What Broke
- Not tested in-game yet.
### Notes
- The proxy is left wherever it last moved after a remote disconnect for now.
- Dynamic spawning, multiple proxy actors, interpolation, and gameplay sync are
still intentionally out of scope.
### Next Steps
- Build the plugin with `xmake build`.
- Run the single-client, two-client, and disconnect tests in `F4TTestCell01`.
+5 -2
View File
@@ -2,7 +2,10 @@
namespace F4T::ProxyActorController namespace F4T::ProxyActorController
{ {
// Temporary game-thread-only actor-control safety test. This validates that the // Game-thread-only actor controller for the single placed proxy test actor.
// plugin can resolve and move the placed proxy before remote-player visual sync. // Networking threads must only update plain remote-player state.
void Update();
// Backward-compatible alias for the earlier local-player-offset safety test entry point.
void UpdateSafetyTest(); void UpdateSafetyTest();
} }
+14 -6
View File
@@ -35,14 +35,17 @@ This means the plugin should be able to:
* Connect to the local server on `127.0.0.1:7777` * Connect to the local server on `127.0.0.1:7777`
* Send local transform packets * Send local transform packets
* Receive `welcome`, `transform`, and `disconnect` packets * Receive `welcome`, `transform`, and `disconnect` packets
* Store remote player state internally without spawning actors * Store remote player state internally
* Move the single placed test proxy actor from remote player state on the game
thread without dynamically spawning actors
## Not Included Yet ## Not Included Yet
The plugin does not currently include: The plugin does not currently include:
* Actor sync * Dynamic actor spawning
* Creation Kit test cell integration * Multiple proxy actors
* Interpolation or smoothing
* Remote player spawning * Remote player spawning
* Combat sync * Combat sync
* Quest sync * Quest sync
@@ -111,9 +114,14 @@ Use this checklist for the first plugin test:
* [ ] Plugin log shows `[LocalPlayerId=N] Assigned server playerId: N` * [ ] Plugin log shows `[LocalPlayerId=N] Assigned server playerId: N`
* [ ] Plugin logs prefixed remote player updates from another connected client * [ ] Plugin logs prefixed remote player updates from another connected client
* [ ] Plugin logs prefixed remote player removal after a disconnect * [ ] Plugin logs prefixed remote player removal after a disconnect
* [ ] `Fallout4Together_Test.esp` is enabled
* [ ] `coc F4TTestCell01` places the local player in the test cell
* [ ] `F4TProxyRemotePlayer01REF` resolves in the test cell
* [ ] With two Fallout 4 instances in `F4TTestCell01`, each instance moves its
local proxy to the other player's networked position
## Notes ## Notes
Remote actor spawning is intentionally not part of the current networking Remote actor spawning is intentionally not part of the current prototype. The
prototype. The receive loop stores plain remote-player state first so later receive loop stores plain remote-player state, and the game-thread proxy actor
actor work can be added from a stable data source. controller consumes a copied snapshot to move the one placed test proxy actor.
+58 -9
View File
@@ -153,7 +153,7 @@ namespace
return true; return true;
} }
void LogThrottledWarning(const std::string& a_key, const char* a_message) void LogThrottledWarning(const std::string& a_key, std::string_view a_message)
{ {
if (ShouldLogWarning(a_key)) { if (ShouldLogWarning(a_key)) {
LogWarningWithLocalPlayerPrefix(a_message); LogWarningWithLocalPlayerPrefix(a_message);
@@ -248,6 +248,27 @@ namespace
return true; return true;
} }
std::optional<std::string_view> GetMissingRequiredTransformField(const Json& a_packet)
{
constexpr std::array<std::string_view, 7> requiredFields{
"type",
"playerId",
"x",
"y",
"z",
"angleZ",
"cellId"
};
for (const auto fieldName : requiredFields) {
if (a_packet.find(fieldName) == a_packet.end()) {
return fieldName;
}
}
return std::nullopt;
}
void HandleWelcomePacket(const Json& a_packet) void HandleWelcomePacket(const Json& a_packet)
{ {
const auto playerId = ReadPlayerId(a_packet); const auto playerId = ReadPlayerId(a_packet);
@@ -262,17 +283,42 @@ namespace
void HandleTransformPacket(const Json& a_packet) void HandleTransformPacket(const Json& a_packet)
{ {
if (const auto missingField = GetMissingRequiredTransformField(a_packet)) {
LogThrottledWarning(
"transform_missing_" + std::string(*missingField),
std::format("Ignoring transform packet missing required field '{}'.", *missingField));
return;
}
const auto playerId = ReadPlayerId(a_packet); const auto playerId = ReadPlayerId(a_packet);
const auto x = ReadDouble(a_packet, "x"); const auto x = ReadDouble(a_packet, "x");
const auto y = ReadDouble(a_packet, "y"); const auto y = ReadDouble(a_packet, "y");
const auto z = ReadDouble(a_packet, "z"); const auto z = ReadDouble(a_packet, "z");
const auto angleZ = ReadDouble(a_packet, "angleZ"); const auto angleZ = ReadDouble(a_packet, "angleZ");
const auto movementType = ReadString(a_packet, "movementType");
const auto cellId = ReadString(a_packet, "cellId"); const auto cellId = ReadString(a_packet, "cellId");
const auto worldspaceId = ReadString(a_packet, "worldspaceId");
if (!playerId || !x || !y || !z || !angleZ || !movementType || !cellId || !worldspaceId) { if (!playerId) {
LogThrottledWarning("transform_invalid", "Ignoring transform packet with missing or invalid required fields."); LogThrottledWarning("transform_invalid_playerId", "Ignoring transform packet with invalid required field 'playerId'.");
return;
}
if (!x) {
LogThrottledWarning("transform_invalid_x", "Ignoring transform packet with invalid required field 'x'.");
return;
}
if (!y) {
LogThrottledWarning("transform_invalid_y", "Ignoring transform packet with invalid required field 'y'.");
return;
}
if (!z) {
LogThrottledWarning("transform_invalid_z", "Ignoring transform packet with invalid required field 'z'.");
return;
}
if (!angleZ) {
LogThrottledWarning("transform_invalid_angleZ", "Ignoring transform packet with invalid required field 'angleZ'.");
return;
}
if (!cellId) {
LogThrottledWarning("transform_invalid_cellId", "Ignoring transform packet with invalid required field 'cellId'.");
return; return;
} }
@@ -280,17 +326,20 @@ namespace
return; return;
} }
const auto movementType = ReadString(a_packet, "movementType").value_or("normal");
const auto worldspaceId = ReadString(a_packet, "worldspaceId").value_or("");
F4T::RemotePlayerState::RemotePlayerState remoteState{ F4T::RemotePlayerState::RemotePlayerState remoteState{
*playerId, *playerId,
static_cast<float>(*x), static_cast<float>(*x),
static_cast<float>(*y), static_cast<float>(*y),
static_cast<float>(*z), static_cast<float>(*z),
static_cast<float>(*angleZ), static_cast<float>(*angleZ),
*movementType, movementType,
*cellId, *cellId,
*worldspaceId, worldspaceId,
ReadDouble(a_packet, "clientTime"), ReadDouble(a_packet, "clientTime").value_or(0.0),
ReadDouble(a_packet, "serverTime"), ReadDouble(a_packet, "serverTime").value_or(0.0),
std::chrono::steady_clock::now() std::chrono::steady_clock::now()
}; };
+225 -14
View File
@@ -3,11 +3,17 @@
#include "F4TProxyActorController.h" #include "F4TProxyActorController.h"
#include "F4TNetworking.h" #include "F4TNetworking.h"
#include "F4TRemotePlayerState.h"
#include <algorithm>
#include <charconv>
#include <cstdint>
#include <format> #include <format>
#include <optional>
#include <string> #include <string>
#include <string_view> #include <string_view>
#include <unordered_map> #include <unordered_map>
#include <vector>
namespace namespace
{ {
@@ -21,6 +27,12 @@ namespace
constexpr auto kWarningLogInterval = 5s; constexpr auto kWarningLogInterval = 5s;
constexpr auto kNotInTestCellLogInterval = 30s; constexpr auto kNotInTestCellLogInterval = 30s;
enum class ProxyMovementMode
{
kSafetyOffsetTest,
kRemotePlayerStateTest
};
enum class ProxyLookupResult enum class ProxyLookupResult
{ {
kReferenceEditorId, kReferenceEditorId,
@@ -28,12 +40,19 @@ namespace
kFallbackFormId kFallbackFormId
}; };
constexpr auto kProxyMovementMode = ProxyMovementMode::kRemotePlayerStateTest;
RE::ObjectRefHandle g_proxyHandle; RE::ObjectRefHandle g_proxyHandle;
bool g_proxyResolvedLogged = false; bool g_proxyResolvedLogged = false;
bool g_proxyMovedLogged = false; bool g_safetyOffsetMovedLogged = false;
bool g_remoteStateMovementStartedLogged = false;
bool g_remoteStateMovedLogged = false;
auto g_lastMovementTime = std::chrono::steady_clock::time_point{}; auto g_lastMovementTime = std::chrono::steady_clock::time_point{};
std::optional<std::uint32_t> g_representedPlayerId;
std::unordered_map<std::string, std::chrono::steady_clock::time_point> g_lastWarningLogTimes; std::unordered_map<std::string, std::chrono::steady_clock::time_point> g_lastWarningLogTimes;
using RemotePlayer = F4T::RemotePlayerState::RemotePlayerState;
std::string GetLocalPlayerLogMessage(std::string_view a_message) std::string GetLocalPlayerLogMessage(std::string_view a_message)
{ {
return F4T::Networking::GetLocalPlayerLogPrefix() + " " + std::string(a_message); return F4T::Networking::GetLocalPlayerLogPrefix() + " " + std::string(a_message);
@@ -102,10 +121,101 @@ namespace
LogThrottledWarning( LogThrottledWarning(
"test_cell_unresolved", "test_cell_unresolved",
"Proxy actor safety test could not resolve F4TTestCell01; actor movement is disabled."); "Proxy actor controller could not resolve F4TTestCell01; actor movement is disabled.");
return false; return false;
} }
std::optional<std::uint32_t> ParseHexFormId(const std::string& a_formId)
{
if (a_formId.empty()) {
return std::nullopt;
}
std::uint32_t formId = 0;
const auto* begin = a_formId.data();
const auto* end = begin + a_formId.size();
const auto result = std::from_chars(begin, end, formId, 16);
if (result.ec != std::errc{} || result.ptr != end) {
return std::nullopt;
}
return formId;
}
std::optional<std::uint32_t> GetLocalWorldspaceId(
const RE::PlayerCharacter& a_player,
const RE::TESObjectCELL& a_parentCell)
{
if (a_player.cachedWorldspace) {
return a_player.cachedWorldspace->GetFormID();
}
if (a_parentCell.IsExterior() && a_parentCell.worldSpace) {
return a_parentCell.worldSpace->GetFormID();
}
return std::nullopt;
}
bool IsRemotePlayerInSameLocation(
const RemotePlayer& a_remotePlayer,
const RE::PlayerCharacter& a_player,
const RE::TESObjectCELL& a_parentCell)
{
const auto remoteCellId = ParseHexFormId(a_remotePlayer.cellId);
if (!remoteCellId) {
LogThrottledWarning(
"remote_cell_invalid_" + std::to_string(a_remotePlayer.playerId),
std::format(
"Remote player {} has an invalid cellId '{}'; proxy movement is skipped.",
a_remotePlayer.playerId,
a_remotePlayer.cellId));
return false;
}
const auto localCellId = a_parentCell.GetFormID();
if (localCellId != *remoteCellId) {
LogThrottledInfo(
"remote_cell_mismatch_" + std::to_string(a_remotePlayer.playerId),
std::format(
"Remote player {} is in a different cell: local={:08X}, remote={}. Proxy movement is skipped.",
a_remotePlayer.playerId,
localCellId,
a_remotePlayer.cellId));
return false;
}
// The current visual test is same-cell only. For the custom interior test cell,
// a matching cell ID is enough even when worldspace data is absent or stale.
if (IsTestCell(a_parentCell)) {
LogThrottledInfo(
"remote_same_test_cell_" + std::to_string(a_remotePlayer.playerId),
std::format(
"Remote player {} is in same test cell; moving proxy from remote state.",
a_remotePlayer.playerId));
return true;
}
const auto remoteWorldspaceId = ParseHexFormId(a_remotePlayer.worldspaceId);
const auto localWorldspaceId = GetLocalWorldspaceId(a_player, a_parentCell);
if (!localWorldspaceId && !remoteWorldspaceId) {
return true;
}
if (!localWorldspaceId || !remoteWorldspaceId || *localWorldspaceId != *remoteWorldspaceId) {
LogThrottledInfo(
"remote_worldspace_mismatch_" + std::to_string(a_remotePlayer.playerId),
std::format(
"Remote player {} is in a different worldspace: local={}, remote={}. Proxy movement is skipped.",
a_remotePlayer.playerId,
localWorldspaceId ? std::format("{:08X}", *localWorldspaceId) : "unavailable",
a_remotePlayer.worldspaceId.empty() ? "unavailable" : a_remotePlayer.worldspaceId));
return false;
}
return true;
}
bool IsValidProxyActor(const RE::Actor& a_proxy, const RE::TESObjectCELL& a_expectedCell, const RE::PlayerCharacter& a_player) bool IsValidProxyActor(const RE::Actor& a_proxy, const RE::TESObjectCELL& a_expectedCell, const RE::PlayerCharacter& a_player)
{ {
return std::addressof(a_proxy) != std::addressof(a_player) && a_proxy.GetParentCell() == std::addressof(a_expectedCell); return std::addressof(a_proxy) != std::addressof(a_player) && a_proxy.GetParentCell() == std::addressof(a_expectedCell);
@@ -244,14 +354,14 @@ namespace
if (!proxy) { if (!proxy) {
LogThrottledWarning( LogThrottledWarning(
"proxy_missing", "proxy_missing",
"Proxy actor safety test could not find F4TProxyRemotePlayer01REF in F4TTestCell01."); "Proxy actor controller could not find F4TProxyRemotePlayer01REF in F4TTestCell01.");
return nullptr; return nullptr;
} }
g_proxyHandle = proxy->GetHandle(); g_proxyHandle = proxy->GetHandle();
if (!g_proxyResolvedLogged) { if (!g_proxyResolvedLogged) {
LogInfoWithLocalPlayerPrefix(std::format( LogInfoWithLocalPlayerPrefix(std::format(
"Proxy actor safety test resolved {} by {} as form {:08X}.", "Proxy actor controller resolved {} by {} as form {:08X}.",
kProxyEditorId, kProxyEditorId,
GetLookupResultDescription(lookupResult), GetLookupResultDescription(lookupResult),
proxy->GetFormID())); proxy->GetFormID()));
@@ -272,6 +382,63 @@ namespace
return true; return true;
} }
std::optional<RemotePlayer> SelectRemotePlayerToRepresent(std::vector<RemotePlayer> a_remotePlayers)
{
if (g_representedPlayerId) {
const auto representedPlayerStillExists = std::ranges::any_of(
a_remotePlayers,
[](const RemotePlayer& a_remotePlayer) {
return a_remotePlayer.playerId == *g_representedPlayerId;
});
if (!representedPlayerStillExists) {
LogInfoWithLocalPlayerPrefix(std::format(
"Represented remote player {} disconnected or disappeared from state; stopping proxy movement. TODO: hide or disable the proxy actor after disconnect.",
*g_representedPlayerId));
g_representedPlayerId.reset();
g_remoteStateMovedLogged = false;
}
}
if (a_remotePlayers.empty()) {
LogThrottledInfo(
"no_remote_players",
"No suitable remote player is available for proxy movement; leaving proxy actor idle.");
return std::nullopt;
}
auto selectedPlayer = std::ranges::min_element(
a_remotePlayers,
[](const RemotePlayer& a_left, const RemotePlayer& a_right) {
return a_left.playerId < a_right.playerId;
});
if (selectedPlayer == a_remotePlayers.end()) {
return std::nullopt;
}
if (a_remotePlayers.size() > 1) {
LogThrottledInfo(
"multiple_remote_players",
std::format(
"Multiple remote players are available; representing lowest playerId {} for now. TODO: add one proxy actor per remote player later.",
selectedPlayer->playerId));
}
if (g_representedPlayerId != selectedPlayer->playerId) {
g_representedPlayerId = selectedPlayer->playerId;
g_remoteStateMovedLogged = false;
if (!g_remoteStateMovementStartedLogged) {
LogInfoWithLocalPlayerPrefix("Remote-state-driven proxy movement started.");
g_remoteStateMovementStartedLogged = true;
}
LogInfoWithLocalPlayerPrefix(std::format(
"Proxy actor {} is representing remote player {}.",
kProxyEditorId,
selectedPlayer->playerId));
}
return *selectedPlayer;
}
void MoveProxyToPlayerOffset(RE::Actor& a_proxy, const RE::PlayerCharacter& a_player) void MoveProxyToPlayerOffset(RE::Actor& a_proxy, const RE::PlayerCharacter& a_player)
{ {
auto targetPosition = a_player.GetPosition(); auto targetPosition = a_player.GetPosition();
@@ -280,48 +447,92 @@ namespace
a_proxy.SetPosition(targetPosition, true); a_proxy.SetPosition(targetPosition, true);
a_proxy.SetHeading(a_player.data.angle.z); a_proxy.SetHeading(a_player.data.angle.z);
if (!g_proxyMovedLogged) { if (!g_safetyOffsetMovedLogged) {
LogInfoWithLocalPlayerPrefix(std::format( LogInfoWithLocalPlayerPrefix(std::format(
"Proxy actor safety test moved {} to player position plus X offset {:.2f}.", "Proxy actor safety test moved {} to player position plus X offset {:.2f}.",
kProxyEditorId, kProxyEditorId,
kProxyOffsetX)); kProxyOffsetX));
g_proxyMovedLogged = true; g_safetyOffsetMovedLogged = true;
}
}
void MoveProxyToRemotePlayer(RE::Actor& a_proxy, const RemotePlayer& a_remotePlayer)
{
auto targetPosition = a_proxy.GetPosition();
targetPosition.x = a_remotePlayer.x;
targetPosition.y = a_remotePlayer.y;
targetPosition.z = a_remotePlayer.z;
a_proxy.SetPosition(targetPosition, true);
a_proxy.SetHeading(a_remotePlayer.angleZ);
if (!g_remoteStateMovedLogged) {
LogInfoWithLocalPlayerPrefix(std::format(
"Moved proxy actor {} from remote player {} state for the first time: X={:.2f}, Y={:.2f}, Z={:.2f}, AngleZ={:.2f}.",
kProxyEditorId,
a_remotePlayer.playerId,
a_remotePlayer.x,
a_remotePlayer.y,
a_remotePlayer.z,
a_remotePlayer.angleZ));
g_remoteStateMovedLogged = true;
} }
} }
} }
namespace F4T::ProxyActorController namespace F4T::ProxyActorController
{ {
void UpdateSafetyTest() void Update()
{ {
// This temporary safety test intentionally ignores remote-player network state.
// Later visual-sync milestones can replace the local-player offset with a
// game-thread snapshot of remote transforms.
auto* player = RE::PlayerCharacter::GetSingleton(); auto* player = RE::PlayerCharacter::GetSingleton();
if (!player) { if (!player) {
LogThrottledWarning("player_missing", "Proxy actor safety test skipped: local player is not available yet."); LogThrottledWarning("player_missing", "Proxy actor controller skipped: local player is not available yet.");
return; return;
} }
auto* parentCell = player->GetParentCell(); auto* parentCell = player->GetParentCell();
if (!parentCell) { if (!parentCell) {
LogThrottledWarning("cell_missing", "Proxy actor safety test skipped: local player parent cell is unavailable."); LogThrottledWarning("cell_missing", "Proxy actor controller skipped: local player parent cell is unavailable.");
return; return;
} }
if (!IsTestCell(*parentCell)) { if (!IsTestCell(*parentCell)) {
LogThrottledWarning( LogThrottledWarning(
"not_in_test_cell", "not_in_test_cell",
"Proxy actor safety test skipped: player is not in F4TTestCell01.", "Proxy actor controller skipped: player is not in F4TTestCell01.",
kNotInTestCellLogInterval); kNotInTestCellLogInterval);
return; return;
} }
if constexpr (kProxyMovementMode == ProxyMovementMode::kSafetyOffsetTest) {
auto* proxy = ResolveProxy(*parentCell, *player);
if (!proxy || !ShouldMoveProxy()) {
return;
}
MoveProxyToPlayerOffset(*proxy, *player);
return;
}
auto selectedRemotePlayer = SelectRemotePlayerToRepresent(F4T::RemotePlayerState::GetRemotePlayerSnapshot());
if (!selectedRemotePlayer) {
return;
}
auto* proxy = ResolveProxy(*parentCell, *player); auto* proxy = ResolveProxy(*parentCell, *player);
if (!proxy || !ShouldMoveProxy()) { if (!proxy || !ShouldMoveProxy()) {
return; return;
} }
MoveProxyToPlayerOffset(*proxy, *player); if (!IsRemotePlayerInSameLocation(*selectedRemotePlayer, *player, *parentCell)) {
return;
}
MoveProxyToRemotePlayer(*proxy, *selectedRemotePlayer);
}
void UpdateSafetyTest()
{
Update();
} }
} }
+1 -1
View File
@@ -232,7 +232,7 @@ namespace
// check on the game thread after load, while the logging gate keeps updates readable. // check on the game thread after load, while the logging gate keeps updates readable.
taskInterface->AddTaskPermanent([]() { taskInterface->AddTaskPermanent([]() {
CheckAndLogPlayerPositionChange(); CheckAndLogPlayerPositionChange();
F4T::ProxyActorController::UpdateSafetyTest(); F4T::ProxyActorController::Update();
}); });
pollingStarted = true; pollingStarted = true;
+18 -3
View File
@@ -21,6 +21,11 @@ remote `playerId`, and removes remote state when disconnect packets arrive.
`server/fake_client.py` remains a lightweight test receiver for the same packet `server/fake_client.py` remains a lightweight test receiver for the same packet
lifecycle. lifecycle.
The plugin's game-thread proxy actor controller can now read a copied snapshot
of this remote-player state and move the single placed test proxy actor
`F4TProxyRemotePlayer01REF` in `F4TTestCell01`. The networking thread still does
not touch Fallout 4 actors or references.
## Current Remote Player State Model ## Current Remote Player State Model
The plugin and fake client store remote players by `playerId`. The plugin and fake client store remote players by `playerId`.
@@ -41,7 +46,14 @@ serverTime
lastReceivedLocalTime lastReceivedLocalTime
``` ```
Required transform packet fields are `type`, `playerId`, `x`, `y`, `z`,
`angleZ`, and `cellId`. `worldspaceId`, `movementType`, `clientTime`, and
`serverTime` are optional; interior test cells may omit `worldspaceId`.
This model proves the data shape and lifecycle before spawning remote actors. This model proves the data shape and lifecycle before spawning remote actors.
For the current visual milestone, the controller represents only one remote
player and chooses the lowest available `playerId` if more than one remote
player exists.
## Implemented ## Implemented
@@ -58,13 +70,16 @@ This model proves the data shape and lifecycle before spawning remote actors.
- The fake client stores remote player state by `playerId`. - The fake client stores remote player state by `playerId`.
- The server broadcasts `disconnect` packets. - The server broadcasts `disconnect` packets.
- The fake client removes disconnected remote players from its state table. - The fake client removes disconnected remote players from its state table.
- The plugin moves the single placed test proxy actor from a thread-safe
remote-player snapshot on the game-thread update path.
No Fallout 4 remote actor is spawned yet. No Fallout 4 remote actor is dynamically spawned yet.
## Planned Later ## Planned Later
- Add interpolation or smoothing after plugin-side state exists. - Add interpolation or smoothing after snap movement is proven stable.
- Spawn and move remote actors only after the receive loop is stable. - Add one proxy actor per remote player after the single-proxy test is stable.
- Dynamically spawn remote actors only after placed-proxy movement is stable.
## Not Required Yet ## Not Required Yet
+6 -7
View File
@@ -22,18 +22,17 @@ def warn(message: str) -> None:
def update_remote_player_state(packet: dict[str, Any]) -> None: def update_remote_player_state(packet: dict[str, Any]) -> None:
required_fields = ( required_fields = (
"type",
"playerId", "playerId",
"x", "x",
"y", "y",
"z", "z",
"angleZ", "angleZ",
"movementType",
"cellId", "cellId",
"worldspaceId",
) )
missing_fields = [field_name for field_name in required_fields if field_name not in packet] missing_fields = [field_name for field_name in required_fields if field_name not in packet]
if missing_fields: if missing_fields:
warn(f"Ignoring transform packet missing {', '.join(missing_fields)}: {packet}") warn(f"Ignoring transform packet missing required field {missing_fields[0]}: {packet}")
return return
try: try:
@@ -52,11 +51,11 @@ def update_remote_player_state(packet: dict[str, Any]) -> None:
"y": y, "y": y,
"z": z, "z": z,
"angleZ": angle_z, "angleZ": angle_z,
"movementType": packet["movementType"], "movementType": packet.get("movementType", "normal"),
"cellId": packet["cellId"], "cellId": packet["cellId"],
"worldspaceId": packet["worldspaceId"], "worldspaceId": packet.get("worldspaceId", ""),
"clientTime": packet.get("clientTime"), "clientTime": packet.get("clientTime", 0.0),
"serverTime": packet.get("serverTime"), "serverTime": packet.get("serverTime", 0.0),
"lastReceivedLocalTime": time.time(), "lastReceivedLocalTime": time.time(),
} }