Hold proxy in hidden position when no remote

Add a temporary in-cell holding fallback for the single placed proxy actor when no valid same-cell remote player is available. Introduces kProxyHiddenHoldingPosition, ProxyLifecycleState, RemotePlayerSelection, MoveProxyToHoldingPosition and RestoreProxyForRemotePlayer, plus selection/state-tracking and throttled logs. The controller now moves the proxy to the hidden position on disconnect, disappearance, or cell-mismatch and snaps it back when a valid same-cell remote appears. Documentation and dev log updated to describe the lifecycle and rationale (docs/architecture.md, docs/dev-log.md, protocol/player-sync.md).
This commit is contained in:
2026-06-02 13:59:32 +12:00
parent d36140c76e
commit a0563a6849
4 changed files with 233 additions and 31 deletions
+6
View File
@@ -30,6 +30,10 @@ both send and receive paths:
Proxy visual updates are independent from local movement log throttling:
normal movement is smoothed toward the latest target, while cell changes,
worldspace changes, and teleports snap directly.
12. When the selected remote player is missing, disconnected, or not in the same
test cell, the game-thread proxy controller moves the single placed proxy to
a hidden holding position inside `F4TTestCell01` until a valid same-cell
remote player is available again.
The Fallout 4 plugin still does not dynamically spawn remote actors.
@@ -48,6 +52,8 @@ The native plugin is responsible for:
- Storing assigned and remote `playerId` state internally
- Moving the single placed test proxy actor from remote-player state on the game
thread
- Holding that proxy actor at a hidden in-cell position when no valid same-cell
remote player should be represented
Planned later:
+45
View File
@@ -837,6 +837,51 @@ Player position: X=2048.00, Y=2048.00, Z=0.00, AngleZ=0.00
---
## 2026-06-02 - Proxy Lifecycle Holding
### What Changed
- Added controller-local lifecycle state for the single placed proxy actor.
- Added a temporary hidden holding position inside `F4TTestCell01` for cases
where no valid same-cell remote player should be represented.
- Moved the proxy to the holding position when the represented remote player
disconnects, disappears from remote-player state, or leaves the local test
cell.
- Restored representation by snapping the proxy to a valid same-cell remote
player once before resuming existing smooth movement.
- Kept disconnect, no-remote-player, and cell-mismatch logs transition-based or
throttled so the log does not spam every update.
### What Worked
- Actor/reference access remains inside `F4TProxyActorController` on the
game-thread update path.
- The networking protocol, Python server, and fake client remain unchanged.
- Existing smooth movement, special movement snapping, and sneak-state
observation remain in place for valid same-cell remote players.
### What Broke
- Nothing recorded during implementation.
### Notes
- This milestone deliberately does not call `Disable()`, `Enable()`,
`SetAlpha()`, invisibility APIs, or animation graph APIs.
- Dynamic spawning, multiple proxy actors, combat, inventory, quest, settlement,
weapon/projectile, interaction, and animation sync remain out of scope.
- The holding position is a prototype-safe fallback until a hide/disable path is
validated for the persistent placed proxy reference.
### Next Steps
- Test single-client, two-client, disconnect, cell-leave, and return/reconnect
flows in `F4TTestCell01`.
- If the holding fallback proves stable, separately validate whether a true
hide/disable path is safe for the placed proxy reference.
---
## Entry Template
Use this format for future updates:
+174 -30
View File
@@ -24,10 +24,16 @@ namespace
constexpr RE::TESFormID kProxyFallbackLocalFormId = 0x0020A2;
constexpr auto kProxyOffsetX = 150.0F;
constexpr float kProxyPositionLerpAlpha = 0.15F;
// Temporary single-proxy lifecycle fallback. Keep the placed proxy inside the
// loaded test cell instead of disabling a persistent reference before that path
// is validated in-game.
constexpr RE::NiPoint3 kProxyHiddenHoldingPosition{ -100000.0F, -100000.0F, -100000.0F };
constexpr auto kSafetyOffsetMovementInterval = 200ms;
constexpr auto kWarningLogInterval = 5s;
constexpr auto kNotInTestCellLogInterval = 30s;
using RemotePlayer = F4T::RemotePlayerState::RemotePlayerState;
enum class ProxyMovementMode
{
kSafetyOffsetTest,
@@ -41,6 +47,22 @@ namespace
kFallbackFormId
};
enum class ProxyLifecycleState
{
kNoRemotePlayer,
kRepresentingRemotePlayer,
kRemotePlayerDisconnected,
kRemotePlayerLeftCell,
kProxyHiddenOrIdle
};
struct RemotePlayerSelection
{
std::optional<RemotePlayer> selectedPlayer;
std::optional<std::uint32_t> disappearedPlayerId;
bool selectedPlayerChanged{ false };
};
constexpr auto kProxyMovementMode = ProxyMovementMode::kRemotePlayerStateTest;
RE::ObjectRefHandle g_proxyHandle;
@@ -51,13 +73,14 @@ namespace
bool g_proxySmoothingEnabledLogged = false;
bool g_firstSmoothedMovementLogged = false;
auto g_lastSafetyOffsetMovementTime = std::chrono::steady_clock::time_point{};
ProxyLifecycleState g_proxyLifecycleState = ProxyLifecycleState::kNoRemotePlayer;
std::optional<ProxyLifecycleState> g_proxyHoldReason;
std::optional<std::uint32_t> g_proxyHoldPlayerId;
std::optional<std::uint32_t> g_representedPlayerId;
std::optional<std::uint32_t> g_lastObservedSneakPlayerId;
std::optional<bool> g_lastObservedSneakState;
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);
@@ -107,6 +130,91 @@ namespace
}
}
void ResetRemoteMovementStateTracking();
bool IsProxyHeldFor(ProxyLifecycleState a_reason, std::optional<std::uint32_t> a_playerId)
{
return g_proxyLifecycleState == ProxyLifecycleState::kProxyHiddenOrIdle &&
g_proxyHoldReason == a_reason &&
g_proxyHoldPlayerId == a_playerId;
}
void LogProxyStillHeld(ProxyLifecycleState a_reason, std::optional<std::uint32_t> a_playerId)
{
switch (a_reason) {
case ProxyLifecycleState::kNoRemotePlayer:
LogThrottledInfo(
"proxy_held_no_remote_player",
"No suitable remote player is available; proxy actor is already at the holding position.");
break;
case ProxyLifecycleState::kRemotePlayerDisconnected:
if (a_playerId) {
LogThrottledInfo(
"proxy_held_disconnected_" + std::to_string(*a_playerId),
std::format(
"Remote player {} is still absent from state; proxy actor remains at the holding position.",
*a_playerId));
}
break;
case ProxyLifecycleState::kRemotePlayerLeftCell:
if (a_playerId) {
LogThrottledInfo(
"proxy_held_left_cell_" + std::to_string(*a_playerId),
std::format(
"Remote player {} is still outside the local test cell; proxy actor remains at the holding position.",
*a_playerId));
}
break;
default:
break;
}
}
void MoveProxyToHoldingPosition(
RE::Actor& a_proxy,
ProxyLifecycleState a_reason,
std::optional<std::uint32_t> a_playerId = std::nullopt)
{
if (IsProxyHeldFor(a_reason, a_playerId)) {
LogProxyStillHeld(a_reason, a_playerId);
return;
}
a_proxy.SetPosition(kProxyHiddenHoldingPosition, true);
g_proxyLifecycleState = ProxyLifecycleState::kProxyHiddenOrIdle;
g_proxyHoldReason = a_reason;
g_proxyHoldPlayerId = a_playerId;
g_remoteStateMovedLogged = false;
g_firstSmoothedMovementLogged = false;
ResetRemoteMovementStateTracking();
switch (a_reason) {
case ProxyLifecycleState::kNoRemotePlayer:
LogInfoWithLocalPlayerPrefix(std::format(
"No suitable remote player is available; moving proxy actor {} to holding position.",
kProxyEditorId));
break;
case ProxyLifecycleState::kRemotePlayerDisconnected:
if (a_playerId) {
LogInfoWithLocalPlayerPrefix(std::format(
"Remote player {} disappeared; moving proxy actor {} to holding position.",
*a_playerId,
kProxyEditorId));
}
break;
case ProxyLifecycleState::kRemotePlayerLeftCell:
if (a_playerId) {
LogInfoWithLocalPlayerPrefix(std::format(
"Remote player {} is not in the same cell; moving proxy actor {} to holding position.",
*a_playerId,
kProxyEditorId));
}
break;
default:
break;
}
}
float Lerp(float a_current, float a_target, float a_alpha)
{
return a_current + ((a_target - a_current) * a_alpha);
@@ -413,8 +521,10 @@ namespace
return true;
}
std::optional<RemotePlayer> SelectRemotePlayerToRepresent(std::vector<RemotePlayer> a_remotePlayers)
RemotePlayerSelection SelectRemotePlayerToRepresent(std::vector<RemotePlayer> a_remotePlayers)
{
RemotePlayerSelection selection;
if (g_representedPlayerId) {
const auto representedPlayerStillExists = std::ranges::any_of(
a_remotePlayers,
@@ -422,9 +532,7 @@ namespace
return a_remotePlayer.playerId == *g_representedPlayerId;
});
if (!representedPlayerStillExists) {
LogInfoWithLocalPlayerPrefix(std::format(
"Represented remote player {} disconnected or disappeared from state; leaving proxy actor idle at its last position. TODO: hide or disable the proxy actor after disconnect.",
*g_representedPlayerId));
selection.disappearedPlayerId = g_representedPlayerId;
g_representedPlayerId.reset();
g_remoteStateMovedLogged = false;
g_firstSmoothedMovementLogged = false;
@@ -433,10 +541,7 @@ namespace
}
if (a_remotePlayers.empty()) {
LogThrottledInfo(
"no_remote_players",
"No suitable remote player is available for proxy movement; leaving proxy actor idle.");
return std::nullopt;
return selection;
}
auto selectedPlayer = std::ranges::min_element(
@@ -445,7 +550,7 @@ namespace
return a_left.playerId < a_right.playerId;
});
if (selectedPlayer == a_remotePlayers.end()) {
return std::nullopt;
return selection;
}
if (a_remotePlayers.size() > 1) {
@@ -457,6 +562,7 @@ namespace
}
if (g_representedPlayerId != selectedPlayer->playerId) {
selection.selectedPlayerChanged = true;
g_representedPlayerId = selectedPlayer->playerId;
g_remoteStateMovedLogged = false;
g_firstSmoothedMovementLogged = false;
@@ -471,13 +577,10 @@ namespace
kProxyPositionLerpAlpha));
g_proxySmoothingEnabledLogged = true;
}
LogInfoWithLocalPlayerPrefix(std::format(
"Proxy actor {} is representing remote player {}.",
kProxyEditorId,
selectedPlayer->playerId));
}
return *selectedPlayer;
selection.selectedPlayer = *selectedPlayer;
return selection;
}
void ApplyRemoteMovementStateToProxy(RE::Actor& a_proxy, const RemotePlayer& a_remotePlayer)
@@ -527,6 +630,28 @@ namespace
}
}
void RestoreProxyForRemotePlayer(RE::Actor& a_proxy, const RemotePlayer& a_remotePlayer)
{
RE::NiPoint3 targetPosition{};
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);
g_proxyLifecycleState = ProxyLifecycleState::kRepresentingRemotePlayer;
g_proxyHoldReason.reset();
g_proxyHoldPlayerId.reset();
LogInfoWithLocalPlayerPrefix(std::format(
"Proxy actor {} resumed representing remote player {} at X={:.2f}, Y={:.2f}, Z={:.2f}.",
kProxyEditorId,
a_remotePlayer.playerId,
a_remotePlayer.x,
a_remotePlayer.y,
a_remotePlayer.z));
}
void MoveProxyToRemotePlayer(RE::Actor& a_proxy, const RemotePlayer& a_remotePlayer)
{
// TODO: Later animation/behavior milestones can read movement state from
@@ -614,28 +739,47 @@ namespace F4T::ProxyActorController
return;
}
auto selectedRemotePlayer = SelectRemotePlayerToRepresent(F4T::RemotePlayerState::GetRemotePlayerSnapshot());
if (!selectedRemotePlayer) {
return;
}
auto* proxy = ResolveProxy(*parentCell, *player);
if (!proxy) {
return;
}
if (!IsRemotePlayerInSameLocation(*selectedRemotePlayer, *player, *parentCell)) {
LogThrottledInfo(
"proxy_idle_cell_mismatch_" + std::to_string(selectedRemotePlayer->playerId),
std::format(
"Proxy actor {} is idle because represented remote player {} is not in the same cell. TODO: hide or disable the proxy actor after cell leave.",
kProxyEditorId,
selectedRemotePlayer->playerId));
const auto selection = SelectRemotePlayerToRepresent(F4T::RemotePlayerState::GetRemotePlayerSnapshot());
if (selection.disappearedPlayerId) {
MoveProxyToHoldingPosition(
*proxy,
ProxyLifecycleState::kRemotePlayerDisconnected,
selection.disappearedPlayerId);
}
if (!selection.selectedPlayer) {
if (!selection.disappearedPlayerId) {
if (g_proxyLifecycleState == ProxyLifecycleState::kProxyHiddenOrIdle) {
LogProxyStillHeld(
g_proxyHoldReason.value_or(ProxyLifecycleState::kNoRemotePlayer),
g_proxyHoldPlayerId);
} else {
MoveProxyToHoldingPosition(*proxy, ProxyLifecycleState::kNoRemotePlayer);
}
}
return;
}
ApplyRemoteMovementStateToProxy(*proxy, *selectedRemotePlayer);
MoveProxyToRemotePlayer(*proxy, *selectedRemotePlayer);
if (!IsRemotePlayerInSameLocation(*selection.selectedPlayer, *player, *parentCell)) {
MoveProxyToHoldingPosition(
*proxy,
ProxyLifecycleState::kRemotePlayerLeftCell,
selection.selectedPlayer->playerId);
return;
}
if (selection.selectedPlayerChanged || g_proxyLifecycleState != ProxyLifecycleState::kRepresentingRemotePlayer) {
RestoreProxyForRemotePlayer(*proxy, *selection.selectedPlayer);
return;
}
ApplyRemoteMovementStateToProxy(*proxy, *selection.selectedPlayer);
MoveProxyToRemotePlayer(*proxy, *selection.selectedPlayer);
}
void UpdateSafetyTest()
+8 -1
View File
@@ -27,7 +27,10 @@ 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.
not touch Fallout 4 actors or references. If no valid same-cell remote player is
available, the controller moves the placed proxy to a hidden holding position
inside the same test cell instead of leaving it frozen at the last represented
position.
## Current Remote Player State Model
@@ -89,6 +92,10 @@ player exists.
- Normal proxy movement is smoothed toward the latest received target, while
`cell_change`, `worldspace_change`, and `teleport` transforms snap directly to
avoid slow movement across large discontinuities.
- The game-thread proxy controller holds the single proxy at a hidden in-cell
position when the represented player disconnects, disappears, or leaves the
local test cell, then snaps back to a valid same-cell remote player before
resuming smoothing.
- Transform packets now include basic movement state data for future animation
work, but the proxy actor does not apply animations from that state yet.