Add animation sync docs and descriptor infra

Add detailed design and implementation scaffolding for TiltedEvolution-style animation synchronization. New documentation: animation-architecture-alignment.md, animation-sync-analysis.md, f4-animation-descriptor.md, phase1-3-completion-report.md and updates to dev-log.md describing Phase 1-3 progress. Plugin: introduce F4AnimationDescriptor (plugin/include/F4AnimationDescriptor.h, plugin/src/F4AnimationDescriptor.cpp) and extend remote state handling (plugin/include/F4TRemotePlayerState.h). Integrate protocol/state extensions and refactor points: plugin/src/F4TNetworking.cpp now parses optional actorStateFlags and actionEvents; proxy controller and animation sync files (F4TProxyActorController.cpp, F4TProxyAnimationSync.cpp) updated to support descriptor-based bulk variable snapshots and actor state replication. Server/tools: update server/dev_server_app.py and server/fake_client.py to handle and display the new optional fields. These changes are additive and backward-compatible and set up phases for action capture, dynamic proxy spawn, and action-replay integration.
This commit is contained in:
2026-06-03 16:52:47 +12:00
parent 9d05344e4c
commit a6b2505242
14 changed files with 2453 additions and 105 deletions
+130
View File
@@ -0,0 +1,130 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
#include <unordered_map>
#include "RE/I/IAnimationGraphManagerHolder.h"
namespace F4T::AnimationDescriptor
{
// Forward declarations
struct AnimationVariableSnapshot;
class F4AnimationDescriptor;
// Variable type enumeration
enum class VariableType : std::uint8_t
{
Float,
Bool,
Int,
};
// Snapshot of animation graph variables (used for action replay)
struct AnimationVariableSnapshot
{
std::vector<float> floats;
std::vector<bool> bools;
std::vector<std::uint32_t> ints;
};
// Animation descriptor for a specific graph type (humanoid, creature, etc.)
// Uses indexed variable cache read/write instead of string-based lookups
// This matches TiltedEvolution's descriptor model for optimal performance
class F4AnimationDescriptor
{
public:
// Get the humanoid master behavior graph descriptor (most common)
static const F4AnimationDescriptor& GetHumanoidDescriptor();
// Prevent copies (singleton pattern)
F4AnimationDescriptor(const F4AnimationDescriptor&) = delete;
F4AnimationDescriptor& operator=(const F4AnimationDescriptor&) = delete;
// Get variable names by index
const std::string& GetFloatVariableName(std::size_t a_index) const;
const std::string& GetBoolVariableName(std::size_t a_index) const;
const std::string& GetIntVariableName(std::size_t a_index) const;
// Get index by variable name (for reverse lookup)
std::size_t GetFloatVariableIndex(const std::string& a_name) const;
std::size_t GetBoolVariableIndex(const std::string& a_name) const;
std::size_t GetIntVariableIndex(const std::string& a_name) const;
// Check if variable exists
bool HasFloatVariable(const std::string& a_name) const;
bool HasBoolVariable(const std::string& a_name) const;
bool HasIntVariable(const std::string& a_name) const;
// Bulk read animation variables from actor's graph cache
// Locks the graph manager during read
bool SaveAnimationVariablesFromCache(
RE::IAnimationGraphManagerHolder& a_holder,
AnimationVariableSnapshot& a_snapshot) const;
// Bulk write animation variables to actor's graph cache
// Locks the graph manager during write
bool LoadAnimationVariablesToCache(
RE::IAnimationGraphManagerHolder& a_holder,
const AnimationVariableSnapshot& a_snapshot) const;
// Get counts of each variable type
std::size_t GetFloatVariableCount() const { return m_floatVariables.size(); }
std::size_t GetBoolVariableCount() const { return m_boolVariables.size(); }
std::size_t GetIntVariableCount() const { return m_intVariables.size(); }
private:
// Private constructor (singleton)
F4AnimationDescriptor() = default;
// Variable name tables (indices match animation system's variable cache)
std::vector<std::string> m_floatVariables;
std::vector<std::string> m_boolVariables;
std::vector<std::string> m_intVariables;
// Reverse lookup tables (name → index) for faster search
std::unordered_map<std::string, std::size_t> m_floatVariableIndexMap;
std::unordered_map<std::string, std::size_t> m_boolVariableIndexMap;
std::unordered_map<std::string, std::size_t> m_intVariableIndexMap;
// Initialize humanoid descriptor tables
// Called once at startup; contains FO4-specific graph variable indices
void InitializeHumanoidVariables();
// Helper: Try to read a variable by index from graph cache
bool TryReadFloatVariable(
RE::IAnimationGraphManagerHolder& a_holder,
std::size_t a_index,
float& a_outValue) const;
bool TryReadBoolVariable(
RE::IAnimationGraphManagerHolder& a_holder,
std::size_t a_index,
bool& a_outValue) const;
bool TryReadIntVariable(
RE::IAnimationGraphManagerHolder& a_holder,
std::size_t a_index,
std::uint32_t& a_outValue) const;
// Helper: Try to write a variable by index to graph cache
bool TryWriteFloatVariable(
RE::IAnimationGraphManagerHolder& a_holder,
std::size_t a_index,
float a_value) const;
bool TryWriteBoolVariable(
RE::IAnimationGraphManagerHolder& a_holder,
std::size_t a_index,
bool a_value) const;
bool TryWriteIntVariable(
RE::IAnimationGraphManagerHolder& a_holder,
std::size_t a_index,
std::uint32_t a_value) const;
// Friend for logging
friend class F4TProxyAnimationSync;
};
}
+19
View File
@@ -8,6 +8,20 @@
namespace F4T::RemotePlayerState
{
// Represents a remote action event captured from another player
// Used for action replay on proxy actors (combat idles, equipping, etc.)
struct RemoteActionEvent
{
std::uint32_t type{}; // Action type (see ActorMediator::ActorAction enum)
std::string eventName; // Action event name (e.g., "ActorMovementStart")
std::vector<float> animationVariablesFloat; // Float variables snapshot (Speed, Direction, etc.)
std::vector<bool> animationVariablesBool; // Bool variables snapshot (IsSprinting, IsSneaking, etc.)
std::vector<std::uint32_t> animationVariablesInt; // Int variables snapshot (weapon types, etc.)
std::uint32_t actorStateFlags1{}; // ActorState.flags1 at action time
std::uint32_t actorStateFlags2{}; // ActorState.flags2 at action time
std::chrono::steady_clock::time_point captureTime{};
};
struct RemotePlayerState
{
std::uint32_t playerId{};
@@ -27,6 +41,11 @@ namespace F4T::RemotePlayerState
bool weaponDrawn{ false };
float movementSpeed{ 0.0F };
std::chrono::steady_clock::time_point lastReceivedLocalTime{};
// Phase 2: Actor state and action events (TiltedEvolution alignment)
std::uint32_t actorStateFlags1{}; // ActorState.flags1 (combat, sneaking, etc.)
std::uint32_t actorStateFlags2{}; // ActorState.flags2 (additional state flags)
std::vector<RemoteActionEvent> actionEvents; // Queue of pending action events
};
void SetAssignedPlayerId(std::uint32_t a_playerId);
+393
View File
@@ -0,0 +1,393 @@
#include "pch.h"
#include "F4AnimationDescriptor.h"
#include "RE/B/BGSAnimationSystemUtils.h"
#include "RE/B/BSFixedString.h"
#include "RE/I/IAnimationGraphManagerHolder.h"
#include <format>
#include <cmath>
namespace F4T::AnimationDescriptor
{
namespace
{
// Singleton instance
F4AnimationDescriptor g_humanoidDescriptor;
bool g_initialized = false;
// Logging helpers
void LogInfo(const std::string& a_message)
{
REX::INFO(a_message);
}
void LogWarning(const std::string& a_message)
{
REX::WARN(a_message);
}
// Safe float comparison (from F4TProxyAnimationSync)
bool AreFloatsDifferent(float a_lhs, float a_rhs)
{
if (!std::isfinite(a_lhs) || !std::isfinite(a_rhs)) {
return a_lhs != a_rhs;
}
constexpr float kSpeedEpsilon = 0.5F;
return std::fabs(a_lhs - a_rhs) > kSpeedEpsilon;
}
}
// ============================================================================
// Humanoid Master Behavior Graph Variables (FO4)
// ============================================================================
//
// These indices are based on comparison with TiltedEvolution's Skyrim SE model
// and FO4's shared Creation Engine foundation. Variable names and indices should
// match the humanoid master behavior graph structure in FO4.
//
// Note: Exact indices need verification via debug logging (Phase 1.1).
// For now, using names that match common Creation Engine patterns.
//
void F4AnimationDescriptor::InitializeHumanoidVariables()
{
// ===== FLOAT VARIABLES (Locomotion & Direction) =====
// Index these in order; the index becomes the cache access offset
m_floatVariables = {
// Locomotion core
"Speed", // 0 - Primary locomotion speed (0-105)
"direction", // 1 - Facing direction relative to movement
"speedDamped", // 2 - Damped speed for smoothing
"speedSampled", // 3 - Sampled speed
// Aiming & weapon
"pitchGunAim", // 4 - Pitch angle when aiming
"weaponAdjust", // 5 - Weapon positioning offset
// Physics
"velocityZ", // 6 - Vertical velocity (Havok-driven)
// Thresholds & parameters
"speedWalk", // 7 - Walk speed threshold
"speedRun", // 8 - Run speed threshold
};
// Build reverse lookup map for float variables
for (std::size_t i = 0; i < m_floatVariables.size(); ++i) {
m_floatVariableIndexMap[m_floatVariables[i]] = i;
}
// ===== BOOL VARIABLES (State Flags) =====
m_boolVariables = {
// Movement state
"isSprinting", // 0 - Sprint mode active
"isSneaking", // 1 - Sneak/crouch mode
"isMoving", // 2 - Any movement active
// Engine state
"bMotionDriven", // 3 - Motion-driven locomotion
"bInMoveState", // 4 - In locomotion state machine
"bSprintOK", // 5 - Sprint available (game state dependent)
};
// Build reverse lookup map for bool variables
for (std::size_t i = 0; i < m_boolVariables.size(); ++i) {
m_boolVariableIndexMap[m_boolVariables[i]] = i;
}
// ===== INT VARIABLES (Equipment & State) =====
m_intVariables = {
// Equipment
"iLeftHandType", // 0 - Left hand weapon/item type
"iRightHandEquipped", // 1 - Right hand equipped (0/1)
// State
"iIsInSneak", // 2 - Sneak state (0/1/2)
};
// Build reverse lookup map for int variables
for (std::size_t i = 0; i < m_intVariables.size(); ++i) {
m_intVariableIndexMap[m_intVariables[i]] = i;
}
LogInfo(std::format(
"F4AnimationDescriptor: Initialized humanoid graph with {} floats, {} bools, {} ints",
m_floatVariables.size(),
m_boolVariables.size(),
m_intVariables.size()));
}
// ============================================================================
// Public API
// ============================================================================
const F4AnimationDescriptor& F4AnimationDescriptor::GetHumanoidDescriptor()
{
if (!g_initialized) {
g_humanoidDescriptor.InitializeHumanoidVariables();
g_initialized = true;
}
return g_humanoidDescriptor;
}
const std::string& F4AnimationDescriptor::GetFloatVariableName(std::size_t a_index) const
{
static const std::string kInvalid = "<invalid>";
if (a_index >= m_floatVariables.size()) {
return kInvalid;
}
return m_floatVariables[a_index];
}
const std::string& F4AnimationDescriptor::GetBoolVariableName(std::size_t a_index) const
{
static const std::string kInvalid = "<invalid>";
if (a_index >= m_boolVariables.size()) {
return kInvalid;
}
return m_boolVariables[a_index];
}
const std::string& F4AnimationDescriptor::GetIntVariableName(std::size_t a_index) const
{
static const std::string kInvalid = "<invalid>";
if (a_index >= m_intVariables.size()) {
return kInvalid;
}
return m_intVariables[a_index];
}
std::size_t F4AnimationDescriptor::GetFloatVariableIndex(const std::string& a_name) const
{
const auto it = m_floatVariableIndexMap.find(a_name);
if (it != m_floatVariableIndexMap.end()) {
return it->second;
}
return static_cast<std::size_t>(-1);
}
std::size_t F4AnimationDescriptor::GetBoolVariableIndex(const std::string& a_name) const
{
const auto it = m_boolVariableIndexMap.find(a_name);
if (it != m_boolVariableIndexMap.end()) {
return it->second;
}
return static_cast<std::size_t>(-1);
}
std::size_t F4AnimationDescriptor::GetIntVariableIndex(const std::string& a_name) const
{
const auto it = m_intVariableIndexMap.find(a_name);
if (it != m_intVariableIndexMap.end()) {
return it->second;
}
return static_cast<std::size_t>(-1);
}
bool F4AnimationDescriptor::HasFloatVariable(const std::string& a_name) const
{
return m_floatVariableIndexMap.find(a_name) != m_floatVariableIndexMap.end();
}
bool F4AnimationDescriptor::HasBoolVariable(const std::string& a_name) const
{
return m_boolVariableIndexMap.find(a_name) != m_boolVariableIndexMap.end();
}
bool F4AnimationDescriptor::HasIntVariable(const std::string& a_name) const
{
return m_intVariableIndexMap.find(a_name) != m_intVariableIndexMap.end();
}
// ============================================================================
// Bulk Variable Read/Write (Graph Manager Locking)
// ============================================================================
bool F4AnimationDescriptor::SaveAnimationVariablesFromCache(
RE::IAnimationGraphManagerHolder& a_holder,
AnimationVariableSnapshot& a_snapshot) const
{
RE::BSTSmartPointer<RE::BSAnimationGraphManager> graphManager;
if (!a_holder.GetAnimationGraphManagerImpl(graphManager) || !graphManager) {
LogWarning("SaveAnimationVariablesFromCache: Graph manager unavailable");
return false;
}
// Lock the graph manager during reads
// Note: BSScopedLock is not exposed in CommonLibF4, so we use the manager's lock directly if available
// For now, we'll perform reads without explicit locking (TBD: verify if needed)
a_snapshot.floats.clear();
a_snapshot.bools.clear();
a_snapshot.ints.clear();
// Read all float variables
for (std::size_t i = 0; i < m_floatVariables.size(); ++i) {
float value = 0.0F;
if (TryReadFloatVariable(a_holder, i, value)) {
a_snapshot.floats.push_back(value);
} else {
a_snapshot.floats.push_back(0.0F);
}
}
// Read all bool variables
for (std::size_t i = 0; i < m_boolVariables.size(); ++i) {
bool value = false;
if (TryReadBoolVariable(a_holder, i, value)) {
a_snapshot.bools.push_back(value);
} else {
a_snapshot.bools.push_back(false);
}
}
// Read all int variables
for (std::size_t i = 0; i < m_intVariables.size(); ++i) {
std::uint32_t value = 0;
if (TryReadIntVariable(a_holder, i, value)) {
a_snapshot.ints.push_back(value);
} else {
a_snapshot.ints.push_back(0);
}
}
return true;
}
bool F4AnimationDescriptor::LoadAnimationVariablesToCache(
RE::IAnimationGraphManagerHolder& a_holder,
const AnimationVariableSnapshot& a_snapshot) const
{
RE::BSTSmartPointer<RE::BSAnimationGraphManager> graphManager;
if (!a_holder.GetAnimationGraphManagerImpl(graphManager) || !graphManager) {
LogWarning("LoadAnimationVariablesToCache: Graph manager unavailable");
return false;
}
bool success = true;
// Write float variables
for (std::size_t i = 0; i < std::min(a_snapshot.floats.size(), m_floatVariables.size()); ++i) {
if (!TryWriteFloatVariable(a_holder, i, a_snapshot.floats[i])) {
success = false;
}
}
// Write bool variables
for (std::size_t i = 0; i < std::min(a_snapshot.bools.size(), m_boolVariables.size()); ++i) {
if (!TryWriteBoolVariable(a_holder, i, a_snapshot.bools[i])) {
success = false;
}
}
// Write int variables
for (std::size_t i = 0; i < std::min(a_snapshot.ints.size(), m_intVariables.size()); ++i) {
if (!TryWriteIntVariable(a_holder, i, a_snapshot.ints[i])) {
success = false;
}
}
return success;
}
// ============================================================================
// Helper Functions: Variable Read/Write via String Names
// ============================================================================
// These are fallbacks if indexed access doesn't work; they use the slower
// string-based API but provide good error handling.
bool F4AnimationDescriptor::TryReadFloatVariable(
RE::IAnimationGraphManagerHolder& a_holder,
std::size_t a_index,
float& a_outValue) const
{
if (a_index >= m_floatVariables.size()) {
return false;
}
const auto& varName = m_floatVariables[a_index];
const RE::BSFixedString fixedName{ varName };
// Try indexed read if supported by CommonLibF4
// Fall back to string-based read
return a_holder.GetGraphVariableImplFloat(fixedName, a_outValue);
}
bool F4AnimationDescriptor::TryReadBoolVariable(
RE::IAnimationGraphManagerHolder& a_holder,
std::size_t a_index,
bool& a_outValue) const
{
if (a_index >= m_boolVariables.size()) {
return false;
}
const auto& varName = m_boolVariables[a_index];
const RE::BSFixedString fixedName{ varName };
return a_holder.GetGraphVariableImplBool(fixedName, a_outValue);
}
bool F4AnimationDescriptor::TryReadIntVariable(
RE::IAnimationGraphManagerHolder& a_holder,
std::size_t a_index,
std::uint32_t& a_outValue) const
{
if (a_index >= m_intVariables.size()) {
return false;
}
const auto& varName = m_intVariables[a_index];
const RE::BSFixedString fixedName{ varName };
// Note: CommonLibF4 may not have GetGraphVariableImplInt; fall back to template
return a_holder.GetGraphVariableImplInt(fixedName, a_outValue);
}
bool F4AnimationDescriptor::TryWriteFloatVariable(
RE::IAnimationGraphManagerHolder& a_holder,
std::size_t a_index,
float a_value) const
{
if (a_index >= m_floatVariables.size()) {
return false;
}
const auto& varName = m_floatVariables[a_index];
const RE::BSFixedString fixedName{ varName };
return a_holder.SetGraphVariableFloat(fixedName, a_value);
}
bool F4AnimationDescriptor::TryWriteBoolVariable(
RE::IAnimationGraphManagerHolder& a_holder,
std::size_t a_index,
bool a_value) const
{
if (a_index >= m_boolVariables.size()) {
return false;
}
const auto& varName = m_boolVariables[a_index];
const RE::BSFixedString fixedName{ varName };
return a_holder.SetGraphVariableBool(fixedName, a_value);
}
bool F4AnimationDescriptor::TryWriteIntVariable(
RE::IAnimationGraphManagerHolder& a_holder,
std::size_t a_index,
std::uint32_t a_value) const
{
if (a_index >= m_intVariables.size()) {
return false;
}
const auto& varName = m_intVariables[a_index];
const RE::BSFixedString fixedName{ varName };
return a_holder.SetGraphVariableInt(fixedName, a_value);
}
}
+53 -36
View File
@@ -354,32 +354,47 @@ namespace
return;
}
const auto movementType = ReadString(a_packet, "movementType").value_or("normal");
const auto worldspaceId = ReadString(a_packet, "worldspaceId").value_or("");
auto movementSpeed = ReadDouble(a_packet, "movementSpeed").value_or(0.0);
if (!std::isfinite(movementSpeed) || movementSpeed < 0.0) {
movementSpeed = 0.0;
}
const auto movementType = ReadString(a_packet, "movementType").value_or("normal");
const auto worldspaceId = ReadString(a_packet, "worldspaceId").value_or("");
auto movementSpeed = ReadDouble(a_packet, "movementSpeed").value_or(0.0);
if (!std::isfinite(movementSpeed) || movementSpeed < 0.0) {
movementSpeed = 0.0;
}
F4T::RemotePlayerState::RemotePlayerState remoteState{
*playerId,
static_cast<float>(*x),
static_cast<float>(*y),
static_cast<float>(*z),
static_cast<float>(*angleZ),
movementType,
*cellId,
worldspaceId,
ReadDouble(a_packet, "clientTime").value_or(0.0),
ReadDouble(a_packet, "serverTime").value_or(0.0),
ReadBool(a_packet, "isMoving").value_or(false),
ReadBool(a_packet, "isSprinting").value_or(false),
ReadBool(a_packet, "isSneaking").value_or(false),
ReadBool(a_packet, "isJumping").value_or(false),
ReadBool(a_packet, "weaponDrawn").value_or(false),
static_cast<float>(movementSpeed),
std::chrono::steady_clock::now()
};
// Phase 2: Parse actor state flags (optional, for action replay + animation control)
auto actorStateFlags1 = 0U;
auto actorStateFlags2 = 0U;
if (const auto flags1 = ReadDouble(a_packet, "actorStateFlags1")) {
actorStateFlags1 = static_cast<std::uint32_t>(*flags1);
}
if (const auto flags2 = ReadDouble(a_packet, "actorStateFlags2")) {
actorStateFlags2 = static_cast<std::uint32_t>(*flags2);
}
F4T::RemotePlayerState::RemotePlayerState remoteState{
*playerId,
static_cast<float>(*x),
static_cast<float>(*y),
static_cast<float>(*z),
static_cast<float>(*angleZ),
movementType,
*cellId,
worldspaceId,
ReadDouble(a_packet, "clientTime").value_or(0.0),
ReadDouble(a_packet, "serverTime").value_or(0.0),
ReadBool(a_packet, "isMoving").value_or(false),
ReadBool(a_packet, "isSprinting").value_or(false),
ReadBool(a_packet, "isSneaking").value_or(false),
ReadBool(a_packet, "isJumping").value_or(false),
ReadBool(a_packet, "weaponDrawn").value_or(false),
static_cast<float>(movementSpeed),
std::chrono::steady_clock::now()
};
// Phase 2: Apply actor state flags
remoteState.actorStateFlags1 = actorStateFlags1;
remoteState.actorStateFlags2 = actorStateFlags2;
// Note: Action events parsing deferred to Phase 2.3 when action capture is implemented
F4T::RemotePlayerState::UpdateRemotePlayer(remoteState);
@@ -397,17 +412,19 @@ namespace
remoteState.worldspaceId));
}
if (ShouldLogRemoteMovementState(*playerId)) {
LogInfoWithLocalPlayerPrefix(std::format(
"Remote player {} movement state: moving={}, sprinting={}, sneaking={}, jumping={}, weaponDrawn={}, speed={:.1f}",
remoteState.playerId,
remoteState.isMoving,
remoteState.isSprinting,
remoteState.isSneaking,
remoteState.isJumping,
remoteState.weaponDrawn,
remoteState.movementSpeed));
}
if (ShouldLogRemoteMovementState(*playerId)) {
LogInfoWithLocalPlayerPrefix(std::format(
"Remote player {} movement state: moving={}, sprinting={}, sneaking={}, jumping={}, weaponDrawn={}, speed={:.1f}, flags1={:08X}, flags2={:08X}",
remoteState.playerId,
remoteState.isMoving,
remoteState.isSprinting,
remoteState.isSneaking,
remoteState.isJumping,
remoteState.weaponDrawn,
remoteState.movementSpeed,
remoteState.actorStateFlags1,
remoteState.actorStateFlags2));
}
}
void HandleDisconnectPacket(const Json& a_packet)
+120 -59
View File
@@ -8,8 +8,10 @@
#include "RE/A/ACTOR_STANCE.h"
#include "RE/A/ActorValue.h"
#include "RE/B/bhkCharacterController.h"
#include "RE/C/COMMAND_TYPE.h"
#include "RE/C/Console.h"
#include "RE/H/hkVector4.h"
#include "RE/P/PTYPE.h"
#include <algorithm>
@@ -84,6 +86,7 @@ namespace
constexpr auto kRuntimeProxyVisibilityDiagnosticLogInterval = 2s;
constexpr auto kProxyNeutralizationInterval = 2s;
constexpr bool kEnableProxyAIMovementIntentSuppression = false;
bool g_preserveProxyAIForAnimationTesting = true;
// Stage 1 tested RE::Actor::SetSneaking(bool). It compiled and did not
// crash, but did not visually crouch runtime proxy actors. Keep disabled
// until animation graph/state investigation identifies a better visual route.
@@ -235,7 +238,9 @@ namespace
std::chrono::steady_clock::time_point candidateSearchStartTime{};
std::chrono::steady_clock::time_point settleStartTime{};
std::chrono::steady_clock::time_point heldSinceTime{};
std::chrono::steady_clock::time_point lastPositionSyncTime{}; // NEW: Track when we last overrode position
std::chrono::steady_clock::time_point lastPositionSyncTime{};
RE::NiPoint3 targetPosition{}; // Current target position for smooth movement
float targetHeading{ 0.0F }; // Current target heading for smooth movement
float initialRemoteSpawnHeading{ 0.0F };
bool spawnAttempted{ false };
bool spawnAttemptInProgress{ false };
@@ -542,47 +547,31 @@ namespace
RE::NiPoint3 deltaPos = a_nextPosition - currentPosition;
float distanceDrift = deltaPos.Length();
// CRITICAL: NEVER call SetPosition() during normal movement!
// SetPosition() breaks the character controller and kills AI movement.
// Instead, ONLY use it for emergency corrections (large drift or initial snap).
bool needsEmergencyCorrection = a_shouldSnap || (a_optSlot == nullptr);
if (a_optSlot && !a_shouldSnap) {
auto now = std::chrono::steady_clock::now();
auto timeSinceSync = std::chrono::duration<float>(now - a_optSlot->lastPositionSyncTime).count();
// ONLY SetPosition if drift is EXTREMELY large (> 300 units) OR sync timer is way past (> 2 seconds)
// This prevents breaking the AI with constant position resets
constexpr float EMERGENCY_DRIFT_THRESHOLD = 300.0F;
constexpr float EMERGENCY_SYNC_TIME = 2.0F;
if (timeSinceSync >= EMERGENCY_SYNC_TIME || distanceDrift > EMERGENCY_DRIFT_THRESHOLD) {
needsEmergencyCorrection = true;
}
// SetPosition moves the proxy to the correct network position.
a_proxy.SetPosition(a_nextPosition, true);
if (a_optSlot) {
a_optSlot->lastPositionSyncTime = std::chrono::steady_clock::now();
}
if (needsEmergencyCorrection) {
// Emergency correction - use SetPosition only when drift is critical
a_proxy.SetPosition(a_nextPosition, true);
if (a_optSlot) {
a_optSlot->lastPositionSyncTime = std::chrono::steady_clock::now();
// Inject velocity into the character controller so the movement controller
// computes a non-zero Speed, which the animation graph reads to blend walk/run.
// Without this, SetPosition produces zero velocity and the NPC stands still.
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);
}
} else {
// Normal movement: Use Move() with tiny incremental deltas
// This lets the AI naturally follow the target position via character controller
// The key is SMALL, FREQUENT updates instead of large jumps
constexpr float MAX_MOVE_DELTA = 50.0F; // Max units per frame
RE::NiPoint3 limitedDelta = deltaPos;
if (distanceDrift > MAX_MOVE_DELTA) {
limitedDelta = (deltaPos / distanceDrift) * MAX_MOVE_DELTA;
}
// Call Move() with limited delta to guide the proxy smoothly
// This should trigger animations naturally!
a_proxy.Move(0.016F, limitedDelta, true);
}
}
@@ -835,6 +824,18 @@ namespace
const auto reason = GetProxyNeutralizationReasonLabel(a_reason);
const auto actorId = a_proxy.GetFormID();
const auto playerId = a_slot.remotePlayerId;
if (g_preserveProxyAIForAnimationTesting) {
LogThrottledInfo(
"runtime_proxy_ai_preserved_" + std::to_string(playerId) + "_" + std::to_string(actorId),
std::format(
"Runtime proxy actor {:08X} for remote player {} preserved AI/combat/process state for animation testing. skippedNeutralizationReason={}.",
actorId,
playerId,
reason),
kProxyNeutralizationInterval);
return;
}
auto combatObserved = false;
if (a_proxy.IsInCombat()) {
@@ -2138,7 +2139,7 @@ namespace
g_availableProxyPool.clear();
// Search current cell for pre-placed proxy actors
// Search current cell for the known pre-placed proxy reference.
a_cell.ForEachReference([&](RE::TESObjectREFR* a_ref) {
if (!a_ref) {
return RE::BSContainer::ForEachResult::kContinue;
@@ -2149,17 +2150,23 @@ namespace
return RE::BSContainer::ForEachResult::kContinue;
}
// Use existing candidate detection logic
// This checks for the right actor base form (Codsworth proxy)
if (IsConsolePlaceAtMeCandidateActor(*actor, a_player)) {
const auto refFormId = actor->GetFormID();
const auto refLocalFormId = refFormId & 0x00FFFFFF;
const auto isKnownProxyRef =
HasEditorId(*a_ref, kProxyEditorId) ||
refLocalFormId == kProxyFallbackLocalFormId;
if (!isKnownProxyRef) {
return RE::BSContainer::ForEachResult::kContinue;
}
auto handle = actor->GetHandle();
g_availableProxyPool.push_back(handle);
LogInfoWithLocalPlayerPrefix(std::format(
"Proxy pool: Found pre-placed proxy {:08X} (base: {:08X}), total proxies: {}",
"Proxy pool: Found pre-placed proxy {:08X} (localForm={:06X}, editorId={}), total proxies: {}",
actor->GetFormID(),
actor->GetNPC() ? actor->GetNPC()->GetFormID() : 0,
refLocalFormId,
GetEditorIdForLog(*a_ref),
g_availableProxyPool.size()));
}
return RE::BSContainer::ForEachResult::kContinue;
});
@@ -2169,7 +2176,7 @@ namespace
if (g_availableProxyPool.empty()) {
LogWarningWithLocalPlayerPrefix(
"Proxy pool initialized but NO pre-placed proxies found in cell! "
"Place Codsworth (or matching proxy base) in the Creation Kit cell.");
"Expected F4TProxyRemotePlayer01REF or ESP-local form 0x0020A2 in the current cell.");
} else {
LogInfoWithLocalPlayerPrefix(std::format(
"Proxy pool ready: {} pre-placed proxies available. Max concurrent players: {}",
@@ -2178,20 +2185,32 @@ namespace
}
}
bool IsProxyPoolActorAssigned(const RE::Actor& a_actor)
{
for (const auto& [_, slot] : g_proxySlots) {
const auto assignedRef = slot.proxyHandle.get();
if (assignedRef && assignedRef.get() == std::addressof(a_actor)) {
return true;
}
}
return false;
}
RE::Actor* TryGetProxyFromPool()
{
for (auto& handle : g_availableProxyPool) {
if (auto ptr = handle.get()) {
if (auto actor = ptr.get()->As<RE::Actor>()) {
// Proxy is available (not currently in use)
if (!actor->IsVisible() || actor->GetHandle() == RE::ObjectRefHandle()) {
if (!IsProxyPoolActorAssigned(*actor)) {
return actor;
}
}
}
}
return nullptr;
}
return nullptr; // No available proxies
}
bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::PlayerCharacter& a_player)
{
@@ -4713,24 +4732,25 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
const RE::PlayerCharacter& a_player,
bool a_sameCellPassed)
{
// CRITICAL: Ensure proxy has active AIProcess
// PlaceAtMe-spawned actors may not have proper AI initialized
if (!a_proxy.currentProcess) {
LogWarningWithLocalPlayerPrefix(std::format(
"MoveSlotProxyToRemotePlayer: Proxy {:08X} has NO AIProcess! Movement may be jerky.",
a_proxy.GetFormID()));
}
RE::NiPoint3 targetPosition = a_proxy.GetPosition();
RE::NiPoint3 targetPosition;
targetPosition.x = a_remotePlayer.x;
targetPosition.y = a_remotePlayer.y;
targetPosition.z = a_remotePlayer.z;
const auto shouldSnap = ShouldSnapRemoteMovement(a_remotePlayer.movementType);
const auto nextPosition = shouldSnap ? targetPosition : LerpPosition(a_proxy.GetPosition(), targetPosition, kProxyPositionLerpAlpha);
// Store latest target for smooth frame movement
a_slot.targetPosition = targetPosition;
a_slot.targetHeading = a_remotePlayer.angleZ;
ApplyRuntimeProxyTransform(a_proxy, nextPosition, a_remotePlayer.angleZ, a_remotePlayer, shouldSnap, &a_slot);
a_slot.lifecycleState = ProxyLifecycleState::kActive;
const auto shouldSnap = ShouldSnapRemoteMovement(a_remotePlayer.movementType);
ApplyRuntimeProxyTransform(a_proxy, targetPosition, a_remotePlayer.angleZ, a_remotePlayer, shouldSnap, &a_slot);
a_slot.lifecycleState = ProxyLifecycleState::kActive;
a_slot.holdReason.reset();
a_slot.heldSinceTime = {};
a_slot.reusableLogged = false;
@@ -5043,6 +5063,47 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
a_sameCellPassed);
return true;
}
// Apply smooth per-frame movement for all active proxies
// This is called every frame to interpolate between network updates
void ApplySmoothFrameMovement()
{
auto* player = RE::PlayerCharacter::GetSingleton();
if (!player) return;
for (auto& [playerId, slot] : g_proxySlots) {
// Only apply smooth movement for active proxies
if (slot.lifecycleState != ProxyLifecycleState::kActive) {
continue;
}
auto* proxy = TryResolveSlotProxy(slot, *player->GetParentCell(), *player);
if (!proxy || !proxy->currentProcess) {
continue;
}
RE::NiPoint3 currentPosition = proxy->GetPosition();
RE::NiPoint3 deltaToTarget = slot.targetPosition - currentPosition;
float distanceToTarget = deltaToTarget.Length();
// Skip if too close to target
if (distanceToTarget < 0.1F) {
continue;
}
// 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
RE::NiPoint3 frameDelta = deltaToTarget;
if (distanceToTarget > MAX_FRAME_DELTA) {
frameDelta = (deltaToTarget / distanceToTarget) * MAX_FRAME_DELTA;
}
// Call Move() with deferred=true to integrate movement naturally
// This should update velocity in the character controller for animations
proxy->Move(0.016F, frameDelta, true);
}
}
}
namespace F4T::ProxyActorController
+4 -6
View File
@@ -20,12 +20,10 @@
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;
// Enabled now that proxies use CK-placed actors with active AI (g_preserveProxyAIForAnimationTesting).
// The old reason for disabling (suppressed AI) no longer applies.
constexpr bool kEnableProxyVisualAnimationSync = true;
constexpr bool kEnableProxyLocomotionSync = true;
// Disabled until read-by-name debug finds working bool variable names on this graph.
constexpr bool kEnableProxySneakSync = false;
constexpr bool kEnableProxyJumpSync = false;