Harden runtime proxy adoption
Ignore deleted actors during proxy candidate selection, delete stale saved runtime proxies before spawning fresh ones, and promote proxies based on loaded 3D instead of camera visibility. Updated docs and changelog accordingly.
This commit is contained in:
@@ -23,6 +23,9 @@ For testing notes, milestone summaries, known issues, and next steps, see [`docs
|
||||
- `timeSync` tracking in `server/fake_client.py` host world-state snapshot output.
|
||||
|
||||
### Fixed
|
||||
- Runtime proxy candidate isolation now ignores actors flagged as deleted, so a freshly spawned proxy is controlled instead of the slot adopting a still-present stale proxy that was marked for deletion the same frame.
|
||||
- Stale saved runtime `COPlayerProxy` actors are deleted before spawning a fresh PlaceAtMe proxy, preventing duplicate visible proxies when an old uncontrolled proxy persisted in a save.
|
||||
- Runtime proxy promotion no longer waits for camera-frustum `IsVisible()` before assigning a newly spawned proxy, preventing remote players from appearing only after the local player turns toward them.
|
||||
- Fixed Papyrus staging path construction so `.pex` and `.psc` files are copied into `build\Fallout 4\Data\Scripts` and `build\Fallout 4\Data\Scripts\Source\User`.
|
||||
- If a server profile exists but its associated save was deleted, joining from the main menu now falls back to first-time character creation and re-binds the profile to the newly created save instead of hard-failing.
|
||||
- Pending first-time profile binding now completes as soon as a save exists even if character-creation phase flags are still active, preventing repeated `coc` first-time launches on reconnect.
|
||||
|
||||
Binary file not shown.
@@ -9,6 +9,93 @@ failed experiments, successful tests, and next steps.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-29 - Stale Runtime Proxy Adoption Fix
|
||||
|
||||
### Summary
|
||||
Fixed a regression where, after deleting a stale saved proxy, the proxy slot adopted the marked-deleted actor instead of the freshly spawned one, leaving a single uncontrolled proxy when client 2 reconnected.
|
||||
|
||||
### Files Changed
|
||||
- `plugin/src/F4TProxyActorController.cpp`
|
||||
- `changelog.md`
|
||||
- `docs/dev-log.md`
|
||||
|
||||
### Details
|
||||
- `MarkAsDeleted()` only flags the stale actor; it is not removed from the cell the same frame, so it remained eligible during PlaceAtMe candidate isolation.
|
||||
- Because stale actors were intentionally excluded from the pre-spawn snapshot, the marked-deleted stale proxy still counted as a "new" candidate and could be isolated as the nearest one. The slot then drove the invisible deleted actor while the real spawn stood idle.
|
||||
- Added a deleted-actor guard to `IsSlotSpawnCandidateActor` so deleted actors are never snapshotted, deleted again, or isolated; isolation can now only pick the live freshly spawned proxy.
|
||||
|
||||
### Testing
|
||||
- Not run in-game in this session.
|
||||
- Expected result: when client 2 reconnects, the single remaining proxy is controlled by client 1's runtime proxy slot.
|
||||
- Recommended manual check: reproduce the reconnect case and confirm the proxy follows client 2's transform/animation; check `CommonwealthOnline.log` for a Stage 4 promotion/restoration on the live spawned actor (not a deleted FormID).
|
||||
|
||||
### Known Issues
|
||||
- In-game confirmation still needed.
|
||||
|
||||
### Next Steps
|
||||
- Re-run the two-client reconnect reproduction after a save/load cycle.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-29 - Stale Runtime Proxy Cleanup
|
||||
|
||||
### Summary
|
||||
Added cleanup for old saved runtime proxy actors so a fresh PlaceAtMe spawn does not leave an uncontrolled duplicate visible in the cell.
|
||||
|
||||
### Files Changed
|
||||
- `plugin/src/F4TProxyActorController.cpp`
|
||||
- `changelog.md`
|
||||
- `docs/dev-log.md`
|
||||
|
||||
### Details
|
||||
- During Stage 4 pre-spawn candidate scanning, runtime-created custom `COPlayerProxy` actors that are not owned by any current slot are marked deleted before a fresh proxy is spawned.
|
||||
- Kept the existing proxy spawning and control flow unchanged; the cleanup does not adopt or start controlling stale actors.
|
||||
- Expanded assigned-proxy detection to include pending spawn handles and cached slot FormIDs so active or settling proxies are not deleted accidentally.
|
||||
|
||||
### Testing
|
||||
- Not run in-game in this session.
|
||||
- Expected result: if a previous saved game contains an old uncontrolled `COPlayerProxy`, it is deleted before the new runtime proxy for client 2 is spawned.
|
||||
- Recommended manual check: load a client/save that previously showed two client-2 proxies, connect client 2, and confirm only the newly spawned proxy remains visible and controlled.
|
||||
|
||||
### Known Issues
|
||||
- In-game confirmation is still needed to verify `MarkAsDeleted()` removes the stale actor immediately enough for the current loaded cell.
|
||||
|
||||
### Next Steps
|
||||
- Run the two-client duplicate-proxy reproduction after a save/load cycle.
|
||||
- Check `CommonwealthOnline.log` for `deleting stale saved runtime proxy candidate` before the fresh Stage 4 spawn.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-29 - Runtime Proxy Spawn Visibility Gate
|
||||
|
||||
### Summary
|
||||
Fixed runtime proxy spawn promotion so a remote player proxy can be assigned once its 3D is loaded, even when the actor is outside the local camera frustum.
|
||||
|
||||
### Files Changed
|
||||
- `plugin/src/F4TProxyActorController.cpp`
|
||||
- `changelog.md`
|
||||
- `docs/dev-log.md`
|
||||
|
||||
### Details
|
||||
- Replaced the Stage 4 promotion readiness check that depended on `Actor::IsVisible()` with a loaded-3D readiness check.
|
||||
- Kept the existing active visibility refresh and frustum nudge path after promotion so off-camera proxies can still be refreshed without blocking assignment.
|
||||
- Updated diagnostic wording from "visible 3D" to "loaded 3D" for the spawn settle wait.
|
||||
|
||||
### Testing
|
||||
- Not run in-game in this session.
|
||||
- Expected result: when client 2 connects behind or outside client 1's current view, client 1 should receive and assign client 2's proxy without needing to rotate 360 degrees or face away.
|
||||
- Recommended manual check: start the server, connect two Fallout 4 instances in the same playable cell, keep client 1 facing away from client 2's position while client 2 connects, and confirm client 2's proxy appears or becomes active without a camera-turn workaround.
|
||||
- Recommended log check: confirm `CommonwealthOnline.log` shows Stage 4 proxy promotion/restoration without repeated "waiting for loaded 3D" messages once the spawned actor has loaded 3D.
|
||||
|
||||
### Known Issues
|
||||
- In-game confirmation is still needed to verify Fallout 4 reliably loads 3D for the PlaceAtMe proxy when spawned off-camera.
|
||||
|
||||
### Next Steps
|
||||
- Run the two-client connection test in `F4TTestCell01` or the current multiplayer test cell.
|
||||
- Verify outgoing transform packets, server relay, remote state parsing, runtime proxy restoration, and visible proxy movement still behave normally.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-29 - `COVault109` Solo Cell
|
||||
|
||||
### Summary
|
||||
|
||||
@@ -1888,15 +1888,12 @@ namespace
|
||||
a_proxy.GetCurrent3D() != nullptr;
|
||||
}
|
||||
|
||||
// Partial Get3D without IsVisible causes headless A-poses if movement or animation
|
||||
// runs too early (especially after PlaceAtMe spawn or load-range reload).
|
||||
bool ProxyMeshReadyForPresentation(const RE::Actor& a_proxy)
|
||||
// IsVisible is frustum-sensitive and can stay false until the local player turns
|
||||
// toward the actor, so use loaded 3D as the promotion gate and let the active
|
||||
// visibility refresh path handle frustum nudges after assignment.
|
||||
bool Proxy3DReadyForPresentation(const RE::Actor& a_proxy)
|
||||
{
|
||||
if (!a_proxy.IsVisible()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return a_proxy.GetFullyLoaded3D() != nullptr || a_proxy.Get3D() != nullptr;
|
||||
return ProxyHasLoaded3D(a_proxy);
|
||||
}
|
||||
|
||||
bool ShouldRetainProxyInPriorLocalCell(
|
||||
@@ -2150,7 +2147,7 @@ namespace
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ProxyMeshReadyForPresentation(a_proxy)) {
|
||||
if (!Proxy3DReadyForPresentation(a_proxy)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2984,6 +2981,15 @@ namespace
|
||||
if (assignedRef && assignedRef.get() == std::addressof(a_actor)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const auto pendingRef = slot.pendingSpawnHandle.get();
|
||||
if (pendingRef && pendingRef.get() == std::addressof(a_actor)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (slot.cachedProxyFormId && *slot.cachedProxyFormId == a_actor.GetFormID()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -4067,10 +4073,33 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
|
||||
return false;
|
||||
}
|
||||
|
||||
// A stale proxy marked for deletion is not removed from the cell the same frame,
|
||||
// so it must never be isolated as the freshly spawned proxy; otherwise the slot
|
||||
// adopts and drives the invisible deleted actor while the real spawn stays idle.
|
||||
// IsDeleted() is non-const in CommonLibF4; const_cast keeps this predicate const.
|
||||
if (const_cast<RE::Actor&>(a_actor).IsDeleted()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto* baseActor = a_actor.GetNPC();
|
||||
return baseActor && baseActor->GetFormID() == a_slot.spawnBaseFormId;
|
||||
}
|
||||
|
||||
bool IsStaleSavedRuntimeProxyCandidate(
|
||||
const ProxyActorSlot& a_slot,
|
||||
const RE::TESObjectREFR& a_ref,
|
||||
const RE::Actor& a_actor,
|
||||
const RE::PlayerCharacter& a_player)
|
||||
{
|
||||
// IsSlotSpawnCandidateActor already rejects deleted actors, so this only matches
|
||||
// live, runtime-created custom proxies that no current slot owns.
|
||||
return a_slot.spawnSource == RuntimeActorBaseSource::kCustomProxy &&
|
||||
a_actor.IsCreated() &&
|
||||
!IsRuntimeProxyRef(a_ref) &&
|
||||
!IsProxyPoolActorAssigned(a_actor) &&
|
||||
IsSlotSpawnCandidateActor(a_slot, a_actor, a_player);
|
||||
}
|
||||
|
||||
void SnapshotPreexistingSlotCandidates(
|
||||
ProxyActorSlot& a_slot,
|
||||
RE::TESObjectCELL& a_currentCell,
|
||||
@@ -4088,6 +4117,20 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
|
||||
return RE::BSContainer::ForEachResult::kContinue;
|
||||
}
|
||||
|
||||
if (IsStaleSavedRuntimeProxyCandidate(a_slot, *a_ref, *actor, a_player)) {
|
||||
const auto staleFormId = actor->GetFormID();
|
||||
const auto position = actor->GetPosition();
|
||||
LogInfoWithLocalPlayerPrefix(std::format(
|
||||
"Stage 4 deleting stale saved runtime proxy candidate before spawning fresh proxy for remote player {}: actor={:08X}, base={:08X}, position={}, distanceFromLocalPlayer={:.2f}.",
|
||||
a_slot.remotePlayerId,
|
||||
staleFormId,
|
||||
actor->GetNPC() ? actor->GetNPC()->GetFormID() : 0,
|
||||
FormatPosition(position),
|
||||
GetDistance(position, playerPosition)));
|
||||
actor->MarkAsDeleted();
|
||||
return RE::BSContainer::ForEachResult::kContinue;
|
||||
}
|
||||
|
||||
const auto position = actor->GetPosition();
|
||||
a_slot.preexistingCandidateRefs.insert(actor->GetFormID());
|
||||
LogInfoWithLocalPlayerPrefix(std::format(
|
||||
@@ -4334,7 +4377,7 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
|
||||
{
|
||||
const auto has3D = a_actor.Get3D() != nullptr;
|
||||
const auto isVisible = a_actor.IsVisible();
|
||||
if (!ProxyMeshReadyForPresentation(a_actor)) {
|
||||
if (!Proxy3DReadyForPresentation(a_actor)) {
|
||||
LogWarningWithLocalPlayerPrefix(std::format(
|
||||
"Stage 4 runtime proxy for remote player {} promoted with partial visibility: Get3D={}, GetFullyLoaded3D={}, IsVisible={}. Continuing to remote movement.",
|
||||
a_slot.remotePlayerId,
|
||||
@@ -4486,12 +4529,12 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!ProxyMeshReadyForPresentation(*actor)) {
|
||||
if (!Proxy3DReadyForPresentation(*actor)) {
|
||||
if (elapsedSeconds < kPlaceAtMeProxyMaxSettleSeconds) {
|
||||
LogThrottledInfo(
|
||||
"stage4_placeatme_proxy_waiting_for_visibility_" + std::to_string(a_slot.remotePlayerId),
|
||||
"stage4_placeatme_proxy_waiting_for_loaded_3d_" + std::to_string(a_slot.remotePlayerId),
|
||||
std::format(
|
||||
"Stage 4 runtime proxy for remote player {} is waiting for visible 3D before promotion: Get3D={}, GetFullyLoaded3D={}, IsVisible={}, settleElapsed={:.1f}s.",
|
||||
"Stage 4 runtime proxy for remote player {} is waiting for loaded 3D before promotion: Get3D={}, GetFullyLoaded3D={}, IsVisible={}, settleElapsed={:.1f}s.",
|
||||
a_slot.remotePlayerId,
|
||||
actor->Get3D() != nullptr,
|
||||
actor->GetFullyLoaded3D() != nullptr,
|
||||
@@ -4502,7 +4545,7 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
|
||||
}
|
||||
|
||||
LogWarningWithLocalPlayerPrefix(std::format(
|
||||
"Stage 4 runtime proxy for remote player {} exceeded max settle wait ({:.1f}s) without visible 3D; promoting anyway.",
|
||||
"Stage 4 runtime proxy for remote player {} exceeded max settle wait ({:.1f}s) without loaded 3D; promoting anyway.",
|
||||
a_slot.remotePlayerId,
|
||||
kPlaceAtMeProxyMaxSettleSeconds));
|
||||
}
|
||||
@@ -5618,7 +5661,7 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
|
||||
slot.visibilityRefreshPending = true;
|
||||
slot.visibilityRefreshLogged = false;
|
||||
slot.proxyObservedLoaded3D = ProxyHasLoaded3D(*proxy);
|
||||
if (!ProxyMeshReadyForPresentation(*proxy)) {
|
||||
if (!Proxy3DReadyForPresentation(*proxy)) {
|
||||
slot.proxyPending3DRecovery = true;
|
||||
slot.proxy3DResetQueued = false;
|
||||
slot.proxy3DRecoverySettling = false;
|
||||
|
||||
Reference in New Issue
Block a user