Add proxy positioning API and CK-placed pool

Add a ProxyGameAPI (F4TProxyGameAPI.h/.cpp) exposing PositionRemoteActor which centralizes positioning logic, play-space resolution, MoveRefrToPosition-style moves, and 20Hz throttling bypass for forced moves. Replace ad-hoc SetPosition/SetHeading calls across the proxy controller to use the new API, ensuring correct cell/worldspace migration, logging, and move-rate throttling.

Introduce support for Creation Kit placed proxy refs: detect CK-placed proxies by editor ID prefix or base form, populate a per-cell proxy pool, and prefer assigning CK-placed proxies for runtime remotes (with fallbacks). Update proxy spawn/holding logic (skip smooth frame once, handle settle states, preserve/reuse CK pool entries) and improve worldspace/cell mismatch checks to avoid incorrect movement across play spaces.

Other changes: add CommonwealthOnline.esp resource file, update setup.md with the new local FormID (0x002666) for COPlayerProxy, enable animation sync gating to require loaded 3D, and small logging/message improvements.
This commit is contained in:
2026-06-07 21:24:07 +12:00
parent cc4d8aac5a
commit 9b133b3109
6 changed files with 445 additions and 71 deletions
Binary file not shown.
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include "RE/N/NiPoint3.h"
namespace RE
{
class Actor;
}
namespace F4T::ProxyGameAPI
{
// Move a remote proxy into the local player's loaded play space at world coordinates.
// Uses the engine MoveRefrToPosition native (same path as Papyrus MoveTo) so parent
// cell/worldspace follow the local player across interior/exterior transitions.
// When true, bypass the 20 Hz MoveRefrToPosition throttle (snap/restore/holding moves).
void PositionRemoteActor(
RE::Actor& a_proxy,
const RE::NiPoint3& a_pos,
float a_headingZ,
bool a_force = false);
void PositionRemoteActor(
RE::Actor& a_proxy,
const RE::NiPoint3& a_pos,
const RE::NiPoint3& a_rot,
bool a_force = false);
}
+1 -1
View File
@@ -115,7 +115,7 @@ Use this checklist for the first plugin test:
* [ ] Plugin log shows `[LocalPlayerId=N] Assigned server playerId: N`
* [ ] Plugin logs prefixed remote player updates from another connected client
* [ ] Plugin logs prefixed remote player removal after a disconnect
* [ ] `CommonwealthOnline.esp` is enabled (provides `COPlayerProxy` actor base, local FormID `001733`)
* [ ] `CommonwealthOnline.esp` is enabled (provides `COPlayerProxy` actor base, local FormID `002666`)
* [ ] With two Fallout 4 instances in the same cell, each instance spawns a dynamic
proxy at the other player's networked position
* [ ] Optional regression: `coc F4TTestCell01` with `kEnablePrePlacedProxyPool` enabled
+209 -68
View File
@@ -8,6 +8,7 @@
#include "F4TRemotePlayerState.h"
#include "F4RemoteActionComponent.h"
#include "F4TInterpolationSystem.h"
#include "F4TProxyGameAPI.h"
#include "RE/A/ACTOR_STANCE.h"
#include "RE/A/ActorValue.h"
@@ -43,14 +44,13 @@ namespace
constexpr auto kProxyBaseActorEditorId = "COPlayerProxy";
constexpr auto kTestPluginName = "CommonwealthOnline.esp";
constexpr auto kBaseGamePluginName = "Fallout4.esm";
constexpr RE::TESFormID kProxyBaseActorLocalFormId = 0x001733;
constexpr RE::TESFormID kProxyBaseActorLocalFormId = 0x002666;
constexpr RE::TESFormID kProxyFallbackLocalFormId = 0x0020A2;
constexpr auto kProxyOffsetX = 150.0F;
// Runtime mode (default: global dynamic spawn)
// Runtime mode (default: global dynamic PlaceAtMe spawn + MoveRefrToPosition puppeting)
constexpr bool kEnableGlobalProxyController = true;
// Disabled while PlaceAtMe-backed spawn is primary; direct CreateReferenceAtLocation
// refs persist as invisible actors (Get3D=false) and block PlaceAtMe fallback.
// Dynamic spawn at the local player, positioned via MoveRefrToPosition (DoxyCoSync path).
constexpr bool kEnableDynamicProxySpawn = false;
constexpr bool kEnablePrePlacedProxyPool = false;
constexpr bool kEnablePlacedFallbackProxy = false;
@@ -67,9 +67,9 @@ namespace
constexpr bool kDebugUsePlaceAtMeCandidateIsolation = kEnableStage3SpawnDiagnostics;
constexpr float kDebugPlaceAtMePostSpawnSettleSeconds = 2.0F;
constexpr float kDebugPlaceAtMeCandidateSearchSeconds = 5.0F;
// CreateReferenceAtLocation spawns often fail to load visible 3D in exterior cells.
// PlaceAtMe (player.placeatme) is the proven runtime spawn path for visible proxies.
constexpr bool kUsePlaceAtMeBackedRuntimeProxySpawn = true;
// Legacy test-cell ref editor ID; also accepts COProxyRemotePlayer* refs in any cell.
constexpr auto kCkPlacedProxyEditorIdPrefix = "COProxyRemotePlayer";
// Pre-placed proxies discovered in the current cell (regression only when kEnablePrePlacedProxyPool)
static std::vector<RE::ObjectRefHandle> g_availableProxyPool;
@@ -94,8 +94,9 @@ namespace
std::uint32_t a_oldCellId,
RE::TESObjectCELL& a_currentCell,
const RE::PlayerCharacter& a_player);
bool IsProxyPoolActorAssigned(const RE::Actor& a_actor);
void ApplySlotInitialRemoteSpawnTransform(ProxyActorSlot& a_slot, RE::Actor& a_actor);
// COPlayerProxy in CommonwealthOnline.esp (local FormID 001733, runtime 01001733 at LO 01).
// COPlayerProxy in CommonwealthOnline.esp (local FormID 002666, runtime 01002666 at LO 01).
constexpr bool kDebugPlaceAtMeSpawnUseCustomProxyBase = true;
constexpr bool kDebugPlaceAtMeSpawnFallbackToCodsworth = true;
constexpr float kPlaceAtMeProxySettleSeconds = 2.0F;
@@ -299,6 +300,7 @@ namespace
bool directRemoteSpawnLogged{ false };
bool visibilityRefreshPending{ true };
bool visibilityRefreshLogged{ false };
bool skipSmoothFrameOnce{ false };
std::optional<std::uint32_t> cachedProxyFormId;
RE::NiPointer<RE::Actor> cachedProxyActor;
bool hasLoggedInitialAnimationState{ false };
@@ -674,11 +676,11 @@ namespace
a_optSlot->interpolationComponent.Clear();
}
float headingToApply = a_heading;
// Slot-backed proxies get per-frame rotation smoothing in ApplySmoothFrameMovement.
if (!a_optSlot) {
const bool shouldSnapHeading =
ShouldSnapRemoteMovement(a_remotePlayer.movementType) || distanceDrift >= kLargeDriftSnapDistance;
float headingToApply = a_heading;
if (!shouldSnapHeading) {
const float rotationAlpha = a_shouldSnap ?
kProxyRotationLerpAlpha :
@@ -691,11 +693,15 @@ namespace
a_heading,
rotationAlpha);
}
a_proxy.SetHeading(headingToApply);
}
a_proxy.SetPosition(positionToApply, true);
const bool shouldForcePosition =
a_shouldSnap || distanceDrift >= kLargeDriftSnapDistance;
F4T::ProxyGameAPI::PositionRemoteActor(
a_proxy,
positionToApply,
headingToApply,
shouldForcePosition);
// Character controller velocity is injected once per frame in ApplySmoothFrameMovement
// (derived from the actual applied displacement). Injecting it here too, at packet rate,
@@ -1189,7 +1195,11 @@ namespace
return;
}
a_proxy.SetPosition(kProxyHiddenHoldingPosition, true);
F4T::ProxyGameAPI::PositionRemoteActor(
a_proxy,
kProxyHiddenHoldingPosition,
a_proxy.GetHeading(),
true);
g_proxyLifecycleState = a_reason;
g_proxyHoldReason = a_reason;
g_proxyHoldPlayerId = a_playerId;
@@ -1256,6 +1266,45 @@ namespace
return editorId && a_editorId == editorId;
}
bool HasEditorIdPrefix(const RE::TESForm& a_form, std::string_view a_prefix)
{
if (a_prefix.empty()) {
return false;
}
const auto* editorId = a_form.GetFormEditorID();
if (!editorId) {
return false;
}
const auto id = std::string_view(editorId);
return id.size() >= a_prefix.size() && id.substr(0, a_prefix.size()) == a_prefix;
}
bool IsCkPlacedProxyReference(
const RE::TESObjectREFR& a_ref,
const RE::Actor& a_actor,
std::optional<RE::TESFormID> a_proxyBaseFormId)
{
if (HasEditorId(a_ref, kProxyEditorId) || HasEditorIdPrefix(a_ref, kCkPlacedProxyEditorIdPrefix)) {
return true;
}
const auto refLocalFormId = a_ref.GetFormID() & 0x00FFFFFF;
if (refLocalFormId == kProxyFallbackLocalFormId) {
return true;
}
if (a_proxyBaseFormId) {
const auto* baseActor = a_actor.GetNPC();
if (baseActor && baseActor->GetFormID() == *a_proxyBaseFormId) {
return true;
}
}
return false;
}
bool IsTestPluginLoaded()
{
auto* dataHandler = RE::TESDataHandler::GetSingleton();
@@ -1638,40 +1687,51 @@ namespace
}
const auto localCellId = a_parentCell.GetFormID();
if (localCellId != *remoteCellId) {
LogThrottledInfo(
"remote_cell_mismatch_" + std::to_string(a_remotePlayer.playerId),
std::format(
"Remote player {} is in a different cell: local={:08X}, remote={}. Proxy movement is skipped.",
a_remotePlayer.playerId,
localCellId,
a_remotePlayer.cellId));
return false;
}
if (localCellId == *remoteCellId) {
const auto remoteWorldspaceId = ParseHexFormId(a_remotePlayer.worldspaceId);
const auto localWorldspaceId = GetLocalWorldspaceId(a_player, a_parentCell);
if (!localWorldspaceId && !remoteWorldspaceId) {
return true;
}
if (!localWorldspaceId || !remoteWorldspaceId || *localWorldspaceId != *remoteWorldspaceId) {
LogThrottledInfo(
"remote_worldspace_mismatch_" + std::to_string(a_remotePlayer.playerId),
std::format(
"Remote player {} is in a different worldspace: local={}, remote={}. Proxy movement is skipped.",
a_remotePlayer.playerId,
localWorldspaceId ? std::format("{:08X}", *localWorldspaceId) : "unavailable",
a_remotePlayer.worldspaceId.empty() ? "unavailable" : a_remotePlayer.worldspaceId));
return false;
}
const auto remoteWorldspaceId = ParseHexFormId(a_remotePlayer.worldspaceId);
const auto localWorldspaceId = GetLocalWorldspaceId(a_player, a_parentCell);
if (!localWorldspaceId && !remoteWorldspaceId) {
LogThrottledInfo(
"remote_same_cell_" + std::to_string(a_remotePlayer.playerId),
std::format(
"Remote player {} is in the same cell/worldspace; moving proxy from remote state.",
a_remotePlayer.playerId));
return true;
}
if (!localWorldspaceId || !remoteWorldspaceId || *localWorldspaceId != *remoteWorldspaceId) {
LogThrottledInfo(
"remote_worldspace_mismatch_" + std::to_string(a_remotePlayer.playerId),
std::format(
"Remote player {} is in a different worldspace: local={}, remote={}. Proxy movement is skipped.",
a_remotePlayer.playerId,
localWorldspaceId ? std::format("{:08X}", *localWorldspaceId) : "unavailable",
a_remotePlayer.worldspaceId.empty() ? "unavailable" : a_remotePlayer.worldspaceId));
return false;
// Exterior Commonwealth: same worldspace is enough; adjacent cells load together.
if (a_parentCell.IsExterior()) {
const auto remoteWorldspaceId = ParseHexFormId(a_remotePlayer.worldspaceId);
const auto localWorldspaceId = GetLocalWorldspaceId(a_player, a_parentCell);
if (localWorldspaceId && remoteWorldspaceId && *localWorldspaceId == *remoteWorldspaceId) {
LogThrottledInfo(
"remote_same_worldspace_" + std::to_string(a_remotePlayer.playerId),
std::format(
"Remote player {} is in a different exterior cell but same worldspace; proxy movement continues. localCell={:08X}, remoteCell={}.",
a_remotePlayer.playerId,
localCellId,
a_remotePlayer.cellId));
return true;
}
}
return true;
LogThrottledInfo(
"remote_cell_mismatch_" + std::to_string(a_remotePlayer.playerId),
std::format(
"Remote player {} is in a different location: local={:08X}, remote={}. Proxy movement is skipped.",
a_remotePlayer.playerId,
localCellId,
a_remotePlayer.cellId));
return false;
}
void FillSpawnDataForCell(
@@ -2403,9 +2463,15 @@ namespace
g_availableProxyPool.clear();
// Search current cell for the known pre-placed proxy reference.
std::optional<RE::TESFormID> proxyBaseFormId;
if (auto* proxyBase = ResolveProxyActorBaseByPluginLocalFormId()) {
proxyBaseFormId = proxyBase->GetFormID();
}
// Search current cell for CK-placed proxy references (refs placed in the editor, not
// spawned at runtime). These actors load with full skeleton, head, and behavior graph.
a_cell.ForEachReference([&](RE::TESObjectREFR* a_ref) {
if (!a_ref) {
if (!a_ref || IsRuntimeProxyRef(*a_ref)) {
return RE::BSContainer::ForEachResult::kContinue;
}
@@ -2414,21 +2480,20 @@ namespace
return RE::BSContainer::ForEachResult::kContinue;
}
const auto refFormId = actor->GetFormID();
const auto refLocalFormId = refFormId & 0x00FFFFFF;
const auto isKnownProxyRef =
HasEditorId(*a_ref, kProxyEditorId) ||
refLocalFormId == kProxyFallbackLocalFormId;
if (!isKnownProxyRef) {
if (!IsCkPlacedProxyReference(*a_ref, *actor, proxyBaseFormId)) {
return RE::BSContainer::ForEachResult::kContinue;
}
if (IsProxyPoolActorAssigned(*actor)) {
return RE::BSContainer::ForEachResult::kContinue;
}
auto handle = actor->GetHandle();
g_availableProxyPool.push_back(handle);
LogInfoWithLocalPlayerPrefix(std::format(
"Proxy pool: Found pre-placed proxy {:08X} (localForm={:06X}, editorId={}), total proxies: {}",
"Proxy pool: Found CK-placed proxy {:08X} (localForm={:06X}, editorId={}), total proxies: {}",
actor->GetFormID(),
refLocalFormId,
actor->GetFormID() & 0x00FFFFFF,
GetEditorIdForLog(*a_ref),
g_availableProxyPool.size()));
@@ -2438,13 +2503,17 @@ namespace
g_proxyPoolInitialized = true;
if (g_availableProxyPool.empty()) {
LogWarningWithLocalPlayerPrefix(
"Proxy pool initialized but NO pre-placed proxies found in cell! "
"Expected F4TProxyRemotePlayer01REF or ESP-local form 0x0020A2 in the current cell.");
LogWarningWithLocalPlayerPrefix(std::format(
"Proxy pool initialized but NO CK-placed proxies found in cell {:08X}. "
"Place Actor refs in Creation Kit using COPlayerProxy base and editor IDs like "
"COProxyRemotePlayer01REF (legacy: {}).",
a_cell.GetFormID(),
kProxyEditorId));
} else {
LogInfoWithLocalPlayerPrefix(std::format(
"Proxy pool ready: {} pre-placed proxies available. Max concurrent players: {}",
"Proxy pool ready: {} CK-placed proxies available in cell {:08X}. Max concurrent remotes in this cell: {}.",
g_availableProxyPool.size(),
a_cell.GetFormID(),
g_availableProxyPool.size()));
}
}
@@ -4706,8 +4775,11 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
targetPosition.y = a_remotePlayer.y;
targetPosition.z = a_remotePlayer.z;
a_proxy.SetPosition(targetPosition, true);
a_proxy.SetHeading(a_remotePlayer.angleZ);
F4T::ProxyGameAPI::PositionRemoteActor(
a_proxy,
targetPosition,
a_remotePlayer.angleZ,
true);
g_proxyLifecycleState = ProxyLifecycleState::kActive;
g_proxyHoldReason.reset();
g_proxyHoldPlayerId.reset();
@@ -4803,6 +4875,14 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
const RE::TESObjectCELL& a_currentCell,
const RE::PlayerCharacter& a_player)
{
// Keep the proxy at the local spawn feet during settle; movement/animation before
// 3D finishes loading causes headless A-poses (especially on PlaceAtMe spawns).
if (a_slot.spawnState == PlaceAtMeBackedProxySpawnState::kSettling ||
a_slot.spawnState == PlaceAtMeBackedProxySpawnState::kCustomProxyAttempting ||
a_slot.spawnState == PlaceAtMeBackedProxySpawnState::kCodsworthFallbackAttempting) {
return nullptr;
}
if (!a_slot.cachedProxyActor) {
return nullptr;
}
@@ -4940,8 +5020,13 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
if (auto* pooledProxy = TryGetProxyFromPool()) {
a_slot.proxyHandle = GetProxyActorHandle(*pooledProxy);
a_slot.cachedProxyFormId = pooledProxy->GetFormID();
a_slot.cachedProxyActor = pooledProxy;
a_slot.spawnState = PlaceAtMeBackedProxySpawnState::kPromoted;
a_slot.spawnSucceeded = true;
a_slot.spawnAttemptInProgress = false;
a_slot.spawnFailed = false;
LogInfoWithLocalPlayerPrefix(std::format(
"Assigned proxy from pre-placed pool {:08X} to remote player {}",
"Assigned CK-placed proxy {:08X} to remote player {} from cell pool.",
pooledProxy->GetFormID(),
a_slot.remotePlayerId));
return pooledProxy;
@@ -5126,7 +5211,7 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
return;
}
a_proxy.SetPosition(holdingPosition, true);
F4T::ProxyGameAPI::PositionRemoteActor(a_proxy, holdingPosition, a_proxy.GetHeading(), true);
LogThrottledInfo(
"held_runtime_proxy_position_corrected_" + std::to_string(a_slot.remotePlayerId),
std::format(
@@ -5165,7 +5250,15 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
ClearSlotVisualSneakIfApplied(slot, *proxy, "local cell change");
ClearSlotProxyAnimationIfApplied(slot, *proxy, "local cell change");
NeutralizeProxyActor(slot, *proxy, a_player, ProxyNeutralizationReason::kHolding);
proxy->MarkAsDeleted();
if constexpr (kEnablePrePlacedProxyPool) {
MoveProxyToHoldingPosition(
*proxy,
ProxyLifecycleState::kHeldLeftCell,
slot.remotePlayerId,
ActiveProxyKind::kPlacedFallback);
} else {
proxy->MarkAsDeleted();
}
slot.proxyHandle.reset();
slot.cachedProxyActor = nullptr;
@@ -5209,7 +5302,11 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
a_slot,
a_proxy,
a_reason == ProxyLifecycleState::kHeldDisconnected ? "disconnect hold" : "hold");
a_proxy.SetPosition(GetSlotHoldingPosition(a_slot), true);
F4T::ProxyGameAPI::PositionRemoteActor(
a_proxy,
GetSlotHoldingPosition(a_slot),
a_proxy.GetHeading(),
true);
if (auto* player = RE::PlayerCharacter::GetSingleton()) {
NeutralizeProxyActor(a_slot, a_proxy, *player, ProxyNeutralizationReason::kHolding);
}
@@ -5306,8 +5403,32 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
{
const auto targetPosition = GetRuntimeProxyVisibleTargetPosition(a_slot, a_remotePlayer, a_player);
ApplyRuntimeProxyTransform(a_proxy, targetPosition, a_remotePlayer.angleZ, a_remotePlayer, true, &a_slot);
a_slot.lifecycleState = ProxyLifecycleState::kActive;
a_slot.targetPosition = targetPosition;
a_slot.targetHeading = a_remotePlayer.angleZ;
a_slot.puppetDirection = a_remotePlayer.angleZ;
a_slot.lastRemotePlayerSnapshot = a_remotePlayer;
a_slot.puppetGraphSpeed = -1.0F;
if (a_remotePlayer.isMoving && a_remotePlayer.movementSpeed >= 1.0F) {
a_slot.puppetGraphSpeed = F4T::ProxyAnimationSync::ResolveLocomotionGraphSpeed(
a_remotePlayer.movementSpeed,
a_remotePlayer.animationGraphSpeed,
a_remotePlayer.isMoving,
a_remotePlayer.isSprinting);
}
F4T::ProxyPuppet::SetProxyLocomotionState(
a_proxy.GetFormID(),
a_slot.puppetGraphSpeed,
a_slot.puppetDirection);
F4T::ProxyGameAPI::PositionRemoteActor(
a_proxy,
targetPosition,
a_remotePlayer.angleZ,
true);
a_slot.skipSmoothFrameOnce = true;
a_slot.lifecycleState = ProxyLifecycleState::kActive;
a_slot.holdReason.reset();
a_slot.heldSinceTime = {};
a_slot.reusableLogged = false;
@@ -5706,6 +5827,9 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
auto* proxy = TryResolveSlotProxy(a_slot, a_parentCell, a_player);
if (!proxy) {
MaintainSlotPlaceAtMeSpawn(a_slot, a_parentCell, a_player);
if (a_slot.spawnState == PlaceAtMeBackedProxySpawnState::kSettling) {
return false;
}
proxy = TryResolveSlotProxy(a_slot, a_parentCell, a_player);
if (!proxy) {
return false;
@@ -5893,6 +6017,11 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
continue;
}
if (slot.skipSmoothFrameOnce) {
slot.skipSmoothFrameOnce = false;
continue;
}
auto* proxy = TryResolveSlotProxy(slot, *player->GetParentCell(), *player);
if (!proxy || !proxy->currentProcess) {
continue;
@@ -5932,7 +6061,11 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
// Animation follows network state, not interpolation remainder. When the remote
// stops, pin position immediately so we don't jog-in-place while lerping.
if (!remoteLocomoting || distanceToTarget < 0.1F) {
proxy->SetPosition(slot.targetPosition, true);
F4T::ProxyGameAPI::PositionRemoteActor(
*proxy,
slot.targetPosition,
headingToApply,
true);
slot.puppetGraphSpeed = -1.0F;
F4T::ProxyPuppet::SetProxyLocomotionState(
proxy->GetFormID(),
@@ -5964,9 +6097,15 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
};
// Move through the character controller so FO4's motion-feedback path sees
// real velocity (within-state walk cadence). SetPosition alone leaves ~0
// controller velocity, so every sub-run pace used the same authored walk.
proxy->Move(frameDeltaSeconds, frameDelta, true);
// real velocity (within-state walk cadence). PositionRemoteActor uses
// SetPosition(..., true) so the Havok body follows the lerp each frame.
// Per-frame lerp must bypass the 20 Hz MoveRefrToPosition throttle; otherwise
// puppet motion feedback runs every frame but the actor stays at PlaceAtMe spawn.
F4T::ProxyGameAPI::PositionRemoteActor(
*proxy,
nextPosition,
headingToApply,
true);
F4T::ProxyPuppet::ApplyProxyLocomotionFrame(
*proxy,
frameDelta,
@@ -6108,7 +6247,9 @@ namespace F4T::ProxyActorController
if (!g_stage4StartedLogged) {
LogInfoWithLocalPlayerPrefix(std::format(
"Runtime proxy actor manager active: one runtime proxy slot per remote playerId, max runtime proxies={}.",
"Proxy actor manager active (PlaceAtMe={}, MoveRefrToPosition positioning, CK pool={}), max slots={}.",
kUsePlaceAtMeBackedRuntimeProxySpawn,
kEnablePrePlacedProxyPool,
kMaxRuntimeProxyActors));
g_stage4StartedLogged = true;
}
@@ -6116,7 +6257,7 @@ namespace F4T::ProxyActorController
const auto remotePlayers = GetSortedRemotePlayerSnapshot();
if constexpr (kEnablePrePlacedProxyPool) {
if (!g_proxyPoolInitialized && IsTestCell(*parentCell)) {
if (!g_proxyPoolInitialized) {
InitializeProxyPool(*parentCell, *player);
}
}
+13 -2
View File
@@ -26,8 +26,8 @@
namespace
{
// Master gate; sub-gates allow incremental rollback.
// Enabled now that proxies use CK-placed actors with active AI (g_preserveProxyAIForAnimationTesting).
// The old reason for disabling (suppressed AI) no longer applies.
// Enabled for dynamic PlaceAtMe proxies positioned via MoveRefrToPosition; puppet hook
// drives graph Speed/Direction because controller velocity stays near zero.
constexpr bool kEnableProxyVisualAnimationSync = true;
constexpr bool kEnableProxyLocomotionSync = true;
// Enabled now that the authoritative FO4 graph name is known: sneak is the INT32
@@ -90,8 +90,19 @@ namespace
return std::fabs(a_lhs - a_rhs) > kSpeedEpsilon;
}
bool ProxyHasLoaded3D(const RE::Actor& a_proxy)
{
return a_proxy.Get3D() != nullptr ||
a_proxy.GetFullyLoaded3D() != nullptr ||
a_proxy.GetCurrent3D() != nullptr;
}
bool CanWriteProxyGraph(RE::Actor& a_proxy, std::uint32_t a_remotePlayerId, std::uint32_t a_actorId)
{
if (!ProxyHasLoaded3D(a_proxy)) {
return false;
}
auto* graphHolder = static_cast<RE::IAnimationGraphManagerHolder*>(&a_proxy);
RE::BSTSmartPointer<RE::BSAnimationGraphManager> graphManager;
if (!graphHolder->GetAnimationGraphManagerImpl(graphManager) || !graphManager) {
+195
View File
@@ -0,0 +1,195 @@
#include "pch.h"
#include "F4TProxyGameAPI.h"
#include "F4TNetworking.h"
#include "RE/P/PlayerCharacter.h"
#include "RE/T/TESObjectCELL.h"
#include "RE/T/TESWorldSpace.h"
#include <chrono>
#include <cmath>
#include <format>
#include <unordered_map>
namespace
{
struct ActorMoveState
{
std::uint32_t callCount{ 0 };
std::chrono::steady_clock::time_point lastTime{};
float lastX{ 0.0F };
float lastY{ 0.0F };
float lastZ{ 0.0F };
};
static std::unordered_map<RE::Actor*, ActorMoveState> g_moveState;
constexpr double kMoveIntervalSeconds = 1.0 / 20.0;
constexpr float kMoveDeltaSq = 0.25F;
constexpr std::uint32_t kForcedMoveCalls = 5;
std::string GetLocalPlayerLogMessage(std::string_view a_message)
{
return F4T::Networking::GetLocalPlayerLogPrefix() + " " + std::string(a_message);
}
void LogThrottledCellFix(
RE::Actor& a_proxy,
const RE::NiPoint3& a_pos,
RE::TESObjectCELL* a_cell,
RE::TESWorldSpace* a_worldSpace,
std::uint32_t a_callCount,
bool a_migratedPlaySpace)
{
if (a_callCount > kForcedMoveCalls && a_callCount % 300 != 0) {
return;
}
const auto cellId = a_cell ? a_cell->GetFormID() : 0U;
const auto worldId = a_worldSpace ? a_worldSpace->GetFormID() : 0U;
REX::INFO(std::string_view{ GetLocalPlayerLogMessage(std::format(
"PositionRemoteActor proxy={:08X} call#{} pos=({:.1f},{:.1f},{:.1f}) mode={} ws={:08X} cell={:08X} migrated={}.",
a_proxy.GetFormID(),
a_callCount,
a_pos.x,
a_pos.y,
a_pos.z,
a_worldSpace ? "exterior" : (a_cell ? "interior" : "no-anchor"),
worldId,
cellId,
a_migratedPlaySpace)) });
}
void ResolveLocalPlaySpace(
RE::TESObjectCELL*& a_outCell,
RE::TESWorldSpace*& a_outWorldSpace)
{
a_outCell = nullptr;
a_outWorldSpace = nullptr;
const auto* player = RE::PlayerCharacter::GetSingleton();
if (!player) {
return;
}
const auto* playerCell = player->GetParentCell();
if (!playerCell) {
return;
}
if (playerCell->IsExterior()) {
a_outWorldSpace = playerCell->worldSpace;
if (!a_outWorldSpace) {
a_outWorldSpace = player->cachedWorldspace;
}
return;
}
a_outCell = const_cast<RE::TESObjectCELL*>(playerCell);
}
bool IsFinitePosition(const RE::NiPoint3& a_pos)
{
return std::isfinite(a_pos.x) && std::isfinite(a_pos.y) && std::isfinite(a_pos.z);
}
bool ProxyInLocalPlaySpace(
const RE::Actor& a_proxy,
RE::TESObjectCELL* a_localCell,
RE::TESWorldSpace* a_localWorldSpace)
{
const auto* proxyCell = a_proxy.GetParentCell();
if (!proxyCell) {
return false;
}
if (a_localWorldSpace) {
return proxyCell->IsExterior() && proxyCell->worldSpace == a_localWorldSpace;
}
return a_localCell && proxyCell->GetFormID() == a_localCell->GetFormID();
}
void ApplyPositionInPlaySpace(
RE::Actor& a_proxy,
const RE::NiPoint3& a_pos,
const RE::NiPoint3& a_rot,
RE::TESObjectCELL* a_cell,
RE::TESWorldSpace* a_worldSpace,
std::uint32_t a_callCount)
{
const bool migrated =
!ProxyInLocalPlaySpace(a_proxy, a_cell, a_worldSpace);
if (migrated) {
a_proxy.MoveRefToNewSpace(a_cell, a_worldSpace);
}
// SetLocationOnReference only updates TESObjectREFR::pos; it does not sync the
// Havok character controller. PlaceAtMe proxies snap back to spawn on the next
// AI tick while GetPosition() briefly reports the teleported coords.
a_proxy.SetPosition(a_pos, true);
a_proxy.SetAngleOnReference(a_rot);
a_proxy.SetHeading(a_rot.z);
LogThrottledCellFix(a_proxy, a_pos, a_cell, a_worldSpace, a_callCount, migrated);
}
}
namespace F4T::ProxyGameAPI
{
void PositionRemoteActor(RE::Actor& a_proxy, const RE::NiPoint3& a_pos, float a_headingZ, bool a_force)
{
PositionRemoteActor(a_proxy, a_pos, RE::NiPoint3{ 0.0F, 0.0F, a_headingZ }, a_force);
}
void PositionRemoteActor(
RE::Actor& a_proxy,
const RE::NiPoint3& a_pos,
const RE::NiPoint3& a_rot,
bool a_force)
{
if (!IsFinitePosition(a_pos)) {
REX::WARN(std::string_view{ GetLocalPlayerLogMessage(std::format(
"PositionRemoteActor skipped for proxy {:08X}: non-finite target position.",
a_proxy.GetFormID())) });
return;
}
RE::TESObjectCELL* cell = nullptr;
RE::TESWorldSpace* worldSpace = nullptr;
ResolveLocalPlaySpace(cell, worldSpace);
if (!cell && !worldSpace) {
REX::WARN(std::string_view{ GetLocalPlayerLogMessage(std::format(
"PositionRemoteActor skipped for proxy {:08X}: local play space unavailable.",
a_proxy.GetFormID())) });
return;
}
auto& moveState = g_moveState[&a_proxy];
++moveState.callCount;
const float dx = a_pos.x - moveState.lastX;
const float dy = a_pos.y - moveState.lastY;
const float dz = a_pos.z - moveState.lastZ;
const float deltaSq = (dx * dx) + (dy * dy) + (dz * dz);
const auto now = std::chrono::steady_clock::now();
const bool forceMove = a_force || moveState.callCount <= kForcedMoveCalls;
const bool timedOut = moveState.lastTime.time_since_epoch().count() == 0 ||
std::chrono::duration<double>(now - moveState.lastTime).count() >= kMoveIntervalSeconds;
const bool movedEnough = deltaSq >= kMoveDeltaSq;
if (!forceMove && !(timedOut && movedEnough)) {
return;
}
moveState.lastX = a_pos.x;
moveState.lastY = a_pos.y;
moveState.lastZ = a_pos.z;
moveState.lastTime = now;
ApplyPositionInPlaySpace(a_proxy, a_pos, a_rot, cell, worldSpace, moveState.callCount);
}
}