Use authoritative FO4 animation variable names

Replace previously guessed graph variable names/types with the real FO4 humanoid movement behavior names (from MTBehavior.hkx) and update syncing logic accordingly. F4AnimationDescriptor now lists only the variables F4T drives (Speed, SpeedSmoothed, IsSprinting, bInJumpState, iIsInSneak) to avoid clobbering graph state; Direction is intentionally not written. Enable proxy sneak sync using the INT32 iIsInSneak, change SpeedSampled -> SpeedSmoothed, and update all Set/Get calls and diagnostic logging to the new names. Fix jump detection by relying on the actor IsJumping() API (removing the noisy vertical-speed heuristic that produced false positives and caused proxies to remain in jump state). Changes touch descriptor, proxy sync, puppet, main logic, and related docs to explain the rationale and testing notes.
This commit is contained in:
2026-06-05 15:29:31 +12:00
parent 5ba440ab5d
commit 6a38e7226c
7 changed files with 220 additions and 144 deletions
+11 -7
View File
@@ -14,18 +14,22 @@ namespace RE
namespace F4T::ProxyAnimationSync
{
// Confirmed via local read-by-name graph debug (Fallout4Together.log):
// - float "Speed" reads/writes while in-game (walk ~47-105)
// - bool "IsSprinting" reads while in-game
// - bIsMoving, bIsSneaking, bSprint, bInJumpState, bWeaponDrawn, Direction: unavailable by name
// Authoritative FO4 humanoid movement-behavior graph variable names, taken from
// MTBehavior.hkx (hkbBehaviorGraphStringData::variableNames, with types from the
// parallel variableInfos array) via the F4-Animation-Research unpack:
// Speed, SpeedSmoothed, Direction -> VARIABLE_TYPE_REAL (float)
// IsSprinting, bInJumpState -> VARIABLE_TYPE_BOOL (bool)
// iIsInSneak -> VARIABLE_TYPE_INT32 (int)
// Earlier code guessed names like "speed"/"speedSampled"/"direction" that do not exist
// on this graph, so every SetGraphVariable* call silently failed.
namespace GraphVar
{
inline constexpr const char* kSpeed = "Speed";
inline constexpr const char* kSpeedLower = "speed";
inline constexpr const char* kSpeedSampled = "speedSampled";
inline constexpr const char* kSpeedDamped = "speedDamped";
inline constexpr const char* kSpeedSmoothed = "SpeedSmoothed";
inline constexpr const char* kDirection = "Direction";
inline constexpr const char* kIsSprinting = "IsSprinting";
inline constexpr const char* kInJumpState = "bInJumpState";
inline constexpr const char* kIsInSneak = "iIsInSneak";
}
// Maps network movementSpeed (units/sec) to graph Speed (~0-105). Used when the sender
+28 -46
View File
@@ -42,36 +42,32 @@ namespace F4T::AnimationDescriptor
// ============================================================================
// 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.
// These names and types are taken from the authoritative FO4 humanoid movement
// behavior graph (Meshes\Actors\Character\Behaviors\MTBehavior.hkx), unpacked to
// XML in the F4-Animation-Research repo. They are the real
// hkbBehaviorGraphStringData::variableNames, paired with the parallel
// variableInfos types - NOT guesses.
//
// Speed, SpeedSmoothed -> VARIABLE_TYPE_REAL (float)
// IsSprinting, bInJumpState -> VARIABLE_TYPE_BOOL (bool)
// iIsInSneak -> VARIABLE_TYPE_INT32 (int, 0 = standing, 1 = sneaking)
//
// IMPORTANT: only list variables F4T actually drives here. LoadAnimationVariablesToCache()
// bulk-writes every variable in the descriptor from the snapshot, so any variable listed
// but not explicitly set would be written as 0/false each update and clobber graph state
// we do not intend to touch (e.g. IsPlayer, bAllowRotation, LookAt* settings).
//
// The previous table guessed names ("direction", "isMoving", "isSneaking", "speedSampled",
// "speedDamped", ...) that do not exist on this graph, so every SetGraphVariable* call
// silently failed and no animation state was ever applied.
//
void F4AnimationDescriptor::InitializeHumanoidVariables()
{
// ===== FLOAT VARIABLES (Locomotion & Direction) =====
// Index these in order; the index becomes the cache access offset
// ===== FLOAT VARIABLES (VARIABLE_TYPE_REAL) =====
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
"Speed", // 0 - primary locomotion speed (world units/sec)
"SpeedSmoothed", // 1 - engine-damped speed; mirror Speed so it tracks the target
};
// Build reverse lookup map for float variables
@@ -79,34 +75,20 @@ namespace F4T::AnimationDescriptor
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
"isJumping", // 3 - Jump in progress
"isCrouching", // 4 - Crouch mode active
// Engine state
"bMotionDriven", // 5 - Motion-driven locomotion
"bInMoveState", // 6 - In locomotion state machine
"bSprintOK", // 7 - Sprint available (game state dependent)
};
// ===== BOOL VARIABLES (VARIABLE_TYPE_BOOL) =====
m_boolVariables = {
"IsSprinting", // 0 - sprint sub-state
"bInJumpState", // 1 - jump / takeoff state
};
// 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) =====
// ===== INT VARIABLES (VARIABLE_TYPE_INT32) =====
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)
"iIsInSneak", // 0 - sneak/crouch state (0 = standing, 1 = sneaking)
};
// Build reverse lookup map for int variables
+57 -88
View File
@@ -30,10 +30,14 @@ namespace
// 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;
// Enabled now that the authoritative FO4 graph name is known: sneak is the INT32
// variable "iIsInSneak" (0 = standing, 1 = sneaking), not a bool. See MTBehavior.hkx
// in the F4-Animation-Research unpack.
constexpr bool kEnableProxySneakSync = true;
constexpr bool kEnableProxyJumpSync = true;
// Crouch is not a separate FO4 graph state; sneaking (iIsInSneak) covers it.
constexpr bool kEnableProxyCrouchSync = false;
// Weapon-drawn lives in the weapon wrapping behavior, not MTBehavior; needs more research.
constexpr bool kEnableProxyWeaponDrawnSync = false;
constexpr float kIdleSpeedThreshold = 1.0F;
@@ -181,14 +185,9 @@ namespace
a_snapshot.floats[speedIdx] = graphSpeed;
}
const auto speedSampledIdx = a_descriptor.GetFloatVariableIndex(F4T::ProxyAnimationSync::GraphVar::kSpeedSampled);
if (speedSampledIdx != static_cast<std::size_t>(-1) && speedSampledIdx < a_snapshot.floats.size()) {
a_snapshot.floats[speedSampledIdx] = graphSpeed;
}
const auto speedDampedIdx = a_descriptor.GetFloatVariableIndex(F4T::ProxyAnimationSync::GraphVar::kSpeedDamped);
if (speedDampedIdx != static_cast<std::size_t>(-1) && speedDampedIdx < a_snapshot.floats.size()) {
a_snapshot.floats[speedDampedIdx] = graphSpeed;
const auto speedSmoothedIdx = a_descriptor.GetFloatVariableIndex(F4T::ProxyAnimationSync::GraphVar::kSpeedSmoothed);
if (speedSmoothedIdx != static_cast<std::size_t>(-1) && speedSmoothedIdx < a_snapshot.floats.size()) {
a_snapshot.floats[speedSmoothedIdx] = graphSpeed;
}
}
@@ -316,18 +315,11 @@ namespace
if constexpr (kEnableProxyLocomotionSync) {
const auto graphSpeed = ClampGraphSpeed(a_desired.graphSpeed);
const auto speedWritten = TrySetGraphFloat(*graphHolder, F4T::ProxyAnimationSync::GraphVar::kSpeed, graphSpeed);
const auto speedLowerWritten = TrySetGraphFloat(*graphHolder, F4T::ProxyAnimationSync::GraphVar::kSpeedLower, graphSpeed);
const auto speedSampledWritten =
TrySetGraphFloat(*graphHolder, F4T::ProxyAnimationSync::GraphVar::kSpeedSampled, graphSpeed);
const auto speedDampedWritten =
TrySetGraphFloat(*graphHolder, F4T::ProxyAnimationSync::GraphVar::kSpeedDamped, 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 && speedSampledWritten && speedDampedWritten && sprintWritten;
const auto speedSmoothedWritten =
TrySetGraphFloat(*graphHolder, F4T::ProxyAnimationSync::GraphVar::kSpeedSmoothed, graphSpeed);
const auto sprintWritten =
TrySetGraphBool(*graphHolder, F4T::ProxyAnimationSync::GraphVar::kIsSprinting, a_desired.isSprinting);
success &= speedWritten && speedSmoothedWritten && sprintWritten;
if (a_desired.isMoving) {
float readBackSpeed = 0.0F;
@@ -382,48 +374,33 @@ namespace
PopulateLocomotionSpeedSnapshot(snapshot, descriptor, a_desired.graphSpeed);
const auto directionIdx = descriptor.GetFloatVariableIndex("direction");
if (directionIdx != static_cast<std::size_t>(-1)) {
snapshot.floats[directionIdx] = a_desired.direction;
// NOTE: the FO4 graph "Direction" variable is the movement direction relative to
// facing (radians), not the world heading we carry in angleZ. Writing angleZ here
// makes the proxy strafe/moonwalk, so Direction is intentionally left at its graph
// default (forward) until a relative movement direction is added to the protocol.
const auto isSprintingIdx = descriptor.GetBoolVariableIndex(F4T::ProxyAnimationSync::GraphVar::kIsSprinting);
if (isSprintingIdx != static_cast<std::size_t>(-1)) {
snapshot.bools[isSprintingIdx] = a_desired.isSprinting;
}
// Populate bool variables
const auto isSprintingIdx = descriptor.GetBoolVariableIndex("isSprinting");
if (isSprintingIdx != static_cast<std::size_t>(-1)) {
snapshot.bools[isSprintingIdx] = a_desired.isSprinting;
}
const auto isSneakingIdx = descriptor.GetBoolVariableIndex("isSneaking");
if (isSneakingIdx != static_cast<std::size_t>(-1)) {
snapshot.bools[isSneakingIdx] = a_desired.isSneaking;
}
const auto isMovingIdx = descriptor.GetBoolVariableIndex("isMoving");
if (isMovingIdx != static_cast<std::size_t>(-1)) {
snapshot.bools[isMovingIdx] = a_desired.isMoving;
}
if constexpr (kEnableProxyJumpSync) {
const auto isJumpingIdx = descriptor.GetBoolVariableIndex("isJumping");
if (isJumpingIdx != static_cast<std::size_t>(-1)) {
snapshot.bools[isJumpingIdx] = a_desired.isJumping;
if constexpr (kEnableProxyJumpSync) {
const auto inJumpIdx = descriptor.GetBoolVariableIndex(F4T::ProxyAnimationSync::GraphVar::kInJumpState);
if (inJumpIdx != static_cast<std::size_t>(-1)) {
snapshot.bools[inJumpIdx] = a_desired.isJumping;
}
}
}
if constexpr (kEnableProxyCrouchSync) {
const auto isCrouchingIdx = descriptor.GetBoolVariableIndex("isCrouching");
if (isCrouchingIdx != static_cast<std::size_t>(-1)) {
snapshot.bools[isCrouchingIdx] = a_desired.isCrouching;
if constexpr (kEnableProxySneakSync) {
const auto sneakIdx = descriptor.GetIntVariableIndex(F4T::ProxyAnimationSync::GraphVar::kIsInSneak);
if (sneakIdx != static_cast<std::size_t>(-1)) {
snapshot.ints[sneakIdx] = a_desired.isSneaking ? 1U : 0U;
}
}
}
// Single bulk write operation (major performance win)
const auto success = descriptor.LoadAnimationVariablesToCache(*graphHolder, snapshot);
if constexpr (kEnableProxyJumpSync) {
TrySetGraphBool(*graphHolder, F4T::ProxyAnimationSync::GraphVar::kInJumpState, a_desired.isJumping);
}
if (!success) {
static std::unordered_set<std::uint32_t> loggedWriteFailures;
if (loggedWriteFailures.insert(a_remotePlayerId).second) {
@@ -578,13 +555,13 @@ namespace F4T::ProxyAnimationSync
last = now;
auto* diagHolder = static_cast<RE::IAnimationGraphManagerHolder*>(&a_proxy);
float readSpeed = -1.0F;
float readSpeedSampled = -1.0F;
float readSpeedSmoothed = -1.0F;
const auto haveSpeed =
diagHolder->GetGraphVariableImplFloat(RE::BSFixedString{ "Speed" }, readSpeed);
const auto haveSpeedSampled =
diagHolder->GetGraphVariableImplFloat(RE::BSFixedString{ "speedSampled" }, readSpeedSampled);
diagHolder->GetGraphVariableImplFloat(RE::BSFixedString{ F4T::ProxyAnimationSync::GraphVar::kSpeed }, readSpeed);
const auto haveSpeedSmoothed =
diagHolder->GetGraphVariableImplFloat(RE::BSFixedString{ F4T::ProxyAnimationSync::GraphVar::kSpeedSmoothed }, readSpeedSmoothed);
LogInfoWithLocalPlayerPrefix(std::format(
"Proxy anim diag p{} actor={:08X}: recvMoving={}, recvSprint={}, recvMovementSpeed={:.1f}, recvAnimGraphSpeed={:.1f}, computedGraphSpeed={:.1f}, graphSpeedReadBack={}({:.1f}), speedSampledReadBack={}({:.1f}).",
"Proxy anim diag p{} actor={:08X}: recvMoving={}, recvSprint={}, recvMovementSpeed={:.1f}, recvAnimGraphSpeed={:.1f}, computedGraphSpeed={:.1f}, graphSpeedReadBack={}({:.1f}), speedSmoothedReadBack={}({:.1f}).",
a_remotePlayer.playerId,
actorId,
a_remotePlayer.isMoving,
@@ -594,8 +571,8 @@ namespace F4T::ProxyAnimationSync
desired.graphSpeed,
haveSpeed,
readSpeed,
haveSpeedSampled,
readSpeedSampled));
haveSpeedSmoothed,
readSpeedSmoothed));
}
}
@@ -689,37 +666,29 @@ namespace F4T::ProxyAnimationSync
const auto desired = BuildDesiredState(a_remotePlayer);
PopulateLocomotionSpeedSnapshot(snapshot, descriptor, desired.graphSpeed);
// Direction: from remote player's heading
if (descriptor.GetFloatVariableIndex("direction") != static_cast<std::size_t>(-1)) {
snapshot.floats[descriptor.GetFloatVariableIndex("direction")] = desired.direction;
// Direction intentionally not written; see ApplyDesiredStateToGraphDescriptorBased.
// Prepare bool variables (IsSprinting, bInJumpState)
snapshot.bools.resize(descriptor.GetBoolVariableCount(), false);
const auto isSprintingIdx = descriptor.GetBoolVariableIndex(GraphVar::kIsSprinting);
if (isSprintingIdx != static_cast<std::size_t>(-1)) {
snapshot.bools[isSprintingIdx] = desired.isSprinting;
}
// Prepare bool variables (isSprinting, isSneaking, isMoving, isJumping, isCrouching)
snapshot.bools.resize(descriptor.GetBoolVariableCount(), false);
const auto inJumpIdx = descriptor.GetBoolVariableIndex(GraphVar::kInJumpState);
if (inJumpIdx != static_cast<std::size_t>(-1)) {
snapshot.bools[inJumpIdx] = desired.isJumping;
}
if (descriptor.GetBoolVariableIndex("isSprinting") != static_cast<std::size_t>(-1)) {
snapshot.bools[descriptor.GetBoolVariableIndex("isSprinting")] = desired.isSprinting;
}
if (descriptor.GetBoolVariableIndex("isSneaking") != static_cast<std::size_t>(-1)) {
snapshot.bools[descriptor.GetBoolVariableIndex("isSneaking")] = desired.isSneaking;
}
if (descriptor.GetBoolVariableIndex("isMoving") != static_cast<std::size_t>(-1)) {
snapshot.bools[descriptor.GetBoolVariableIndex("isMoving")] = desired.isMoving;
}
if (descriptor.GetBoolVariableIndex("isJumping") != static_cast<std::size_t>(-1)) {
snapshot.bools[descriptor.GetBoolVariableIndex("isJumping")] = desired.isJumping;
}
if (descriptor.GetBoolVariableIndex("isCrouching") != static_cast<std::size_t>(-1)) {
snapshot.bools[descriptor.GetBoolVariableIndex("isCrouching")] = desired.isCrouching;
}
// Prepare int variables (equipment, etc.)
// Prepare int variables (iIsInSneak)
snapshot.ints.resize(descriptor.GetIntVariableCount(), 0);
const auto sneakIdx = descriptor.GetIntVariableIndex(GraphVar::kIsInSneak);
if (sneakIdx != static_cast<std::size_t>(-1)) {
snapshot.ints[sneakIdx] = desired.isSneaking ? 1U : 0U;
}
// Apply all variables at once via descriptor bulk write
if (!descriptor.LoadAnimationVariablesToCache(
*static_cast<RE::IAnimationGraphManagerHolder*>(&a_proxy),
+3 -1
View File
@@ -66,8 +66,10 @@ namespace F4T::ProxyPuppet
return;
}
auto* graphHolder = static_cast<RE::IAnimationGraphManagerHolder*>(a_this);
// "Speed" and "SpeedSmoothed" are the real FO4 humanoid graph float variables
// (MTBehavior.hkx). The previous "SpeedSampled" name does not exist on this graph.
graphHolder->SetGraphVariableFloat(RE::BSFixedString{ "Speed" }, a_speed);
graphHolder->SetGraphVariableFloat(RE::BSFixedString{ "SpeedSampled" }, a_speed);
graphHolder->SetGraphVariableFloat(RE::BSFixedString{ "SpeedSmoothed" }, a_speed);
}
void SyncControllerMotionFields(RE::Actor& a_actor, const RE::NiPoint3& a_velocity, float a_speed)
+6 -2
View File
@@ -296,9 +296,13 @@ namespace
const auto derivedMoving = a_movementSpeed >= kMovingSpeedThreshold;
const auto isMoving = ResolveMovementHoldState(derivedMoving, a_currentTime, a_movingHoldUntil);
const auto sneakDetection = GetSneakDetectionState(a_player);
// Jump detection relies solely on the IsJumping() API. The previous
// vertical-speed heuristic (a_verticalSpeed >= kJumpVerticalSpeedThreshold) produced
// constant false positives: (z - prevZ)/dt spikes to thousands of units/sec on flat
// ground due to tiny dt and z micro-jitter, so proxies were stuck in a perpetual jump
// state that masked the run/walk locomotion animation (see Fallout4Together.log).
const auto apiJumping = a_player.IsJumping();
const auto derivedJumping = a_verticalSpeed >= kJumpVerticalSpeedThreshold;
if (apiJumping || derivedJumping) {
if (apiJumping) {
jumpHoldUntil = a_currentTime + kJumpStateHoldDuration;
}