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:
+11
-5
@@ -22,8 +22,10 @@ both send and receive paths:
|
||||
8. The server broadcasts `disconnect` packets when clients disconnect.
|
||||
9. The plugin and fake client remove disconnected players from their remote
|
||||
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
|
||||
|
||||
@@ -36,10 +38,11 @@ The native plugin is responsible for:
|
||||
- Sending local player data to the server
|
||||
- Receiving server packets on a background thread
|
||||
- Storing assigned and remote `playerId` state internally
|
||||
- Moving the single placed test proxy actor from remote-player state on the game
|
||||
thread
|
||||
|
||||
Planned later:
|
||||
|
||||
- Applying remote player updates safely
|
||||
- Spawning or moving remote actors
|
||||
|
||||
### External Server
|
||||
@@ -86,17 +89,20 @@ Current approach:
|
||||
```text
|
||||
Networking thread receives packets
|
||||
Networking thread stores remote player state
|
||||
Game update reads remote player state
|
||||
Future game update applies actor movement
|
||||
Game update reads a copied remote player snapshot
|
||||
Game update moves the placed test proxy actor
|
||||
```
|
||||
|
||||
The networking receive thread does not directly modify Fallout 4 actors or game
|
||||
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
|
||||
|
||||
- Remote actor spawning
|
||||
- Multiple proxy actors
|
||||
- Interpolation or smoothing
|
||||
- Combat sync
|
||||
- Quest sync
|
||||
- Inventory sync
|
||||
|
||||
@@ -607,3 +607,40 @@ Use this format for future updates:
|
||||
- Only move the proxy when the remote player is in the same cell/worldspace.
|
||||
- Snap movement first.
|
||||
- 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`.
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
namespace F4T::ProxyActorController
|
||||
{
|
||||
// Temporary game-thread-only actor-control safety test. This validates that the
|
||||
// plugin can resolve and move the placed proxy before remote-player visual sync.
|
||||
// Game-thread-only actor controller for the single placed proxy test actor.
|
||||
// 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();
|
||||
}
|
||||
|
||||
+14
-6
@@ -35,14 +35,17 @@ This means the plugin should be able to:
|
||||
* Connect to the local server on `127.0.0.1:7777`
|
||||
* Send local transform 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
|
||||
|
||||
The plugin does not currently include:
|
||||
|
||||
* Actor sync
|
||||
* Creation Kit test cell integration
|
||||
* Dynamic actor spawning
|
||||
* Multiple proxy actors
|
||||
* Interpolation or smoothing
|
||||
* Remote player spawning
|
||||
* Combat 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 logs prefixed remote player updates from another connected client
|
||||
* [ ] 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
|
||||
|
||||
Remote actor spawning is intentionally not part of the current networking
|
||||
prototype. The receive loop stores plain remote-player state first so later
|
||||
actor work can be added from a stable data source.
|
||||
Remote actor spawning is intentionally not part of the current prototype. The
|
||||
receive loop stores plain remote-player state, and the game-thread proxy actor
|
||||
controller consumes a copied snapshot to move the one placed test proxy actor.
|
||||
|
||||
@@ -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;
|
||||
|
||||
+18
-3
@@ -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
|
||||
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
|
||||
|
||||
The plugin and fake client store remote players by `playerId`.
|
||||
@@ -41,7 +46,14 @@ serverTime
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -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 server broadcasts `disconnect` packets.
|
||||
- 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
|
||||
|
||||
- Add interpolation or smoothing after plugin-side state exists.
|
||||
- Spawn and move remote actors only after the receive loop is stable.
|
||||
- Add interpolation or smoothing after snap movement is proven 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
|
||||
|
||||
|
||||
@@ -22,18 +22,17 @@ def warn(message: str) -> None:
|
||||
|
||||
def update_remote_player_state(packet: dict[str, Any]) -> None:
|
||||
required_fields = (
|
||||
"type",
|
||||
"playerId",
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"angleZ",
|
||||
"movementType",
|
||||
"cellId",
|
||||
"worldspaceId",
|
||||
)
|
||||
missing_fields = [field_name for field_name in required_fields if field_name not in packet]
|
||||
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
|
||||
|
||||
try:
|
||||
@@ -52,11 +51,11 @@ def update_remote_player_state(packet: dict[str, Any]) -> None:
|
||||
"y": y,
|
||||
"z": z,
|
||||
"angleZ": angle_z,
|
||||
"movementType": packet["movementType"],
|
||||
"movementType": packet.get("movementType", "normal"),
|
||||
"cellId": packet["cellId"],
|
||||
"worldspaceId": packet["worldspaceId"],
|
||||
"clientTime": packet.get("clientTime"),
|
||||
"serverTime": packet.get("serverTime"),
|
||||
"worldspaceId": packet.get("worldspaceId", ""),
|
||||
"clientTime": packet.get("clientTime", 0.0),
|
||||
"serverTime": packet.get("serverTime", 0.0),
|
||||
"lastReceivedLocalTime": time.time(),
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user