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