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
+58 -9
View File
@@ -153,7 +153,7 @@ namespace
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)) {
LogWarningWithLocalPlayerPrefix(a_message);
@@ -248,6 +248,27 @@ namespace
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)
{
const auto playerId = ReadPlayerId(a_packet);
@@ -262,17 +283,42 @@ namespace
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 x = ReadDouble(a_packet, "x");
const auto y = ReadDouble(a_packet, "y");
const auto z = ReadDouble(a_packet, "z");
const auto angleZ = ReadDouble(a_packet, "angleZ");
const auto movementType = ReadString(a_packet, "movementType");
const auto cellId = ReadString(a_packet, "cellId");
const auto worldspaceId = ReadString(a_packet, "worldspaceId");
if (!playerId || !x || !y || !z || !angleZ || !movementType || !cellId || !worldspaceId) {
LogThrottledWarning("transform_invalid", "Ignoring transform packet with missing or invalid required fields.");
if (!playerId) {
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;
}
@@ -280,17 +326,20 @@ namespace
return;
}
const auto movementType = ReadString(a_packet, "movementType").value_or("normal");
const auto worldspaceId = ReadString(a_packet, "worldspaceId").value_or("");
F4T::RemotePlayerState::RemotePlayerState remoteState{
*playerId,
static_cast<float>(*x),
static_cast<float>(*y),
static_cast<float>(*z),
static_cast<float>(*angleZ),
*movementType,
movementType,
*cellId,
*worldspaceId,
ReadDouble(a_packet, "clientTime"),
ReadDouble(a_packet, "serverTime"),
worldspaceId,
ReadDouble(a_packet, "clientTime").value_or(0.0),
ReadDouble(a_packet, "serverTime").value_or(0.0),
std::chrono::steady_clock::now()
};
+225 -14
View File
@@ -3,11 +3,17 @@
#include "F4TProxyActorController.h"
#include "F4TNetworking.h"
#include "F4TRemotePlayerState.h"
#include <algorithm>
#include <charconv>
#include <cstdint>
#include <format>
#include <optional>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
namespace
{
@@ -21,6 +27,12 @@ namespace
constexpr auto kWarningLogInterval = 5s;
constexpr auto kNotInTestCellLogInterval = 30s;
enum class ProxyMovementMode
{
kSafetyOffsetTest,
kRemotePlayerStateTest
};
enum class ProxyLookupResult
{
kReferenceEditorId,
@@ -28,12 +40,19 @@ namespace
kFallbackFormId
};
constexpr auto kProxyMovementMode = ProxyMovementMode::kRemotePlayerStateTest;
RE::ObjectRefHandle g_proxyHandle;
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{};
std::optional<std::uint32_t> g_representedPlayerId;
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)
{
return F4T::Networking::GetLocalPlayerLogPrefix() + " " + std::string(a_message);
@@ -102,10 +121,101 @@ namespace
LogThrottledWarning(
"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;
}
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)
{
return std::addressof(a_proxy) != std::addressof(a_player) && a_proxy.GetParentCell() == std::addressof(a_expectedCell);
@@ -244,14 +354,14 @@ namespace
if (!proxy) {
LogThrottledWarning(
"proxy_missing",
"Proxy actor safety test could not find F4TProxyRemotePlayer01REF in F4TTestCell01.");
"Proxy actor controller could not find F4TProxyRemotePlayer01REF in F4TTestCell01.");
return nullptr;
}
g_proxyHandle = proxy->GetHandle();
if (!g_proxyResolvedLogged) {
LogInfoWithLocalPlayerPrefix(std::format(
"Proxy actor safety test resolved {} by {} as form {:08X}.",
"Proxy actor controller resolved {} by {} as form {:08X}.",
kProxyEditorId,
GetLookupResultDescription(lookupResult),
proxy->GetFormID()));
@@ -272,6 +382,63 @@ namespace
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)
{
auto targetPosition = a_player.GetPosition();
@@ -280,48 +447,92 @@ namespace
a_proxy.SetPosition(targetPosition, true);
a_proxy.SetHeading(a_player.data.angle.z);
if (!g_proxyMovedLogged) {
if (!g_safetyOffsetMovedLogged) {
LogInfoWithLocalPlayerPrefix(std::format(
"Proxy actor safety test moved {} to player position plus X offset {:.2f}.",
kProxyEditorId,
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
{
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();
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;
}
auto* parentCell = player->GetParentCell();
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;
}
if (!IsTestCell(*parentCell)) {
LogThrottledWarning(
"not_in_test_cell",
"Proxy actor safety test skipped: player is not in F4TTestCell01.",
"Proxy actor controller skipped: player is not in F4TTestCell01.",
kNotInTestCellLogInterval);
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);
if (!proxy || !ShouldMoveProxy()) {
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.
taskInterface->AddTaskPermanent([]() {
CheckAndLogPlayerPositionChange();
F4T::ProxyActorController::UpdateSafetyTest();
F4T::ProxyActorController::Update();
});
pollingStarted = true;