Stabilize proxy visibility and smooth movement

Fix proxy orbiting and improve motion/visibility handling.

- Capture a per-slot visibleDebugAnchorPosition (reset on reassignment) to keep pre-placed proxies visually stable and avoid orbiting when the local player moves.
- Remove the debug +20Z offset so pre-placed actors remain grounded.
- Add GetRuntimeProxyVisibleTargetPosition to compute a stable visible target (anchored base + scaled remote delta).
- Replace teleport-heavy updates with smooth movement: add InjectProxyMovementVelocity to publish velocity into bhkCharacterController, call Actor::Move() per-frame and inject velocity for animation blending.
- Prefer reusing pre-placed pool proxies before attempting dynamic spawn.
- Implement dynamic spawn via NEW_REFR_DATA / TESDataHandler::CreateReferenceAtLocation, apply debug visibility to spawned actors, and store handles in the dynamic pool.
- Harden debug visibility setup: explicitly enable/disable 3D, alpha, display geometry, set 3D update flags and queue a TaskQueueInterface 3D update; expand debug logging.
- Remove unsafe currentProcess target-clearing code and other minor cleanups.
- Add dev-log entries describing Phase 6 debugging and fixes.
This commit is contained in:
2026-06-03 18:27:06 +12:00
parent fb59eea0a2
commit b128729ebf
2 changed files with 276 additions and 83 deletions
+205 -82
View File
@@ -13,7 +13,11 @@
#include "RE/C/COMMAND_TYPE.h"
#include "RE/C/Console.h"
#include "RE/H/hkVector4.h"
#include "RE/N/NEW_REFR_DATA.h"
#include "RE/P/PTYPE.h"
#include "RE/R/RESET_3D_FLAGS.h"
#include "RE/T/TESDataHandler.h"
#include "RE/T/TaskQueueInterface.h"
#include <algorithm>
#include <charconv>
@@ -75,7 +79,7 @@ namespace
constexpr bool kDebugForceRuntimeProxyNearLocalPlayer = true;
constexpr bool kDebugHoldRuntimeProxyNearLocalPlayer = true;
constexpr float kDebugRuntimeProxyHoldSeconds = 15.0F;
constexpr auto kDebugRuntimeProxyOffsetZ = 20.0F;
constexpr auto kDebugRuntimeProxyOffsetZ = 0.0F;
constexpr auto kDebugRuntimeProxyScale = 1.50F;
constexpr float kProxyPositionLerpAlpha = 0.15F;
constexpr std::size_t kMaxRuntimeProxyActors = 4;
@@ -240,6 +244,7 @@ namespace
std::uint32_t spawnBaseFormId{ 0 };
std::unordered_set<std::uint32_t> preexistingCandidateRefs;
std::optional<RE::NiPoint3> initialRemoteSpawnPosition;
std::optional<RE::NiPoint3> visibleDebugAnchorPosition;
std::chrono::steady_clock::time_point candidateSearchStartTime{};
std::chrono::steady_clock::time_point settleStartTime{};
std::chrono::steady_clock::time_point heldSinceTime{};
@@ -488,6 +493,32 @@ namespace
return std::sqrt((deltaX * deltaX) + (deltaY * deltaY) + (deltaZ * deltaZ));
}
void InjectProxyMovementVelocity(RE::bhkCharacterController& a_charController, const RE::NiPoint3& a_deltaPos, float a_deltaSeconds)
{
if (a_deltaSeconds <= 0.0F) {
return;
}
const auto distance = a_deltaPos.Length();
if (distance <= 0.001F) {
const RE::hkVector4f stoppedVelocity{ 0.0F, 0.0F, 0.0F, 0.0F };
a_charController.SetLinearVelocityImpl(stoppedVelocity);
return;
}
constexpr float kMaxInjectedSpeed = 400.0F;
const auto unclampedSpeed = distance / a_deltaSeconds;
const auto clampedSpeed = unclampedSpeed > kMaxInjectedSpeed ? kMaxInjectedSpeed : unclampedSpeed;
const auto direction = a_deltaPos / distance;
const RE::hkVector4f velocity{
direction.x * clampedSpeed,
direction.y * clampedSpeed,
direction.z * clampedSpeed,
0.0F
};
a_charController.SetLinearVelocityImpl(velocity);
}
// Hybrid movement strategy for AI-driven animations
// Phase 1: Let the proxy's natural AI/character controller drive movement
// This allows Move() to be called, which updates velocity and plays animations
@@ -567,18 +598,7 @@ namespace
if (distanceDrift > 0.001F) {
auto* charController = a_proxy.Move(0.016F, deltaPos, false);
if (charController) {
// Scale delta to units-per-second so the controller sees walking velocity
const float speed = distanceDrift / 0.016F;
constexpr float MAX_INJECT_SPEED = 400.0F;
const float clampedSpeed = speed > MAX_INJECT_SPEED ? MAX_INJECT_SPEED : speed;
const RE::NiPoint3 dir = distanceDrift > 0.0F ? (deltaPos / distanceDrift) : RE::NiPoint3{};
const RE::hkVector4f vel{
dir.x * clampedSpeed,
dir.y * clampedSpeed,
dir.z * clampedSpeed,
0.0F
};
charController->SetLinearVelocityImpl(vel);
InjectProxyMovementVelocity(*charController, deltaPos, 0.016F);
}
}
}
@@ -712,30 +732,6 @@ namespace
auto packageTypeForLog = -1;
if (auto* process = a_proxy.currentProcess) {
auto clearHandle = [&](auto& a_handle) {
if (!a_handle) {
return false;
}
a_handle.reset();
return true;
};
processTargetsCleared |= clearHandle(process->target);
processTargetsCleared |= clearHandle(process->followTarget);
processTargetsCleared |= clearHandle(process->genericLocation);
processTargetsCleared |= clearHandle(process->genericSecondLocation);
processTargetsCleared |= clearHandle(process->patrolLocation);
processTargetsCleared |= clearHandle(process->currentPackage.target);
if (process->acquireObject) {
process->acquireObject = nullptr;
processTargetsCleared = true;
}
if (process->savedAcquireObject) {
process->savedAcquireObject = nullptr;
processTargetsCleared = true;
}
const auto commandType = process->GetCommandType();
if (commandType != RE::COMMAND_TYPE::kNone) {
process->SetCommandType(RE::COMMAND_TYPE::kNone);
@@ -1657,15 +1653,47 @@ namespace
}
const auto debugPosition = GetRuntimeProxyDebugPosition(a_player);
const auto wasDisabled = a_proxy.IsDisabled();
const auto had3D = a_proxy.Get3D() != nullptr || a_proxy.GetFullyLoaded3D() != nullptr || a_proxy.GetCurrent3D() != nullptr;
const auto wasVisible = a_proxy.IsVisible();
if (wasDisabled) {
a_proxy.Enable(false);
}
a_proxy.SetPosition(debugPosition, true);
a_proxy.SetHeading(a_player.data.angle.z);
a_proxy.SetScale(kDebugRuntimeProxyScale);
a_proxy.SetAlpha(1.0F);
a_proxy.UpdateAlpha();
a_proxy.SetDisplayGeometry(true);
a_proxy.Update3DPosition(true);
a_proxy.MoveHavok(true);
a_proxy.Set3DUpdateFlag(RE::RESET_3D_FLAGS::kModel);
a_proxy.Set3DUpdateFlag(RE::RESET_3D_FLAGS::kSkin);
a_proxy.Set3DUpdateFlag(RE::RESET_3D_FLAGS::kSkeleton);
a_proxy.Set3DUpdateFlag(RE::RESET_3D_FLAGS::kInitDefault);
if (auto* taskQueue = RE::TaskQueueInterface::GetSingleton()) {
const auto updateFlags =
static_cast<std::uint32_t>(RE::RESET_3D_FLAGS::kModel) |
static_cast<std::uint32_t>(RE::RESET_3D_FLAGS::kSkin) |
static_cast<std::uint32_t>(RE::RESET_3D_FLAGS::kSkeleton) |
static_cast<std::uint32_t>(RE::RESET_3D_FLAGS::kInitDefault);
taskQueue->QueueUpdate3D(std::addressof(a_proxy), updateFlags);
}
const auto has3D = a_proxy.Get3D() != nullptr || a_proxy.GetFullyLoaded3D() != nullptr || a_proxy.GetCurrent3D() != nullptr;
const auto isVisible = a_proxy.IsVisible();
LogInfoWithLocalPlayerPrefix(std::format(
"Runtime proxy debug visibility mode active: forcing runtime proxy near local player. debugPosition={}, scale={:.2f}.",
"Runtime proxy debug visibility mode active: forcing runtime proxy near local player. debugPosition={}, scale={:.2f}, wasDisabled={}, had3D={}, wasVisible={}, nowDisabled={}, has3D={}, isVisible={}.",
FormatPosition(debugPosition),
kDebugRuntimeProxyScale));
kDebugRuntimeProxyScale,
wasDisabled,
had3D,
wasVisible,
a_proxy.IsDisabled(),
has3D,
isVisible));
}
struct RuntimeProxyVisibilityHoldTiming
@@ -2203,32 +2231,19 @@ namespace
return nullptr;
}
// Phase 4: Dynamic spawning implementation
// NOTE: Disabled for Phase 6 testing - API verification needed
// TODO: Phase 7 - Fix CommonLibF4 API calls for dynamic spawning
//
// Original code disabled - API calls need verification
/*
RE::TESNPC* proxyBase = nullptr;
// First try to find custom proxy base in ESP
const auto* dataHandler = RE::TESDataHandler::GetSingleton();
auto* dataHandler = RE::TESDataHandler::GetSingleton();
if (!dataHandler) {
LogWarningWithLocalPlayerPrefix(
"SpawnDynamicProxyActor: Cannot spawn proxy; data handler unavailable.");
return nullptr;
}
// Try to load custom proxy base (F4T_RemotePlayerProxy)
const auto customFile = dataHandler->LookupLoadedModByName(kTestPluginName);
if (customFile && customFile->compileIndex != 0xFF) {
const auto customBaseFormId = (customFile->compileIndex << 24) | kProxyBaseActorLocalFormId;
proxyBase = RE::TESForm::LookupByID<RE::TESNPC>(customBaseFormId);
}
// Fallback to Codsworth if custom proxy not available
auto* proxyBase = ResolveProxyActorBase();
if (!proxyBase) {
proxyBase = RE::TESForm::LookupByID<RE::TESNPC>(kDebugPlaceAtMeActorBaseFormId);
LogWarningWithLocalPlayerPrefix(std::format(
"SpawnDynamicProxyActor: Custom proxy base unavailable for remote player {}; trying Codsworth diagnostic base.",
a_remotePlayerId));
proxyBase = ResolveCodsworthBaseByAbsoluteFormId();
}
if (!proxyBase) {
@@ -2237,8 +2252,76 @@ namespace
a_remotePlayerId));
return nullptr;
}
*/
return nullptr; // Phase 6: Disabled - API verification needed for Phase 7
auto* boundObject = proxyBase->As<RE::TESBoundObject>();
if (!boundObject) {
LogWarningWithLocalPlayerPrefix(std::format(
"SpawnDynamicProxyActor: Cannot spawn proxy for remote player {}; actor base {:08X} is not a TESBoundObject.",
a_remotePlayerId,
proxyBase->GetFormID()));
return nullptr;
}
auto* parentCell = a_player.GetParentCell();
if (!parentCell) {
LogWarningWithLocalPlayerPrefix(std::format(
"SpawnDynamicProxyActor: Cannot spawn proxy for remote player {}; player parent cell unavailable.",
a_remotePlayerId));
return nullptr;
}
const auto spawnPosition = GetRuntimeProxyDebugPosition(a_player);
RE::NiPoint3 spawnDirection{};
spawnDirection.z = a_player.data.angle.z;
RE::NEW_REFR_DATA spawnData;
spawnData.object = boundObject;
spawnData.location = spawnPosition;
spawnData.direction = spawnDirection;
spawnData.interior = parentCell;
spawnData.world = nullptr;
spawnData.reference = nullptr;
spawnData.forcePersist = false;
spawnData.initializeScripts = true;
spawnData.initiallyDisabled = false;
const auto spawnedHandle = dataHandler->CreateReferenceAtLocation(spawnData);
if (!spawnedHandle) {
LogWarningWithLocalPlayerPrefix(std::format(
"SpawnDynamicProxyActor: CreateReferenceAtLocation failed for remote player {} with base {:08X}.",
a_remotePlayerId,
proxyBase->GetFormID()));
return nullptr;
}
const auto spawnedRef = spawnedHandle.get();
auto* spawnedActor = spawnedRef ? spawnedRef->As<RE::Actor>() : nullptr;
if (!spawnedActor) {
LogWarningWithLocalPlayerPrefix(std::format(
"SpawnDynamicProxyActor: Created reference for remote player {} did not resolve as actor.",
a_remotePlayerId));
return nullptr;
}
if (std::addressof(*spawnedActor) == std::addressof(a_player)) {
LogWarningWithLocalPlayerPrefix(std::format(
"SpawnDynamicProxyActor: Created reference {:08X} unexpectedly resolved as the local player.",
spawnedActor->GetFormID()));
return nullptr;
}
ApplyRuntimeProxyDebugVisibility(*spawnedActor, a_player);
g_dynamicProxyPool[a_remotePlayerId] = spawnedHandle;
LogInfoWithLocalPlayerPrefix(std::format(
"SpawnDynamicProxyActor: Spawned dynamic proxy actor {:08X} for remote player {} with base {:08X} at {}.",
spawnedActor->GetFormID(),
a_remotePlayerId,
proxyBase->GetFormID(),
FormatPosition(spawnPosition)));
return spawnedActor;
}
// Phase 4.1: Retrieve or spawn proxy for a remote player
@@ -3279,6 +3362,41 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
};
}
RE::NiPoint3 GetRuntimeProxyVisibleTargetPosition(
ProxyActorSlot& a_slot,
const RemotePlayer& a_remotePlayer,
const RE::PlayerCharacter& a_player)
{
if constexpr (!kDebugForceRuntimeProxyNearLocalPlayer) {
return GetRemotePlayerPosition(a_remotePlayer);
}
if (!a_slot.visibleDebugAnchorPosition) {
a_slot.visibleDebugAnchorPosition = GetRuntimeProxyDebugPosition(a_player);
LogInfoWithLocalPlayerPrefix(std::format(
"Runtime proxy visible debug anchor captured for remote player {}: anchor={}.",
a_remotePlayer.playerId,
FormatPosition(*a_slot.visibleDebugAnchorPosition)));
}
const auto debugBasePosition = *a_slot.visibleDebugAnchorPosition;
if (!a_slot.initialRemoteSpawnPosition) {
a_slot.initialRemoteSpawnPosition = GetRemotePlayerPosition(a_remotePlayer);
a_slot.initialRemoteSpawnHeading = a_remotePlayer.angleZ;
return debugBasePosition;
}
auto remoteDelta = GetRemotePlayerPosition(a_remotePlayer) - *a_slot.initialRemoteSpawnPosition;
const auto remoteDeltaLength = remoteDelta.Length();
if (remoteDeltaLength > 0.001F) {
constexpr auto kVisibleRemoteDeltaScale = 0.35F;
constexpr auto kVisibleRemoteDeltaMax = 250.0F;
const auto scaledLength = std::min(remoteDeltaLength * kVisibleRemoteDeltaScale, kVisibleRemoteDeltaMax);
remoteDelta = (remoteDelta / remoteDeltaLength) * scaledLength;
}
return debugBasePosition + remoteDelta;
}
void SetSlotInitialRemoteSpawnTransform(ProxyActorSlot& a_slot, const RemotePlayer& a_remotePlayer)
{
a_slot.initialRemoteSpawnPosition = GetRemotePlayerPosition(a_remotePlayer);
@@ -4387,7 +4505,15 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
const RE::PlayerCharacter& a_player)
{
if (!a_slot.proxyHandle) {
// Phase 4: Try dynamic spawn first (new model)
// Phase 6 validation: prefer pre-placed actors because Creation Kit refs
// already have loaded 3D, collision, AI process, and animation state.
if (auto* pooledProxy = TryGetProxyFromPool()) {
a_slot.proxyHandle = pooledProxy->GetHandle();
LogInfoWithLocalPlayerPrefix(std::format(
"Assigned proxy from pre-placed pool {:08X} to remote player {}",
pooledProxy->GetFormID(),
a_slot.remotePlayerId));
} else
if (g_dynamicProxyPoolEnabled) {
if (auto* dynamicProxy = GetOrSpawnDynamicProxy(const_cast<RE::PlayerCharacter&>(a_player), a_slot.remotePlayerId)) {
a_slot.proxyHandle = dynamicProxy->GetHandle();
@@ -4397,15 +4523,6 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
a_slot.remotePlayerId));
return dynamicProxy;
}
}
// Fallback: Try to grab a proxy from the pre-placed pool
if (auto* pooledProxy = TryGetProxyFromPool()) {
a_slot.proxyHandle = pooledProxy->GetHandle();
LogInfoWithLocalPlayerPrefix(std::format(
"Assigned proxy from pre-placed pool {:08X} to remote player {}",
pooledProxy->GetFormID(),
a_slot.remotePlayerId));
} else {
return nullptr;
}
@@ -4670,10 +4787,7 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
const RE::PlayerCharacter& a_player,
bool a_sameCellPassed)
{
RE::NiPoint3 targetPosition{};
targetPosition.x = a_remotePlayer.x;
targetPosition.y = a_remotePlayer.y;
targetPosition.z = a_remotePlayer.z;
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;
@@ -4685,9 +4799,12 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
a_slot.remoteStateMovedLogged = false;
LogInfoWithLocalPlayerPrefix(std::format(
"Runtime proxy actor {:08X} restored/reused for remote player {} at X={:.2f}, Y={:.2f}, Z={:.2f}.",
"Runtime proxy actor {:08X} restored/reused for remote player {} at visible X={:.2f}, Y={:.2f}, Z={:.2f} from remote X={:.2f}, Y={:.2f}, Z={:.2f}.",
a_proxy.GetFormID(),
a_remotePlayer.playerId,
targetPosition.x,
targetPosition.y,
targetPosition.z,
a_remotePlayer.x,
a_remotePlayer.y,
a_remotePlayer.z));
@@ -4831,10 +4948,7 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
a_proxy.GetFormID()));
}
RE::NiPoint3 targetPosition;
targetPosition.x = a_remotePlayer.x;
targetPosition.y = a_remotePlayer.y;
targetPosition.z = a_remotePlayer.z;
const auto targetPosition = GetRuntimeProxyVisibleTargetPosition(a_slot, a_remotePlayer, a_player);
// Store latest target for smooth frame movement
a_slot.targetPosition = targetPosition;
@@ -4953,6 +5067,7 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
}
slotValue.previousRemotePlayerId = previousRemotePlayerId;
slotValue.remotePlayerId = a_remotePlayer.playerId;
slotValue.visibleDebugAnchorPosition.reset();
slotValue.lifecycleState = ProxyLifecycleState::kHeldNoRemote;
slotValue.holdReason.reset();
slotValue.heldSinceTime = {};
@@ -5194,17 +5309,23 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
continue;
}
proxy->SetHeading(slot.targetHeading);
// Use Move() to smoothly approach target via character controller
// This lets the AI/character controller drive animations naturally
constexpr float MAX_FRAME_DELTA = 100.0F; // Max movement per frame
constexpr float kFrameDeltaSeconds = 0.016F;
constexpr float MAX_FRAME_DELTA = 12.0F; // Max movement per frame
RE::NiPoint3 frameDelta = deltaToTarget;
if (distanceToTarget > MAX_FRAME_DELTA) {
frameDelta = (deltaToTarget / distanceToTarget) * MAX_FRAME_DELTA;
}
// Call Move() with deferred=true to integrate movement naturally
// Call Move() through the character controller and publish a velocity
// sample so the graph can blend into locomotion.
// This should update velocity in the character controller for animations
proxy->Move(0.016F, frameDelta, true);
if (auto* charController = proxy->Move(kFrameDeltaSeconds, frameDelta, false)) {
InjectProxyMovementVelocity(*charController, frameDelta, kFrameDeltaSeconds);
}
}
}
}
@@ -5325,6 +5446,8 @@ namespace F4T::ProxyActorController
*player,
fallbackCandidateSameCellPassed);
}
ApplySmoothFrameMovement();
}
void UpdateSafetyTest()