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:
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user