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