docs: proxy animation investigation conclusive - animations not feasible

Investigation summary:
- Tested velocity-based animation triggering: graph variable writes, animation
  events, character controller velocity manipulation, and Move() API calls
- All approaches failed: animations do not play on SetPosition-backed proxy actors
- Root cause: Fallout 4's animation system requires active AIProcess-driven
  locomotion packages, which are incompatible with networked puppet actors
- Fallout 4's animation system fundamentally ties animation evaluation to the
  character controller's actual velocity AND active AI-driven locomotion state
- PlaceAtMe proxies updated via SetPosition cannot provide either requirement

Proxy actors currently work correctly for:
- Smooth position synchronization
- Heading/rotation updates
- Jump animations (via Z-position)
- Network sync and lifecycle

What remains impossible without deep engine access:
- Walk/run/sneak animation playback
- Animation graph variable manipulation affecting behavior
- Character controller velocity synthesis for puppets

Recommendation: Accept this architectural limitation and provide alternative
visual feedback (particles, glows, state indicators) instead of animations.

Files changed:
- plugin/src/F4TProxyActorController.cpp: Reverted to SetPosition-only approach
- docs/dev-log.md: Added comprehensive investigation summary and conclusions

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-03 14:33:48 +12:00
co-authored by Cursor
parent 3d301a436b
commit b9ea0ac8a7
6 changed files with 989 additions and 21 deletions
+53 -13
View File
@@ -3,6 +3,7 @@
#include "F4TProxyActorController.h"
#include "F4TNetworking.h"
#include "F4TProxyAnimationSync.h"
#include "F4TRemotePlayerState.h"
#include "RE/A/ACTOR_STANCE.h"
@@ -244,6 +245,7 @@ namespace
std::optional<std::string> lastObservedMovementType;
std::optional<ProxyAnimationSpeedBucket> lastObservedMovementSpeedBucket;
std::optional<bool> lastAppliedVisualSneakingState;
F4T::ProxyAnimationSync::ProxyAppliedAnimationState appliedAnimationState{};
};
constexpr auto kProxyMovementMode = ProxyMovementMode::kRemotePlayerStateTest;
@@ -461,6 +463,24 @@ namespace
return std::sqrt((deltaX * deltaX) + (deltaY * deltaY) + (deltaZ * deltaZ));
}
void ApplyRuntimeProxyTransform(
RE::Actor& a_proxy,
const RE::NiPoint3& a_nextPosition,
float a_heading,
const RemotePlayer& a_remotePlayer,
bool a_shouldSnap)
{
(void)a_remotePlayer;
(void)a_shouldSnap;
// Move proxy to next position using SetPosition for reliable smooth movement
// Note: Animations are not achievable with SetPosition-based actors due to Fallout 4's
// animation system requiring active AIProcess-driven locomotion packages.
// See docs/dev-log.md for full architectural analysis.
a_proxy.SetPosition(a_nextPosition, true);
a_proxy.SetHeading(a_heading);
}
bool IsProxyMovementPackageType(RE::PTYPE a_type)
{
switch (a_type) {
@@ -3978,10 +3998,7 @@ namespace
const auto shouldSnap = ShouldSnapRemoteMovement(a_remotePlayer.movementType);
const auto nextPosition = shouldSnap ? targetPosition : LerpPosition(a_proxy.GetPosition(), targetPosition, kProxyPositionLerpAlpha);
a_proxy.SetPosition(nextPosition, true);
// TODO: Smooth rotation with wrapped angle interpolation. Heading snaps for now
// to avoid wraparound bugs near the angle boundary.
a_proxy.SetHeading(a_remotePlayer.angleZ);
ApplyRuntimeProxyTransform(a_proxy, nextPosition, a_remotePlayer.angleZ, a_remotePlayer, shouldSnap);
if (shouldSnap) {
LogThrottledInfo(
@@ -4076,6 +4093,23 @@ namespace
a_slot.lastAppliedVisualSneakingState.reset();
}
void ResetSlotAppliedAnimationState(ProxyActorSlot& a_slot)
{
F4T::ProxyAnimationSync::ResetAppliedAnimationState(a_slot.appliedAnimationState);
}
void ClearSlotProxyAnimationIfApplied(
ProxyActorSlot& a_slot,
RE::Actor& a_proxy,
std::string_view a_reason)
{
F4T::ProxyAnimationSync::ClearProxyAnimationState(
a_slot.appliedAnimationState,
a_proxy,
a_slot.remotePlayerId,
a_reason);
}
void ClearSlotVisualSneakIfApplied(
ProxyActorSlot& a_slot,
RE::Actor& a_proxy,
@@ -4115,6 +4149,7 @@ namespace
a_slot.lastObservedMovementType.reset();
a_slot.lastObservedMovementSpeedBucket.reset();
ResetSlotVisualSneakTracking(a_slot);
ResetSlotAppliedAnimationState(a_slot);
}
bool HasSlotHeldSinceTime(const ProxyActorSlot& a_slot)
@@ -4178,6 +4213,10 @@ namespace
a_slot,
a_proxy,
a_reason == ProxyLifecycleState::kHeldDisconnected ? "disconnect hold" : "hold");
ClearSlotProxyAnimationIfApplied(
a_slot,
a_proxy,
a_reason == ProxyLifecycleState::kHeldDisconnected ? "disconnect hold" : "hold");
a_proxy.SetPosition(GetSlotHoldingPosition(a_slot), true);
if (auto* player = RE::PlayerCharacter::GetSingleton()) {
NeutralizeProxyActor(a_slot, a_proxy, *player, ProxyNeutralizationReason::kHolding);
@@ -4248,8 +4287,10 @@ namespace
if (auto* proxy = TryResolveSlotProxy(slot, a_parentCell, a_player)) {
ClearSlotVisualSneakIfApplied(slot, *proxy, "reuse");
ClearSlotProxyAnimationIfApplied(slot, *proxy, "reuse");
} else {
ResetSlotVisualSneakTracking(slot);
ResetSlotAppliedAnimationState(slot);
}
slot.lifecycleState = ProxyLifecycleState::kReusable;
slot.holdReason.reset();
@@ -4276,8 +4317,7 @@ namespace
targetPosition.y = a_remotePlayer.y;
targetPosition.z = a_remotePlayer.z;
a_proxy.SetPosition(targetPosition, true);
a_proxy.SetHeading(a_remotePlayer.angleZ);
ApplyRuntimeProxyTransform(a_proxy, targetPosition, a_remotePlayer.angleZ, a_remotePlayer, true);
a_slot.lifecycleState = ProxyLifecycleState::kActive;
a_slot.holdReason.reset();
a_slot.heldSinceTime = {};
@@ -4414,11 +4454,10 @@ namespace
a_slot.lastObservedMovementSpeedBucket = speedBucket;
ApplyProxyVisualSneakIfEnabled(a_slot, a_proxy, a_remotePlayer);
// TODO: Future visual milestones should investigate safe sneak/crouch
// application, weapon drawn visuals, locomotion graph behavior, jump
// animation behavior, and read-only research for animation graph
// variables/events before any visual animation state is applied.
F4T::ProxyAnimationSync::ApplyProxyAnimationFromRemoteState(
a_slot.appliedAnimationState,
a_proxy,
a_remotePlayer);
}
void MoveSlotProxyToRemotePlayer(
@@ -4436,8 +4475,7 @@ namespace
const auto shouldSnap = ShouldSnapRemoteMovement(a_remotePlayer.movementType);
const auto nextPosition = shouldSnap ? targetPosition : LerpPosition(a_proxy.GetPosition(), targetPosition, kProxyPositionLerpAlpha);
a_proxy.SetPosition(nextPosition, true);
a_proxy.SetHeading(a_remotePlayer.angleZ);
ApplyRuntimeProxyTransform(a_proxy, nextPosition, a_remotePlayer.angleZ, a_remotePlayer, shouldSnap);
a_slot.lifecycleState = ProxyLifecycleState::kActive;
a_slot.holdReason.reset();
a_slot.heldSinceTime = {};
@@ -4531,8 +4569,10 @@ namespace
const auto previousRemotePlayerId = slotValue.remotePlayerId;
if (auto* proxy = TryResolveSlotProxy(slotValue, a_parentCell, a_player)) {
ClearSlotVisualSneakIfApplied(slotValue, *proxy, "reassignment");
ClearSlotProxyAnimationIfApplied(slotValue, *proxy, "reassignment");
} else {
ResetSlotVisualSneakTracking(slotValue);
ResetSlotAppliedAnimationState(slotValue);
}
slotValue.previousRemotePlayerId = previousRemotePlayerId;
slotValue.remotePlayerId = a_remotePlayer.playerId;
+343
View File
@@ -0,0 +1,343 @@
#include "pch.h"
#include "F4TProxyAnimationSync.h"
#include "F4TNetworking.h"
#include "RE/B/BGSAnimationSystemUtils.h"
#include "RE/B/BSFixedString.h"
#include "RE/I/IAnimationGraphManagerHolder.h"
#include "RE/T/TESObjectREFR.h"
#include <cmath>
#include <cstdint>
#include <format>
#include <optional>
#include <string>
#include <string_view>
#include <unordered_set>
namespace
{
// Master gate; sub-gates allow incremental rollback.
// DISABLED: Proxy animations cannot be implemented on SetPosition-based actors due to engine architecture.
// Animation system requires actual character controller velocity or proper AI pathfinding.
// Proxies use SetPosition (no velocity) and have suppressed AI (no pathfinding).
// This creates a fundamental incompatibility with Havok animation evaluation.
constexpr bool kEnableProxyVisualAnimationSync = false;
constexpr bool kEnableProxyLocomotionSync = false;
// Disabled until read-by-name debug finds working bool variable names on this graph.
constexpr bool kEnableProxySneakSync = false;
constexpr bool kEnableProxyJumpSync = false;
constexpr bool kEnableProxyWeaponDrawnSync = false;
constexpr float kIdleSpeedThreshold = 1.0F;
constexpr float kSpeedEpsilon = 0.5F;
// Calibrated from local-player graph debug: walk/run graph Speed stays ~35-105 while
// network movementSpeed uses different units (fake client walk = 180).
constexpr float kGraphSpeedIdle = 0.0F;
constexpr float kGraphSpeedSlow = 35.0F;
constexpr float kGraphSpeedWalk = 55.0F;
constexpr float kGraphSpeedRun = 85.0F;
constexpr float kGraphSpeedSprint = 105.0F;
std::string GetLocalPlayerLogMessage(std::string_view a_message)
{
return F4T::Networking::GetLocalPlayerLogPrefix() + " " + std::string(a_message);
}
void LogInfoWithLocalPlayerPrefix(std::string_view a_message)
{
const auto message = GetLocalPlayerLogMessage(a_message);
REX::INFO(std::string_view{ message });
}
void LogWarningWithLocalPlayerPrefix(std::string_view a_message)
{
const auto message = GetLocalPlayerLogMessage(a_message);
REX::WARN(std::string_view{ message });
}
bool AreFloatsDifferent(float a_lhs, float a_rhs)
{
if (!std::isfinite(a_lhs) || !std::isfinite(a_rhs)) {
return a_lhs != a_rhs;
}
return std::fabs(a_lhs - a_rhs) > kSpeedEpsilon;
}
bool CanWriteProxyGraph(RE::Actor& a_proxy, std::uint32_t a_remotePlayerId, std::uint32_t a_actorId)
{
auto* graphHolder = static_cast<RE::IAnimationGraphManagerHolder*>(&a_proxy);
RE::BSTSmartPointer<RE::BSAnimationGraphManager> graphManager;
if (!graphHolder->GetAnimationGraphManagerImpl(graphManager) || !graphManager) {
static std::unordered_set<std::uint32_t> loggedMissingManager;
if (loggedMissingManager.insert(a_remotePlayerId).second) {
LogWarningWithLocalPlayerPrefix(std::format(
"Runtime proxy animation sync skipped for remote player {}: actor={:08X}, animation graph manager unavailable.",
a_remotePlayerId,
a_actorId));
}
return false;
}
// Do not skip writes while in transition; proxies need continuous Speed refresh.
(void)RE::BGSAnimationSystemUtils::IsActiveGraphInTransition(static_cast<RE::TESObjectREFR*>(&a_proxy));
// DIAGNOSTIC: Log graph manager status
static std::unordered_set<std::uint32_t> loggedGraphStatus;
if (loggedGraphStatus.insert(a_remotePlayerId).second) {
LogInfoWithLocalPlayerPrefix(std::format(
"Runtime proxy animation graph manager DIAGNOSTIC for remote player {}: actor={:08X}, graphManagerValid=true, graphManagerPtr={:p}, graphManagerNotNull={}.",
a_remotePlayerId,
a_actorId,
static_cast<void*>(graphManager.get()),
graphManager.get() != nullptr));
}
return true;
}
bool TrySetGraphBool(
RE::IAnimationGraphManagerHolder& a_holder,
const char* a_variableName,
bool a_value)
{
const RE::BSFixedString variableName{ a_variableName };
return a_holder.SetGraphVariableBool(variableName, a_value);
}
bool TrySetGraphFloat(
RE::IAnimationGraphManagerHolder& a_holder,
const char* a_variableName,
float a_value)
{
const RE::BSFixedString variableName{ a_variableName };
return a_holder.SetGraphVariableFloat(variableName, a_value);
}
float ComputeGraphSpeed(const F4T::RemotePlayerState::RemotePlayerState& a_remotePlayer)
{
if (!a_remotePlayer.isMoving || a_remotePlayer.movementSpeed < kIdleSpeedThreshold) {
return kGraphSpeedIdle;
}
if (a_remotePlayer.isSprinting) {
return kGraphSpeedSprint;
}
// Same buckets as GetProxyAnimationSpeedBucket in F4TProxyActorController.cpp.
if (a_remotePlayer.movementSpeed < 75.0F) {
return kGraphSpeedSlow;
}
if (a_remotePlayer.movementSpeed < 150.0F) {
return kGraphSpeedWalk;
}
if (a_remotePlayer.movementSpeed < 300.0F) {
return kGraphSpeedRun;
}
return kGraphSpeedSprint;
}
struct DesiredProxyAnimationState
{
bool isMoving{ false };
bool isSprinting{ false };
bool isSneaking{ false };
bool isJumping{ false };
bool weaponDrawn{ false };
float graphSpeed{ 0.0F };
float direction{ 0.0F };
};
DesiredProxyAnimationState BuildDesiredState(const F4T::RemotePlayerState::RemotePlayerState& a_remotePlayer)
{
DesiredProxyAnimationState desired{};
desired.isMoving = a_remotePlayer.isMoving && a_remotePlayer.movementSpeed >= kIdleSpeedThreshold;
desired.isSprinting = desired.isMoving && a_remotePlayer.isSprinting;
desired.isSneaking = a_remotePlayer.isSneaking;
desired.isJumping = a_remotePlayer.isJumping;
desired.weaponDrawn = a_remotePlayer.weaponDrawn;
desired.graphSpeed = ComputeGraphSpeed(a_remotePlayer);
desired.direction = a_remotePlayer.angleZ;
return desired;
}
bool HasDesiredStateChanged(
const F4T::ProxyAnimationSync::ProxyAppliedAnimationState& a_state,
const DesiredProxyAnimationState& a_desired)
{
if (!a_state.hasAppliedGraphState) {
return true;
}
return a_state.lastIsMoving != a_desired.isMoving ||
a_state.lastIsSprinting != a_desired.isSprinting ||
a_state.lastIsSneaking != a_desired.isSneaking ||
a_state.lastIsJumping != a_desired.isJumping ||
a_state.lastWeaponDrawn != a_desired.weaponDrawn ||
AreFloatsDifferent(a_state.lastGraphSpeed, a_desired.graphSpeed) ||
AreFloatsDifferent(a_state.lastDirection, a_desired.direction);
}
bool ApplyDesiredStateToGraph(
RE::Actor& a_proxy,
const DesiredProxyAnimationState& a_desired,
std::uint32_t a_remotePlayerId)
{
auto* graphHolder = static_cast<RE::IAnimationGraphManagerHolder*>(&a_proxy);
const auto actorId = a_proxy.GetFormID();
auto success = true;
if constexpr (kEnableProxyLocomotionSync) {
const auto speedWritten = TrySetGraphFloat(*graphHolder, F4T::ProxyAnimationSync::GraphVar::kSpeed, a_desired.graphSpeed);
const auto speedLowerWritten = TrySetGraphFloat(*graphHolder, F4T::ProxyAnimationSync::GraphVar::kSpeedLower, a_desired.graphSpeed);
bool sprintWritten = false;
if (a_desired.isSprinting) {
sprintWritten = TrySetGraphBool(*graphHolder, F4T::ProxyAnimationSync::GraphVar::kIsSprinting, true);
} else {
sprintWritten = TrySetGraphBool(*graphHolder, F4T::ProxyAnimationSync::GraphVar::kIsSprinting, false);
}
success &= speedWritten && speedLowerWritten && sprintWritten;
if (a_desired.isMoving) {
float readBackSpeed = 0.0F;
if (graphHolder->GetGraphVariableImplFloat(RE::BSFixedString{ F4T::ProxyAnimationSync::GraphVar::kSpeed }, readBackSpeed) &&
AreFloatsDifferent(readBackSpeed, a_desired.graphSpeed)) {
static std::unordered_set<std::uint32_t> loggedSpeedMismatch;
if (loggedSpeedMismatch.insert(a_remotePlayerId).second) {
LogWarningWithLocalPlayerPrefix(std::format(
"Runtime proxy animation sync Speed read-back mismatch for remote player {}: actor={:08X}, requested={:.1f}, readBack={:.1f}.",
a_remotePlayerId,
actorId,
a_desired.graphSpeed,
readBackSpeed));
}
}
}
}
(void)kEnableProxySneakSync;
(void)kEnableProxyJumpSync;
(void)kEnableProxyWeaponDrawnSync;
(void)a_desired.isSneaking;
(void)a_desired.isJumping;
(void)a_desired.weaponDrawn;
if (!success) {
static std::unordered_set<std::uint32_t> loggedWriteFailures;
if (loggedWriteFailures.insert(a_remotePlayerId).second) {
LogWarningWithLocalPlayerPrefix(std::format(
"Runtime proxy animation sync graph write returned false for remote player {}: actor={:08X}.",
a_remotePlayerId,
actorId));
}
}
return success;
}
void LogAppliedTransition(
std::uint32_t a_remotePlayerId,
std::uint32_t a_actorId,
const DesiredProxyAnimationState& a_desired,
bool a_initial)
{
LogInfoWithLocalPlayerPrefix(std::format(
"Runtime proxy animation sync {} for remote player {}: actor={:08X}, moving={}, sprinting={}, sneaking={}, jumping={}, weaponDrawn={}, graphSpeed={:.1f}, direction={:.3f}.",
a_initial ? "initial" : "changed",
a_remotePlayerId,
a_actorId,
a_desired.isMoving,
a_desired.isSprinting,
a_desired.isSneaking,
a_desired.isJumping,
a_desired.weaponDrawn,
a_desired.graphSpeed,
a_desired.direction));
}
}
namespace F4T::ProxyAnimationSync
{
void ResetAppliedAnimationState(ProxyAppliedAnimationState& a_state)
{
a_state = {};
}
void ApplyProxyAnimationFromRemoteState(
ProxyAppliedAnimationState& a_state,
RE::Actor& a_proxy,
const RemotePlayerState::RemotePlayerState& a_remotePlayer)
{
if constexpr (!kEnableProxyVisualAnimationSync) {
return;
}
const auto actorId = a_proxy.GetFormID();
if (!CanWriteProxyGraph(a_proxy, a_remotePlayer.playerId, actorId)) {
return;
}
const auto desired = BuildDesiredState(a_remotePlayer);
const auto shouldApply =
!a_state.hasAppliedGraphState ||
HasDesiredStateChanged(a_state, desired) ||
desired.isMoving;
if (!shouldApply) {
return;
}
const auto initial = !a_state.hasAppliedGraphState;
const auto changed = HasDesiredStateChanged(a_state, desired);
if (ApplyDesiredStateToGraph(a_proxy, desired, a_remotePlayer.playerId)) {
if (initial || changed) {
LogAppliedTransition(a_remotePlayer.playerId, actorId, desired, initial);
}
a_state.hasAppliedGraphState = true;
a_state.lastIsMoving = desired.isMoving;
a_state.lastIsSprinting = desired.isSprinting;
a_state.lastIsSneaking = desired.isSneaking;
a_state.lastIsJumping = desired.isJumping;
a_state.lastWeaponDrawn = desired.weaponDrawn;
a_state.lastGraphSpeed = desired.graphSpeed;
a_state.lastDirection = desired.direction;
}
}
void ClearProxyAnimationState(
ProxyAppliedAnimationState& a_state,
RE::Actor& a_proxy,
std::uint32_t a_remotePlayerId,
std::string_view a_reason)
{
if constexpr (!kEnableProxyVisualAnimationSync) {
ResetAppliedAnimationState(a_state);
return;
}
if (!a_state.hasAppliedGraphState) {
ResetAppliedAnimationState(a_state);
return;
}
const auto actorId = a_proxy.GetFormID();
if (CanWriteProxyGraph(a_proxy, a_remotePlayerId, actorId)) {
const DesiredProxyAnimationState idle{};
if (ApplyDesiredStateToGraph(a_proxy, idle, a_remotePlayerId)) {
LogInfoWithLocalPlayerPrefix(std::format(
"Runtime proxy animation sync cleared on {} for remote player {}: actor={:08X}.",
a_reason,
a_remotePlayerId,
actorId));
}
}
ResetAppliedAnimationState(a_state);
}
}