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:
@@ -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()
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user