Improve runtime proxy slot lifecycle & spawn

Refine Stage 4 runtime proxy management: introduce explicit lifecycle states (HeldNoRemote, HeldDisconnected, HeldLeftCell, Reusable, SpawnFailed), add a 30s disconnected-slot reuse delay, and implement per-slot hidden holding positions with spacing. Add a held-position correction pass (correct drift >25 units) and make disconnected-held slots become reusable after the grace period; reusable slots can be reassigned to later playerIds. Capture a remote player's current transform before performing the PlaceAtMe-backed Stage 4 spawn and apply that transform immediately when the isolated proxy is promoted, preventing a brief visual spawn near the local player. Misc: improved logging/throttling, slot assignment/reassignment logic, and various helper functions. Docs updated (architecture.md, dev-log.md) and plugin/src/F4TProxyActorController.cpp implements the behavior.
This commit is contained in:
2026-06-03 10:47:15 +12:00
parent b4ec928c67
commit 5394250ef2
3 changed files with 540 additions and 61 deletions
+24 -3
View File
@@ -37,15 +37,21 @@ both send and receive paths:
13. Runtime proxies are demand-driven. A slot executes the Stage 3.7
PlaceAtMe-backed spawn flow only when that remote player needs a same-cell
representation, and only one PlaceAtMe spawn attempt may run at a time so
candidate isolation remains unambiguous.
candidate isolation remains unambiguous. Stage 4 captures the remote
player's current valid transform before spawning and moves the isolated
runtime actor to that transform immediately, so normal remote representation
does not visually start at the local player offset.
14. Proxy visual updates are independent per slot. Normal movement is smoothed
toward that player’s latest target, while `cell_change`, `worldspace_change`,
and `teleport` snap directly. Sneak state is still observed and logged only;
no visual crouch/sneak application is attempted.
15. When a remote player leaves the cell or disconnects, only that player’s
runtime proxy is moved to the hidden holding position inside
`F4TTestCell01`. Runtime actors are not disabled, deleted, or reassigned to a
different `playerId` during Stage 4.
`F4TTestCell01`. Runtime actors are not disabled or deleted. Connected
players who leave the cell keep their slot reserved, while disconnected
held slots become reusable only after the configured grace period. Runtime
slots use per-slot holding positions with spacing and a game-thread
correction check so held actors do not stack or drift back into view.
16. The placed fallback reference `F4TProxyRemotePlayer01REF` remains a single
backup representation. If runtime spawning fails for one deterministic
selected remote player, the fallback can represent that one player only; it
@@ -96,6 +102,9 @@ both send and receive paths:
The Fallout 4 plugin now dynamically spawns one runtime proxy actor per
represented remote `playerId` in the test cell, up to a small Stage 4 test cap.
Disconnected held slots can be reassigned to later server `playerId` values after
the grace period, but slots held for connected players outside the cell are not
reused.
The placed proxy reference remains as a single fallback path if runtime spawning
fails for one selected remote player.
@@ -117,11 +126,19 @@ The native plugin is responsible for:
for one selected player only if runtime spawning fails
- Holding only the affected proxy actor at a hidden in-cell position when its
remote player disconnects or leaves the same cell
- Assigning each runtime proxy slot a unique hidden holding position and
correcting held actors back to that position if collision, AI, or physics drift
moves them away
- Reusing only disconnected runtime proxy slots after a grace period; slots held
because a still-connected remote player left the cell remain reserved for that
original `playerId`
- Logging staged runtime proxy actor base lookup diagnostics on the same
game-thread controller path
- Attempting one game-thread-only PlaceAtMe-backed runtime spawn from the
selected Stage 3 actor base in `F4TTestCell01`, normally
`F4T_RemotePlayerProxy`
- Capturing the remote player's current valid transform before Stage 4 runtime
spawn and applying it as soon as the PlaceAtMe-backed actor is isolated
- Logging temporary Stage 3.1 visibility and movement diagnostics for the single
runtime proxy, including active proxy source, runtime FormID, local distance,
and safe loaded-3D checks
@@ -200,9 +217,13 @@ Game update validates transform data and same-cell compatibility
Game update creates or reuses a runtime proxy slot per eligible remote playerId
Game update attempts at most one PlaceAtMe-backed runtime actor spawn at a time
Game update snapshots matching candidates, isolates the new actor, and waits for the settle window
Game update moves isolated Stage 4 runtime spawn candidates to the remote player's current transform
Game update stores promoted runtime actors as ObjectRefHandle values in slots
Game update moves each valid proxy from its assigned remote player state
Game update holds only proxies whose assigned remote player left the cell or disappeared
Game update corrects held proxy slots back to their per-slot hidden positions
Game update marks disconnected held slots reusable after the grace period
Game update may reassign reusable disconnected slots to later server playerIds
Game update may use F4TProxyRemotePlayer01REF as one placed fallback if runtime spawning fails
```
+175
View File
@@ -1903,6 +1903,181 @@ proxy actors only when valid same-cell remote state needs representation.
---
## 2026-06-03 - Runtime Proxy Manager Cleanup and Stability Pass
### Summary
Cleaned up the Stage 4 runtime proxy slot lifecycle so disconnected held slots
can become reusable after a grace period instead of occupying the small runtime
proxy cap forever.
### Files Changed
- `plugin/src/F4TProxyActorController.cpp`
- `docs/architecture.md`
- `docs/dev-log.md`
### Details
- Refined runtime proxy slot lifecycle states to distinguish active, held because
no valid remote transform, held because the remote player left the cell, held
because the remote player disconnected/disappeared, reusable, and spawn-failed
slots.
- Added a 30 second disconnected-slot reuse delay. Disconnected slots are first
moved to the holding position and marked held; only after the delay do they
become reusable.
- Updated slot acquisition to prefer an existing slot for the same `playerId`,
create a new slot while under `kMaxRuntimeProxyActors`, and reuse an eligible
disconnected slot at the cap before logging a throttled max-cap warning.
- Preserved connected-but-left-cell slots for their original `playerId`, so
Leave Cell / Return To Cell keeps the same proxy assignment.
- Reset movement smoothing flags and sneak observation state when a reusable slot
is reassigned, then snap the proxy to the new remote player's current transform
before normal smoothing resumes.
- Kept server `playerId` allocation, networking packets, Python server code, fake
client tooling, placed fallback behavior, same-cell gating, movement
smoothing/snap behavior, and data-only sneak observation unchanged.
- Continued to avoid disabling, deleting, alpha-hiding, or otherwise marking
runtime actors for deletion; held actors remain at the hidden in-cell holding
position.
### Testing
- `xmake build` from the repository root prompts because there is no root
`xmake.lua`.
- Ran `xmake build` from `plugin`; it succeeded and rebuilt
`Fallout4Together.dll`.
- Pending manual validation: launch Fallout 4 through F4SE, load
`Fallout4Together_Test.esp`, `coc F4TTestCell01`, start the Dev Server GUI,
add fake clients up to the cap, disconnect clients, wait for the reuse delay,
and confirm a later higher `playerId` can reuse an old disconnected slot.
- Pending manual validation: confirm Leave Cell / Return To Cell preserves a
connected player's original slot and that Walk Circle, Walk To Player,
Teleport Test, and sneak-state logging still behave as before.
### Known Issues
- Runtime actors are still held instead of safely despawned or deleted.
- The placed fallback remains single-player only by design.
- In-game validation for this cleanup pass is still pending.
### Next Steps
- Run the manual GUI fake-client lifecycle test in `F4TTestCell01`.
- If reuse validates cleanly, keep the next milestone focused on diagnostics or
safe actor cleanup research rather than gameplay sync expansion.
---
## 2026-06-03 - Held Runtime Proxy Stability Patch
### Summary
After manual testing showed some disconnected held runtime proxies could visually
reappear even while their remote players were absent, tightened the held-proxy
positioning path.
### Files Changed
- `plugin/src/F4TProxyActorController.cpp`
- `docs/architecture.md`
- `docs/dev-log.md`
### Details
- Kept the hidden holding-position approach and continued to avoid disabling,
deleting, alpha-hiding, or marking actors for deletion.
- Moved the shared holding base farther away/lower and gave each runtime proxy
slot a stable holding-position index with 500 unit spacing so held actors do
not stack on the same coordinates.
- Added a game-thread held-position correction pass for `HeldNoRemote`,
`HeldLeftCell`, `HeldDisconnected`, and `Reusable` slots. If a held proxy
drifts more than 25 units from its assigned holding position, the controller
moves it back and logs a throttled correction message.
- Preserved disconnected-slot reuse behavior: `HeldDisconnected` still becomes
`Reusable` after the delay, reusable slots can still be reassigned, and
`HeldLeftCell` slots remain reserved for connected players.
- Networking, protocol, Python server code, fake-client tooling, placed fallback
behavior, movement smoothing/snap behavior, same-cell gating, and data-only
sneak observation were unchanged.
### Testing
- Ran `xmake build` from `plugin`; it succeeded and rebuilt
`Fallout4Together.dll`.
- Pending manual validation: repeat the GUI fake-client disconnect test that
previously caused held proxies to visually reappear.
### Known Issues
- Runtime actors are still held instead of safely despawned or deleted.
- The placed fallback remains single-player only by design.
- In-game validation of the held-position correction is still pending.
### Next Steps
- Re-test removing all fake clients and confirm held proxies stay out of view.
- Re-test reuse after the grace period and Leave Cell / Return To Cell.
---
## 2026-06-03 - Runtime Proxy Spawn Polish Patch
### Summary
Polished Stage 4 runtime proxy spawning so newly isolated runtime actors are
placed at the remote player's current transform immediately instead of visually
starting near the local player before snapping away.
### Files Changed
- `plugin/src/F4TProxyActorController.cpp`
- `docs/architecture.md`
- `docs/dev-log.md`
### Details
- Added slot-local initial remote spawn transform tracking for runtime proxy
slots.
- Captures the remote player's current valid same-cell transform before starting
the Stage 4 PlaceAtMe-backed spawn path.
- Applies that transform as soon as the new PlaceAtMe-backed actor is isolated
and again at promotion, setting both position and heading before normal remote
movement resumes.
- Reusable slot reassignment continues to snap directly to the newly assigned
remote player's current transform and resets movement/sneak observation state.
- Kept movement smoothing and snap behavior unchanged: normal movement still
lerps after initialization, while `cell_change`, `worldspace_change`, and
`teleport` still snap.
- Networking, protocol, Python server code, fake-client tooling, placed fallback
behavior, held/reusable slot behavior, same-cell gating, and data-only sneak
observation were unchanged.
### Testing
- Ran `xmake build` from `plugin`; it succeeded and rebuilt
`Fallout4Together.dll`.
- Pending manual validation: add a GUI fake client and confirm its proxy no
longer briefly appears near the local player before moving to the fake client's
remote transform.
- Pending manual validation: re-test reusable slot reassignment, Walk To Player,
Walk Circle, Leave Cell, Return To Cell, disconnect, and reconnect.
### Known Issues
- The Stage 4 spawn path still uses PlaceAtMe-backed candidate isolation, so the
actor is moved to the remote transform immediately after isolation rather than
replacing the underlying diagnostic spawn mechanism.
- Runtime actors are still held instead of safely despawned or deleted.
### Next Steps
- Manually verify new-client spawn visuals in `F4TTestCell01`.
- If a visible local-player flash remains before candidate isolation, investigate
replacing the PlaceAtMe-backed Stage 4 spawn backend in a separate milestone.
---
## Entry Template
Use this format for future updates:
+341 -58
View File
@@ -56,9 +56,12 @@ namespace
constexpr auto kDebugRuntimeProxyScale = 1.50F;
constexpr float kProxyPositionLerpAlpha = 0.15F;
constexpr std::size_t kMaxRuntimeProxyActors = 4;
constexpr float kDisconnectedProxyReuseDelaySeconds = 30.0F;
// Keep inactive proxies inside the loaded test cell instead of disabling actor
// refs before a safe lifecycle cleanup path is validated in-game.
constexpr RE::NiPoint3 kProxyHiddenHoldingPosition{ -100000.0F, -100000.0F, -100000.0F };
constexpr RE::NiPoint3 kProxyHiddenHoldingPosition{ -200000.0F, -200000.0F, -50000.0F };
constexpr float kProxyHiddenHoldingPositionSpacing = 500.0F;
constexpr float kHeldProxyPositionCorrectionDistance = 25.0F;
constexpr auto kSafetyOffsetMovementInterval = 200ms;
constexpr auto kWarningLogInterval = 5s;
constexpr auto kProxyMovementDiagnosticLogInterval = 2s;
@@ -110,11 +113,12 @@ namespace
enum class ProxyLifecycleState
{
kNoRemotePlayer,
kRepresentingRemotePlayer,
kRemotePlayerDisconnected,
kRemotePlayerLeftCell,
kProxyHiddenOrIdle
kHeldNoRemote,
kActive,
kHeldDisconnected,
kHeldLeftCell,
kReusable,
kSpawnFailed
};
enum class ProxyMovementDiagnosticMode
@@ -177,16 +181,21 @@ namespace
struct ProxyActorSlot
{
std::uint32_t remotePlayerId{};
std::uint32_t holdingPositionIndex{};
RE::ObjectRefHandle proxyHandle;
RE::ObjectRefHandle pendingSpawnHandle;
ProxyLifecycleState lifecycleState{ ProxyLifecycleState::kNoRemotePlayer };
ProxyLifecycleState lifecycleState{ ProxyLifecycleState::kHeldNoRemote };
std::optional<ProxyLifecycleState> holdReason;
std::optional<std::uint32_t> previousRemotePlayerId;
PlaceAtMeBackedProxySpawnState spawnState{ PlaceAtMeBackedProxySpawnState::kNotStarted };
RuntimeActorBaseSource spawnSource{ RuntimeActorBaseSource::kCustomProxy };
std::uint32_t spawnBaseFormId{ 0 };
std::unordered_set<std::uint32_t> preexistingCandidateRefs;
std::optional<RE::NiPoint3> initialRemoteSpawnPosition;
std::chrono::steady_clock::time_point candidateSearchStartTime{};
std::chrono::steady_clock::time_point settleStartTime{};
std::chrono::steady_clock::time_point heldSinceTime{};
float initialRemoteSpawnHeading{ 0.0F };
bool spawnAttempted{ false };
bool spawnAttemptInProgress{ false };
bool spawnSucceeded{ false };
@@ -194,6 +203,8 @@ namespace
bool restoredForCurrentState{ false };
bool firstSmoothedMovementLogged{ false };
bool remoteStateMovedLogged{ false };
bool reusableLogged{ false };
bool directRemoteSpawnLogged{ false };
std::optional<bool> lastObservedSneakState;
};
@@ -216,7 +227,7 @@ namespace
bool g_proxySmoothingEnabledLogged = false;
bool g_firstSmoothedMovementLogged = false;
auto g_lastSafetyOffsetMovementTime = std::chrono::steady_clock::time_point{};
ProxyLifecycleState g_proxyLifecycleState = ProxyLifecycleState::kNoRemotePlayer;
ProxyLifecycleState g_proxyLifecycleState = ProxyLifecycleState::kHeldNoRemote;
std::optional<ProxyLifecycleState> g_proxyHoldReason;
std::optional<std::uint32_t> g_proxyHoldPlayerId;
std::optional<ActiveProxyKind> g_proxyHoldKind;
@@ -282,6 +293,7 @@ namespace
RuntimeActorBaseSource g_runtimeProxyActorBaseSource = RuntimeActorBaseSource::kCustomProxy;
std::unordered_map<std::uint32_t, ProxyActorSlot> g_proxySlots;
std::optional<std::uint32_t> g_runtimeProxySpawnAttemptPlayerId;
std::uint32_t g_nextProxyHoldingPositionIndex = 0;
bool g_stage4StartedLogged = false;
bool g_fallbackRuntimeUnavailableLogged = false;
std::unordered_map<std::string, std::chrono::steady_clock::time_point> g_lastWarningLogTimes;
@@ -442,7 +454,7 @@ namespace
std::optional<std::uint32_t> a_playerId,
ActiveProxyKind a_proxyKind)
{
return g_proxyLifecycleState == ProxyLifecycleState::kProxyHiddenOrIdle &&
return g_proxyLifecycleState == a_reason &&
g_proxyHoldReason == a_reason &&
g_proxyHoldPlayerId == a_playerId &&
g_proxyHoldKind == a_proxyKind;
@@ -451,12 +463,12 @@ namespace
void LogProxyStillHeld(ProxyLifecycleState a_reason, std::optional<std::uint32_t> a_playerId)
{
switch (a_reason) {
case ProxyLifecycleState::kNoRemotePlayer:
case ProxyLifecycleState::kHeldNoRemote:
LogThrottledInfo(
"proxy_held_no_remote_player",
"No suitable remote player is available; proxy actor is already at the holding position.");
break;
case ProxyLifecycleState::kRemotePlayerDisconnected:
case ProxyLifecycleState::kHeldDisconnected:
if (a_playerId) {
LogThrottledInfo(
"proxy_held_disconnected_" + std::to_string(*a_playerId),
@@ -465,7 +477,7 @@ namespace
*a_playerId));
}
break;
case ProxyLifecycleState::kRemotePlayerLeftCell:
case ProxyLifecycleState::kHeldLeftCell:
if (a_playerId) {
LogThrottledInfo(
"proxy_held_left_cell_" + std::to_string(*a_playerId),
@@ -491,7 +503,7 @@ namespace
}
a_proxy.SetPosition(kProxyHiddenHoldingPosition, true);
g_proxyLifecycleState = ProxyLifecycleState::kProxyHiddenOrIdle;
g_proxyLifecycleState = a_reason;
g_proxyHoldReason = a_reason;
g_proxyHoldPlayerId = a_playerId;
g_proxyHoldKind = a_proxyKind;
@@ -500,12 +512,12 @@ namespace
ResetRemoteMovementStateTracking();
switch (a_reason) {
case ProxyLifecycleState::kNoRemotePlayer:
case ProxyLifecycleState::kHeldNoRemote:
LogInfoWithLocalPlayerPrefix(std::format(
"No suitable remote player is available; moving {} to holding position.",
GetActiveProxyLogName(a_proxyKind)));
break;
case ProxyLifecycleState::kRemotePlayerDisconnected:
case ProxyLifecycleState::kHeldDisconnected:
if (a_playerId) {
LogInfoWithLocalPlayerPrefix(std::format(
"Remote player {} disappeared; moving {} to holding position.",
@@ -513,7 +525,7 @@ namespace
GetActiveProxyLogName(a_proxyKind)));
}
break;
case ProxyLifecycleState::kRemotePlayerLeftCell:
case ProxyLifecycleState::kHeldLeftCell:
if (a_playerId) {
LogInfoWithLocalPlayerPrefix(std::format(
"Remote player {} is not in the same cell; moving {} to holding position.",
@@ -2517,6 +2529,48 @@ namespace
return candidates;
}
RE::NiPoint3 GetRemotePlayerPosition(const RemotePlayer& a_remotePlayer)
{
return RE::NiPoint3{
a_remotePlayer.x,
a_remotePlayer.y,
a_remotePlayer.z
};
}
void SetSlotInitialRemoteSpawnTransform(ProxyActorSlot& a_slot, const RemotePlayer& a_remotePlayer)
{
a_slot.initialRemoteSpawnPosition = GetRemotePlayerPosition(a_remotePlayer);
a_slot.initialRemoteSpawnHeading = a_remotePlayer.angleZ;
}
void CaptureSlotInitialRemoteSpawnTransform(ProxyActorSlot& a_slot, const RemotePlayer& a_remotePlayer)
{
if (a_slot.proxyHandle || a_slot.spawnState == PlaceAtMeBackedProxySpawnState::kPromoted) {
return;
}
SetSlotInitialRemoteSpawnTransform(a_slot, a_remotePlayer);
}
void ApplySlotInitialRemoteSpawnTransform(ProxyActorSlot& a_slot, RE::Actor& a_actor)
{
if (!a_slot.initialRemoteSpawnPosition) {
return;
}
a_actor.SetPosition(*a_slot.initialRemoteSpawnPosition, true);
a_actor.SetHeading(a_slot.initialRemoteSpawnHeading);
if (!a_slot.directRemoteSpawnLogged) {
LogInfoWithLocalPlayerPrefix(std::format(
"Runtime proxy actor {:08X} for remote player {} spawned directly at remote transform position {}.",
a_actor.GetFormID(),
a_slot.remotePlayerId,
FormatPosition(*a_slot.initialRemoteSpawnPosition)));
a_slot.directRemoteSpawnLogged = true;
}
}
bool IsSlotSpawnCandidateActor(
const ProxyActorSlot& a_slot,
const RE::Actor& a_actor,
@@ -2723,6 +2777,7 @@ namespace
a_slot.spawnState = PlaceAtMeBackedProxySpawnState::kFailed;
a_slot.spawnAttemptInProgress = false;
a_slot.spawnFailed = true;
a_slot.lifecycleState = ProxyLifecycleState::kSpawnFailed;
a_slot.pendingSpawnHandle.reset();
if (g_runtimeProxySpawnAttemptPlayerId == a_slot.remotePlayerId) {
g_runtimeProxySpawnAttemptPlayerId.reset();
@@ -2731,6 +2786,7 @@ namespace
void StartSlotPlaceAtMeSettle(ProxyActorSlot& a_slot, RE::Actor& a_actor)
{
ApplySlotInitialRemoteSpawnTransform(a_slot, a_actor);
a_slot.pendingSpawnHandle = a_actor.GetHandle();
a_slot.settleStartTime = std::chrono::steady_clock::now();
a_slot.spawnState = PlaceAtMeBackedProxySpawnState::kSettling;
@@ -2755,14 +2811,17 @@ namespace
isVisible));
}
ApplySlotInitialRemoteSpawnTransform(a_slot, a_actor);
a_slot.proxyHandle = a_actor.GetHandle();
a_slot.pendingSpawnHandle.reset();
a_slot.spawnSucceeded = true;
a_slot.spawnAttemptInProgress = false;
a_slot.spawnFailed = false;
a_slot.spawnState = PlaceAtMeBackedProxySpawnState::kPromoted;
a_slot.lifecycleState = ProxyLifecycleState::kProxyHiddenOrIdle;
a_slot.holdReason = ProxyLifecycleState::kProxyHiddenOrIdle;
a_slot.lifecycleState = ProxyLifecycleState::kHeldNoRemote;
a_slot.holdReason = ProxyLifecycleState::kHeldNoRemote;
a_slot.heldSinceTime = {};
a_slot.reusableLogged = false;
a_slot.restoredForCurrentState = false;
if (g_runtimeProxySpawnAttemptPlayerId == a_slot.remotePlayerId) {
g_runtimeProxySpawnAttemptPlayerId.reset();
@@ -3490,7 +3549,7 @@ namespace
a_proxy.SetPosition(targetPosition, true);
a_proxy.SetHeading(a_remotePlayer.angleZ);
g_proxyLifecycleState = ProxyLifecycleState::kRepresentingRemotePlayer;
g_proxyLifecycleState = ProxyLifecycleState::kActive;
g_proxyHoldReason.reset();
g_proxyHoldPlayerId.reset();
g_proxyHoldKind.reset();
@@ -3602,38 +3661,103 @@ namespace
a_slot.spawnAttemptInProgress = false;
a_slot.spawnSucceeded = false;
a_slot.restoredForCurrentState = false;
if (a_slot.lifecycleState != ProxyLifecycleState::kHeldDisconnected) {
a_slot.heldSinceTime = {};
a_slot.reusableLogged = false;
}
if (g_runtimeProxySpawnAttemptPlayerId == a_slot.remotePlayerId) {
g_runtimeProxySpawnAttemptPlayerId.reset();
}
return nullptr;
}
void MoveSlotProxyToHoldingPosition(
void ResetSlotMovementState(ProxyActorSlot& a_slot)
{
a_slot.restoredForCurrentState = false;
a_slot.firstSmoothedMovementLogged = false;
a_slot.remoteStateMovedLogged = false;
}
void ResetSlotObservationState(ProxyActorSlot& a_slot)
{
a_slot.lastObservedSneakState.reset();
}
bool HasSlotHeldSinceTime(const ProxyActorSlot& a_slot)
{
return a_slot.heldSinceTime.time_since_epoch().count() != 0;
}
bool IsHeldSlotState(ProxyLifecycleState a_state)
{
return a_state == ProxyLifecycleState::kHeldNoRemote ||
a_state == ProxyLifecycleState::kHeldLeftCell ||
a_state == ProxyLifecycleState::kHeldDisconnected ||
a_state == ProxyLifecycleState::kReusable;
}
RE::NiPoint3 GetSlotHoldingPosition(const ProxyActorSlot& a_slot)
{
auto holdingPosition = kProxyHiddenHoldingPosition;
holdingPosition.x += static_cast<float>(a_slot.holdingPositionIndex) * kProxyHiddenHoldingPositionSpacing;
return holdingPosition;
}
void CorrectHeldSlotPositionIfNeeded(ProxyActorSlot& a_slot, RE::Actor& a_proxy)
{
if (!IsHeldSlotState(a_slot.lifecycleState)) {
return;
}
const auto holdingPosition = GetSlotHoldingPosition(a_slot);
const auto currentPosition = a_proxy.GetPosition();
if (GetDistance(currentPosition, holdingPosition) <= kHeldProxyPositionCorrectionDistance) {
return;
}
a_proxy.SetPosition(holdingPosition, true);
LogThrottledInfo(
"held_runtime_proxy_position_corrected_" + std::to_string(a_slot.remotePlayerId),
std::format(
"Held runtime proxy actor {:08X} for remote player {} drifted from holding position; correcting.",
a_proxy.GetFormID(),
a_slot.remotePlayerId));
}
void MarkSlotHeld(
ProxyActorSlot& a_slot,
RE::Actor& a_proxy,
ProxyLifecycleState a_reason)
{
if (a_slot.lifecycleState == ProxyLifecycleState::kProxyHiddenOrIdle && a_slot.holdReason == a_reason) {
if (a_slot.lifecycleState == a_reason && a_slot.holdReason == a_reason) {
CorrectHeldSlotPositionIfNeeded(a_slot, a_proxy);
LogProxyStillHeld(a_reason, a_slot.remotePlayerId);
return;
}
a_proxy.SetPosition(kProxyHiddenHoldingPosition, true);
a_slot.lifecycleState = ProxyLifecycleState::kProxyHiddenOrIdle;
a_proxy.SetPosition(GetSlotHoldingPosition(a_slot), true);
a_slot.lifecycleState = a_reason;
a_slot.holdReason = a_reason;
a_slot.restoredForCurrentState = false;
a_slot.firstSmoothedMovementLogged = false;
a_slot.remoteStateMovedLogged = false;
a_slot.lastObservedSneakState.reset();
ResetSlotMovementState(a_slot);
ResetSlotObservationState(a_slot);
if (a_reason == ProxyLifecycleState::kHeldDisconnected) {
if (!HasSlotHeldSinceTime(a_slot)) {
a_slot.heldSinceTime = std::chrono::steady_clock::now();
}
a_slot.reusableLogged = false;
} else {
a_slot.heldSinceTime = {};
a_slot.reusableLogged = false;
}
switch (a_reason) {
case ProxyLifecycleState::kRemotePlayerDisconnected:
case ProxyLifecycleState::kHeldDisconnected:
LogInfoWithLocalPlayerPrefix(std::format(
"Remote player {} disappeared; moving runtime proxy actor {:08X} to holding position.",
a_slot.remotePlayerId,
a_proxy.GetFormID()));
break;
case ProxyLifecycleState::kRemotePlayerLeftCell:
case ProxyLifecycleState::kHeldLeftCell:
LogInfoWithLocalPlayerPrefix(std::format(
"Remote player {} is not in the same cell; moving runtime proxy actor {:08X} to holding position.",
a_slot.remotePlayerId,
@@ -3648,6 +3772,45 @@ namespace
}
}
void CorrectHeldSlotPositions(RE::TESObjectCELL& a_parentCell, const RE::PlayerCharacter& a_player)
{
for (auto& [_, slot] : g_proxySlots) {
if (!IsHeldSlotState(slot.lifecycleState)) {
continue;
}
if (auto* proxy = TryResolveSlotProxy(slot, a_parentCell, a_player)) {
CorrectHeldSlotPositionIfNeeded(slot, *proxy);
}
}
}
void MarkReusableSlots()
{
const auto now = std::chrono::steady_clock::now();
for (auto& [playerId, slot] : g_proxySlots) {
if (slot.lifecycleState != ProxyLifecycleState::kHeldDisconnected || !HasSlotHeldSinceTime(slot)) {
continue;
}
const auto heldSeconds = std::chrono::duration<float>(now - slot.heldSinceTime).count();
if (heldSeconds < kDisconnectedProxyReuseDelaySeconds) {
continue;
}
slot.lifecycleState = ProxyLifecycleState::kReusable;
slot.holdReason.reset();
slot.previousRemotePlayerId = playerId;
if (!slot.reusableLogged) {
LogInfoWithLocalPlayerPrefix(std::format(
"Runtime proxy slot for disconnected remote player {} became reusable after {:.1f}s held.",
playerId,
heldSeconds));
slot.reusableLogged = true;
}
}
}
void RestoreSlotProxyForRemotePlayer(
ProxyActorSlot& a_slot,
RE::Actor& a_proxy,
@@ -3662,8 +3825,10 @@ namespace
a_proxy.SetPosition(targetPosition, true);
a_proxy.SetHeading(a_remotePlayer.angleZ);
a_slot.lifecycleState = ProxyLifecycleState::kRepresentingRemotePlayer;
a_slot.lifecycleState = ProxyLifecycleState::kActive;
a_slot.holdReason.reset();
a_slot.heldSinceTime = {};
a_slot.reusableLogged = false;
a_slot.restoredForCurrentState = true;
a_slot.firstSmoothedMovementLogged = false;
a_slot.remoteStateMovedLogged = false;
@@ -3720,8 +3885,10 @@ namespace
a_proxy.SetPosition(nextPosition, true);
a_proxy.SetHeading(a_remotePlayer.angleZ);
a_slot.lifecycleState = ProxyLifecycleState::kRepresentingRemotePlayer;
a_slot.lifecycleState = ProxyLifecycleState::kActive;
a_slot.holdReason.reset();
a_slot.heldSinceTime = {};
a_slot.reusableLogged = false;
if (shouldSnap) {
LogThrottledInfo(
@@ -3770,31 +3937,125 @@ namespace
return slot != g_proxySlots.end() ? std::addressof(slot->second) : nullptr;
}
ProxyActorSlot* GetOrCreateSlot(std::uint32_t a_remotePlayerId)
std::size_t CountSlotsInState(ProxyLifecycleState a_state)
{
if (auto* existingSlot = FindSlot(a_remotePlayerId)) {
return existingSlot;
}
return static_cast<std::size_t>(std::ranges::count_if(
g_proxySlots,
[a_state](const auto& a_entry) {
return a_entry.second.lifecycleState == a_state;
}));
}
if (g_proxySlots.size() >= kMaxRuntimeProxyActors) {
LogThrottledWarning(
"stage4_max_runtime_proxy_limit",
std::format(
"Runtime proxy max limit reached: max={}, activeSlots={}. Remote player {} will not receive a visual proxy yet.",
kMaxRuntimeProxyActors,
g_proxySlots.size(),
a_remotePlayerId));
std::optional<std::uint32_t> FindReusableSlot()
{
std::optional<std::uint32_t> reusablePlayerId;
for (const auto& [playerId, slot] : g_proxySlots) {
if (slot.lifecycleState != ProxyLifecycleState::kReusable) {
continue;
}
if (!reusablePlayerId || playerId < *reusablePlayerId) {
reusablePlayerId = playerId;
}
}
return reusablePlayerId;
}
ProxyActorSlot* AssignSlotToRemotePlayer(
std::uint32_t a_reusablePlayerId,
const RemotePlayer& a_remotePlayer,
RE::TESObjectCELL& a_parentCell,
const RE::PlayerCharacter& a_player,
bool a_sameCellPassed)
{
auto node = g_proxySlots.extract(a_reusablePlayerId);
if (node.empty()) {
return nullptr;
}
auto [slot, inserted] = g_proxySlots.emplace(a_remotePlayerId, ProxyActorSlot{});
slot->second.remotePlayerId = a_remotePlayerId;
if (inserted) {
LogInfoWithLocalPlayerPrefix(std::format(
"Runtime proxy slot created for remote player {}.",
a_remotePlayerId));
auto& slotValue = node.mapped();
const auto previousRemotePlayerId = slotValue.remotePlayerId;
slotValue.previousRemotePlayerId = previousRemotePlayerId;
slotValue.remotePlayerId = a_remotePlayer.playerId;
slotValue.lifecycleState = ProxyLifecycleState::kHeldNoRemote;
slotValue.holdReason.reset();
slotValue.heldSinceTime = {};
slotValue.reusableLogged = false;
slotValue.directRemoteSpawnLogged = false;
SetSlotInitialRemoteSpawnTransform(slotValue, a_remotePlayer);
slotValue.pendingSpawnHandle.reset();
slotValue.spawnAttemptInProgress = false;
slotValue.spawnFailed = false;
if (slotValue.proxyHandle) {
slotValue.spawnState = PlaceAtMeBackedProxySpawnState::kPromoted;
slotValue.spawnSucceeded = true;
} else {
slotValue.spawnState = PlaceAtMeBackedProxySpawnState::kNotStarted;
slotValue.spawnAttempted = false;
slotValue.spawnSucceeded = false;
slotValue.preexistingCandidateRefs.clear();
}
return std::addressof(slot->second);
ResetSlotMovementState(slotValue);
ResetSlotObservationState(slotValue);
if (g_runtimeProxySpawnAttemptPlayerId == previousRemotePlayerId) {
g_runtimeProxySpawnAttemptPlayerId.reset();
}
node.key() = a_remotePlayer.playerId;
auto insertResult = g_proxySlots.insert(std::move(node));
auto& slot = insertResult.position->second;
LogInfoWithLocalPlayerPrefix(std::format(
"Reusable runtime proxy slot reassigned from disconnected remote player {} to remote player {}.",
previousRemotePlayerId,
a_remotePlayer.playerId));
if (auto* proxy = TryResolveSlotProxy(slot, a_parentCell, a_player)) {
RestoreSlotProxyForRemotePlayer(slot, *proxy, a_remotePlayer, a_player, a_sameCellPassed);
}
return std::addressof(slot);
}
ProxyActorSlot* GetOrCreateSlot(
const RemotePlayer& a_remotePlayer,
RE::TESObjectCELL& a_parentCell,
const RE::PlayerCharacter& a_player,
bool a_sameCellPassed)
{
if (auto* existingSlot = FindSlot(a_remotePlayer.playerId)) {
return existingSlot;
}
if (g_proxySlots.size() < kMaxRuntimeProxyActors) {
auto [slot, inserted] = g_proxySlots.emplace(a_remotePlayer.playerId, ProxyActorSlot{});
slot->second.remotePlayerId = a_remotePlayer.playerId;
CaptureSlotInitialRemoteSpawnTransform(slot->second, a_remotePlayer);
if (inserted) {
slot->second.holdingPositionIndex = g_nextProxyHoldingPositionIndex++;
LogInfoWithLocalPlayerPrefix(std::format(
"Runtime proxy slot created for remote player {} with holding index {}.",
a_remotePlayer.playerId,
slot->second.holdingPositionIndex));
}
return std::addressof(slot->second);
}
if (auto reusablePlayerId = FindReusableSlot()) {
return AssignSlotToRemotePlayer(*reusablePlayerId, a_remotePlayer, a_parentCell, a_player, a_sameCellPassed);
}
LogThrottledWarning(
"stage4_max_runtime_proxy_limit",
std::format(
"Runtime proxy max limit reached: max={}, totalSlots={}, active={}, heldLeftCell={}, heldDisconnectedWaiting={}, reusable={}. Remote player {} will not receive a visual proxy yet.",
kMaxRuntimeProxyActors,
g_proxySlots.size(),
CountSlotsInState(ProxyLifecycleState::kActive),
CountSlotsInState(ProxyLifecycleState::kHeldLeftCell),
CountSlotsInState(ProxyLifecycleState::kHeldDisconnected),
CountSlotsInState(ProxyLifecycleState::kReusable),
a_remotePlayer.playerId));
return nullptr;
}
bool UpdateRuntimeSlotForRemotePlayer(
@@ -3804,6 +4065,7 @@ namespace
const RE::PlayerCharacter& a_player,
bool a_sameCellPassed)
{
CaptureSlotInitialRemoteSpawnTransform(a_slot, a_remotePlayer);
auto* proxy = TryResolveSlotProxy(a_slot, a_parentCell, a_player);
if (!proxy) {
MaintainSlotPlaceAtMeSpawn(a_slot, a_parentCell, a_player);
@@ -3814,7 +4076,7 @@ namespace
}
if (!a_slot.restoredForCurrentState ||
a_slot.lifecycleState != ProxyLifecycleState::kRepresentingRemotePlayer ||
a_slot.lifecycleState != ProxyLifecycleState::kActive ||
ShouldSnapRemoteMovement(a_remotePlayer.movementType)) {
RestoreSlotProxyForRemotePlayer(a_slot, *proxy, a_remotePlayer, a_player, a_sameCellPassed);
return true;
@@ -3834,13 +4096,31 @@ namespace
if (a_seenRemotePlayerIds.contains(playerId)) {
continue;
}
if (slot.lifecycleState == ProxyLifecycleState::kReusable) {
continue;
}
if (slot.spawnAttemptInProgress || slot.spawnState == PlaceAtMeBackedProxySpawnState::kSettling) {
MaintainSlotPlaceAtMeSpawn(slot, a_parentCell, a_player);
}
if (auto* proxy = TryResolveSlotProxy(slot, a_parentCell, a_player)) {
MoveSlotProxyToHoldingPosition(slot, *proxy, ProxyLifecycleState::kRemotePlayerDisconnected);
MarkSlotHeld(slot, *proxy, ProxyLifecycleState::kHeldDisconnected);
} else if (slot.lifecycleState != ProxyLifecycleState::kHeldDisconnected || !HasSlotHeldSinceTime(slot)) {
const auto newlyHeldDisconnected = slot.lifecycleState != ProxyLifecycleState::kHeldDisconnected;
slot.lifecycleState = ProxyLifecycleState::kHeldDisconnected;
slot.holdReason = ProxyLifecycleState::kHeldDisconnected;
if (!HasSlotHeldSinceTime(slot)) {
slot.heldSinceTime = std::chrono::steady_clock::now();
}
slot.reusableLogged = false;
ResetSlotMovementState(slot);
ResetSlotObservationState(slot);
if (newlyHeldDisconnected) {
LogInfoWithLocalPlayerPrefix(std::format(
"Remote player {} disappeared; runtime proxy slot is held without a valid proxy actor and can become reusable after the grace period.",
playerId));
}
}
}
}
@@ -3857,7 +4137,7 @@ namespace
if (auto* fallbackProxy = ResolveProxy(a_parentCell, a_player)) {
MoveProxyToHoldingPosition(
*fallbackProxy,
ProxyLifecycleState::kRemotePlayerDisconnected,
ProxyLifecycleState::kHeldDisconnected,
g_representedPlayerId,
ActiveProxyKind::kPlacedFallback);
}
@@ -3885,7 +4165,7 @@ namespace
const auto representedPlayerChanged = g_representedPlayerId != a_remotePlayer.playerId;
g_representedPlayerId = a_remotePlayer.playerId;
if (representedPlayerChanged ||
g_proxyLifecycleState != ProxyLifecycleState::kRepresentingRemotePlayer ||
g_proxyLifecycleState != ProxyLifecycleState::kActive ||
ShouldSnapRemoteMovement(a_remotePlayer.movementType)) {
RestoreProxyForRemotePlayer(
*fallbackProxy,
@@ -3949,6 +4229,7 @@ namespace F4T::ProxyActorController
}
const auto remotePlayers = GetSortedRemotePlayerSnapshot();
MarkReusableSlots();
std::unordered_set<std::uint32_t> seenRemotePlayerIds;
seenRemotePlayerIds.reserve(remotePlayers.size());
std::optional<RemotePlayer> fallbackCandidate;
@@ -3960,7 +4241,7 @@ namespace F4T::ProxyActorController
if (!HasValidRemoteTransform(remotePlayer)) {
if (auto* slot = FindSlot(remotePlayer.playerId)) {
if (auto* proxy = TryResolveSlotProxy(*slot, *parentCell, *player)) {
MoveSlotProxyToHoldingPosition(*slot, *proxy, ProxyLifecycleState::kNoRemotePlayer);
MarkSlotHeld(*slot, *proxy, ProxyLifecycleState::kHeldNoRemote);
}
}
continue;
@@ -3970,13 +4251,13 @@ namespace F4T::ProxyActorController
if (!sameCellPassed) {
if (auto* slot = FindSlot(remotePlayer.playerId)) {
if (auto* proxy = TryResolveSlotProxy(*slot, *parentCell, *player)) {
MoveSlotProxyToHoldingPosition(*slot, *proxy, ProxyLifecycleState::kRemotePlayerLeftCell);
MarkSlotHeld(*slot, *proxy, ProxyLifecycleState::kHeldLeftCell);
}
}
continue;
}
auto* slot = GetOrCreateSlot(remotePlayer.playerId);
auto* slot = GetOrCreateSlot(remotePlayer, *parentCell, *player, sameCellPassed);
if (!slot) {
continue;
}
@@ -3991,7 +4272,7 @@ namespace F4T::ProxyActorController
if (auto* fallbackProxy = ResolveProxy(*parentCell, *player)) {
MoveProxyToHoldingPosition(
*fallbackProxy,
ProxyLifecycleState::kNoRemotePlayer,
ProxyLifecycleState::kHeldNoRemote,
g_representedPlayerId,
ActiveProxyKind::kPlacedFallback);
}
@@ -4005,6 +4286,8 @@ namespace F4T::ProxyActorController
}
HoldDisconnectedSlots(seenRemotePlayerIds, *parentCell, *player);
MarkReusableSlots();
CorrectHeldSlotPositions(*parentCell, *player);
HoldFallbackIfRepresentedPlayerDisconnected(seenRemotePlayerIds, *parentCell, *player);
if (fallbackCandidate) {