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:
Binary file not shown.
@@ -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
@@ -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
|
||||
|
||||
@@ -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,25 +1687,10 @@ 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) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1674,6 +1708,32 @@ namespace
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
RE::NEW_REFR_DATA& a_spawnData,
|
||||
RE::TESObjectCELL& a_parentCell,
|
||||
@@ -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);
|
||||
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,7 +5403,31 @@ 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.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 = {};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user