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: Proxy visual updates are independent from local movement log throttling:
normal movement is smoothed toward the latest target, while cell changes, normal movement is smoothed toward the latest target, while cell changes,
worldspace changes, and teleports snap directly. 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. 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 - Storing assigned and remote `playerId` state internally
- Moving the single placed test proxy actor from remote-player state on the game - Moving the single placed test proxy actor from remote-player state on the game
thread thread
- Holding that proxy actor at a hidden in-cell position when no valid same-cell
remote player should be represented
Planned later: 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 ## Entry Template
Use this format for future updates: Use this format for future updates:
+174 -30
View File
@@ -24,10 +24,16 @@ namespace
constexpr RE::TESFormID kProxyFallbackLocalFormId = 0x0020A2; constexpr RE::TESFormID kProxyFallbackLocalFormId = 0x0020A2;
constexpr auto kProxyOffsetX = 150.0F; constexpr auto kProxyOffsetX = 150.0F;
constexpr float kProxyPositionLerpAlpha = 0.15F; 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 kSafetyOffsetMovementInterval = 200ms;
constexpr auto kWarningLogInterval = 5s; constexpr auto kWarningLogInterval = 5s;
constexpr auto kNotInTestCellLogInterval = 30s; constexpr auto kNotInTestCellLogInterval = 30s;
using RemotePlayer = F4T::RemotePlayerState::RemotePlayerState;
enum class ProxyMovementMode enum class ProxyMovementMode
{ {
kSafetyOffsetTest, kSafetyOffsetTest,
@@ -41,6 +47,22 @@ namespace
kFallbackFormId 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; constexpr auto kProxyMovementMode = ProxyMovementMode::kRemotePlayerStateTest;
RE::ObjectRefHandle g_proxyHandle; RE::ObjectRefHandle g_proxyHandle;
@@ -51,13 +73,14 @@ namespace
bool g_proxySmoothingEnabledLogged = false; bool g_proxySmoothingEnabledLogged = false;
bool g_firstSmoothedMovementLogged = false; bool g_firstSmoothedMovementLogged = false;
auto g_lastSafetyOffsetMovementTime = std::chrono::steady_clock::time_point{}; 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_representedPlayerId;
std::optional<std::uint32_t> g_lastObservedSneakPlayerId; std::optional<std::uint32_t> g_lastObservedSneakPlayerId;
std::optional<bool> g_lastObservedSneakState; std::optional<bool> g_lastObservedSneakState;
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);
@@ -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) float Lerp(float a_current, float a_target, float a_alpha)
{ {
return a_current + ((a_target - a_current) * a_alpha); return a_current + ((a_target - a_current) * a_alpha);
@@ -413,8 +521,10 @@ namespace
return true; return true;
} }
std::optional<RemotePlayer> SelectRemotePlayerToRepresent(std::vector<RemotePlayer> a_remotePlayers) RemotePlayerSelection SelectRemotePlayerToRepresent(std::vector<RemotePlayer> a_remotePlayers)
{ {
RemotePlayerSelection selection;
if (g_representedPlayerId) { if (g_representedPlayerId) {
const auto representedPlayerStillExists = std::ranges::any_of( const auto representedPlayerStillExists = std::ranges::any_of(
a_remotePlayers, a_remotePlayers,
@@ -422,9 +532,7 @@ namespace
return a_remotePlayer.playerId == *g_representedPlayerId; return a_remotePlayer.playerId == *g_representedPlayerId;
}); });
if (!representedPlayerStillExists) { if (!representedPlayerStillExists) {
LogInfoWithLocalPlayerPrefix(std::format( selection.disappearedPlayerId = g_representedPlayerId;
"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));
g_representedPlayerId.reset(); g_representedPlayerId.reset();
g_remoteStateMovedLogged = false; g_remoteStateMovedLogged = false;
g_firstSmoothedMovementLogged = false; g_firstSmoothedMovementLogged = false;
@@ -433,10 +541,7 @@ namespace
} }
if (a_remotePlayers.empty()) { if (a_remotePlayers.empty()) {
LogThrottledInfo( return selection;
"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( auto selectedPlayer = std::ranges::min_element(
@@ -445,7 +550,7 @@ namespace
return a_left.playerId < a_right.playerId; return a_left.playerId < a_right.playerId;
}); });
if (selectedPlayer == a_remotePlayers.end()) { if (selectedPlayer == a_remotePlayers.end()) {
return std::nullopt; return selection;
} }
if (a_remotePlayers.size() > 1) { if (a_remotePlayers.size() > 1) {
@@ -457,6 +562,7 @@ namespace
} }
if (g_representedPlayerId != selectedPlayer->playerId) { if (g_representedPlayerId != selectedPlayer->playerId) {
selection.selectedPlayerChanged = true;
g_representedPlayerId = selectedPlayer->playerId; g_representedPlayerId = selectedPlayer->playerId;
g_remoteStateMovedLogged = false; g_remoteStateMovedLogged = false;
g_firstSmoothedMovementLogged = false; g_firstSmoothedMovementLogged = false;
@@ -471,13 +577,10 @@ namespace
kProxyPositionLerpAlpha)); kProxyPositionLerpAlpha));
g_proxySmoothingEnabledLogged = true; 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) 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) void MoveProxyToRemotePlayer(RE::Actor& a_proxy, const RemotePlayer& a_remotePlayer)
{ {
// TODO: Later animation/behavior milestones can read movement state from // TODO: Later animation/behavior milestones can read movement state from
@@ -614,28 +739,47 @@ namespace F4T::ProxyActorController
return; return;
} }
auto selectedRemotePlayer = SelectRemotePlayerToRepresent(F4T::RemotePlayerState::GetRemotePlayerSnapshot());
if (!selectedRemotePlayer) {
return;
}
auto* proxy = ResolveProxy(*parentCell, *player); auto* proxy = ResolveProxy(*parentCell, *player);
if (!proxy) { if (!proxy) {
return; return;
} }
if (!IsRemotePlayerInSameLocation(*selectedRemotePlayer, *player, *parentCell)) { const auto selection = SelectRemotePlayerToRepresent(F4T::RemotePlayerState::GetRemotePlayerSnapshot());
LogThrottledInfo( if (selection.disappearedPlayerId) {
"proxy_idle_cell_mismatch_" + std::to_string(selectedRemotePlayer->playerId), MoveProxyToHoldingPosition(
std::format( *proxy,
"Proxy actor {} is idle because represented remote player {} is not in the same cell. TODO: hide or disable the proxy actor after cell leave.", ProxyLifecycleState::kRemotePlayerDisconnected,
kProxyEditorId, selection.disappearedPlayerId);
selectedRemotePlayer->playerId)); }
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; return;
} }
ApplyRemoteMovementStateToProxy(*proxy, *selectedRemotePlayer); if (!IsRemotePlayerInSameLocation(*selection.selectedPlayer, *player, *parentCell)) {
MoveProxyToRemotePlayer(*proxy, *selectedRemotePlayer); 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() 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 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 of this remote-player state and move the single placed test proxy actor
`F4TProxyRemotePlayer01REF` in `F4TTestCell01`. The networking thread still does `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 ## Current Remote Player State Model
@@ -89,6 +92,10 @@ player exists.
- Normal proxy movement is smoothed toward the latest received target, while - Normal proxy movement is smoothed toward the latest received target, while
`cell_change`, `worldspace_change`, and `teleport` transforms snap directly to `cell_change`, `worldspace_change`, and `teleport` transforms snap directly to
avoid slow movement across large discontinuities. 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 - Transform packets now include basic movement state data for future animation
work, but the proxy actor does not apply animations from that state yet. work, but the proxy actor does not apply animations from that state yet.