Add proxy puppet hook and graph speed sync

Introduce a puppet vtable hook to preserve networked locomotion Speed and fix proxy cadence. Added F4TProxyPuppet (header + impl) which installs an Actor::Update hook that writes the desired graph Speed and calls UpdateNoAI for proxy actors so the engine does not recompute/zero Speed from controller velocity. Publish active proxy FormIDs and per-proxy desired speeds so the hook targets only proxy actors.

Wire animationGraphSpeed through networking: parse/send a new "animationGraphSpeed" field and store it on RemotePlayerState. Proxy animation sync now prefers the authoritative animationGraphSpeed when present, otherwise maps movementSpeed ~1:1 to graph Speed (with sprint floor and clamping). Added graph variables speedSampled/speedDamped alongside Speed and updated snapshot population and diagnostics. Removed character-controller velocity injection (was causing physics/animation conflicts) and adjusted proxy movement logic to rely on graph variables plus kinematic SetPosition.

Also: Publish puppet diagnostics from the controller, add sender-side local graph speed read diagnostic, update fake_player to compute and send animationGraphSpeed, and append detailed dev-log entries describing the root cause, diagnostics, and fixes. Build and runtime diagnostics added to verify hook behavior and cadence changes.
This commit is contained in:
2026-06-04 20:18:34 +12:00
parent 2aab83087f
commit abef899505
11 changed files with 658 additions and 89 deletions
+23 -1
View File
@@ -361,6 +361,13 @@ namespace
movementSpeed = 0.0;
}
auto animationGraphSpeed = -1.0;
if (const auto graphSpeed = ReadDouble(a_packet, "animationGraphSpeed")) {
if (std::isfinite(*graphSpeed) && *graphSpeed >= 0.0) {
animationGraphSpeed = *graphSpeed;
}
}
// Phase 2: Parse actor state flags (optional, for action replay + animation control)
auto actorStateFlags1 = 0U;
auto actorStateFlags2 = 0U;
@@ -388,6 +395,7 @@ namespace
ReadBool(a_packet, "isJumping").value_or(false),
ReadBool(a_packet, "weaponDrawn").value_or(false),
static_cast<float>(movementSpeed),
static_cast<float>(animationGraphSpeed),
std::chrono::steady_clock::now()
};
@@ -690,7 +698,8 @@ namespace F4T::Networking
bool a_isSneaking,
bool a_isJumping,
bool a_weaponDrawn,
float a_movementSpeed)
float a_movementSpeed,
float a_animationGraphSpeed)
{
if (!IsConnectedToServer() && !ConnectToLocalServer()) {
return false;
@@ -718,6 +727,19 @@ namespace F4T::Networking
a_weaponDrawn ? "true" : "false",
a_movementSpeed);
if (packetSize > 0 && a_animationGraphSpeed >= 0.0F && std::isfinite(a_animationGraphSpeed)) {
const auto remaining = packet.size() - static_cast<std::size_t>(packetSize);
const auto appended = std::snprintf(
packet.data() + packetSize,
remaining,
",\"animationGraphSpeed\":%.1f",
a_animationGraphSpeed);
if (appended > 0 && static_cast<std::size_t>(appended) < remaining) {
packetSize += appended;
}
}
if (packetSize <= 0 || static_cast<std::size_t>(packetSize) >= packet.size()) {
LogWarningWithLocalPlayerPrefix("Could not format Fallout 4 Together transform packet.");
return false;
+67 -39
View File
@@ -4,6 +4,7 @@
#include "F4TNetworking.h"
#include "F4TProxyAnimationSync.h"
#include "F4TProxyPuppet.h"
#include "F4TRemotePlayerState.h"
#include "F4RemoteActionComponent.h"
#include "F4TInterpolationSystem.h"
@@ -499,31 +500,10 @@ namespace
return std::sqrt((deltaX * deltaX) + (deltaY * deltaY) + (deltaZ * deltaZ));
}
void InjectProxyMovementVelocity(RE::bhkCharacterController& a_charController, const RE::NiPoint3& a_deltaPos, float a_deltaSeconds)
{
if (a_deltaSeconds <= 0.0F) {
return;
}
const auto distance = a_deltaPos.Length();
if (distance <= 0.001F) {
const RE::hkVector4f stoppedVelocity{ 0.0F, 0.0F, 0.0F, 0.0F };
a_charController.SetLinearVelocityImpl(stoppedVelocity);
return;
}
constexpr float kMaxInjectedSpeed = 400.0F;
const auto unclampedSpeed = distance / a_deltaSeconds;
const auto clampedSpeed = unclampedSpeed > kMaxInjectedSpeed ? kMaxInjectedSpeed : unclampedSpeed;
const auto direction = a_deltaPos / distance;
const RE::hkVector4f velocity{
direction.x * clampedSpeed,
direction.y * clampedSpeed,
direction.z * clampedSpeed,
0.0F
};
a_charController.SetLinearVelocityImpl(velocity);
}
// NOTE: Character-controller velocity injection was removed. SetLinearVelocityImpl
// physically drives the Havok controller, which fights the SetPosition pin and the
// proxy's active AIProcess and causes forward/back glitching. Proxy locomotion
// animation is driven by graph variables (Speed/speedSampled) instead of real velocity.
// Hybrid movement strategy for AI-driven animations
// Phase 1: Let the proxy's natural AI/character controller drive movement
@@ -575,7 +555,7 @@ namespace
ProxyActorSlot* a_optSlot = nullptr)
{
(void)a_remotePlayer;
// DEBUG: Log AI state once per proxy
if (g_loggedProxies.find(&a_proxy) == g_loggedProxies.end()) {
g_loggedProxies.insert(&a_proxy);
@@ -620,15 +600,10 @@ namespace
a_proxy.SetHeading(a_heading);
a_proxy.SetPosition(positionToApply, true);
// Phase 6.1: Inject character controller velocity
// The animation system requires velocity on the character controller to evaluate
// animation graphs and blend between locomotion states. Without velocity, the
// "Speed" graph variable alone doesn't trigger animations.
// Access controller via: Actor::currentProcess -> AIProcess::middleHigh -> MiddleHighProcessData::charController
if (a_proxy.currentProcess && a_proxy.currentProcess->middleHigh && a_proxy.currentProcess->middleHigh->charController) {
const RE::NiPoint3 latestDeltaPos = a_nextPosition - currentPosition;
InjectProxyMovementVelocity(*a_proxy.currentProcess->middleHigh->charController, latestDeltaPos, deltaSeconds);
}
// Character controller velocity is injected once per frame in ApplySmoothFrameMovement
// (derived from the actual applied displacement). Injecting it here too, at packet rate,
// produced a velocity that disagreed with the per-frame translation and caused the walk
// animation to glide. Velocity is intentionally NOT set on the packet path.
if (a_optSlot) {
a_optSlot->lastPositionSyncTime = now;
@@ -5346,10 +5321,11 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
}
// Kinematic smoothing toward the network target.
// We intentionally avoid proxy->Move() + velocity injection: physics-driven
// movement combined with the proxy's active AIProcess caused unstable
// wandering/overshoot. SetPosition pins the actor deterministically while
// the locomotion animation is driven by the "Speed" graph variable (Phase 1).
// We intentionally avoid proxy->Move() + character-controller velocity
// injection: SetLinearVelocityImpl physically drives the Havok controller,
// which fights this SetPosition pin and the proxy's active AIProcess, causing
// forward/back oscillation. SetPosition pins the actor deterministically; the
// locomotion animation is driven by the graph "Speed" variable instead.
constexpr float kLargeDriftSnapDistance = 1500.0F;
RE::NiPoint3 nextPosition;
if (distanceToTarget > kLargeDriftSnapDistance) {
@@ -5369,6 +5345,56 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
namespace F4T::ProxyActorController
{
// Publishes the FormIDs of all actors currently backing proxy slots so the
// puppet hook (F4TProxyPuppet) can suppress engine locomotion Speed/Direction
// recompute on exactly those actors and nothing else.
void PublishActiveProxyFormIds()
{
std::vector<std::uint32_t> proxyFormIds;
proxyFormIds.reserve(g_proxySlots.size());
const auto appendHandleFormId = [&proxyFormIds](const RE::ObjectRefHandle& a_handle) {
if (!a_handle) {
return;
}
if (const auto ref = a_handle.get()) {
proxyFormIds.push_back(ref->GetFormID());
}
};
for (const auto& [_, slot] : g_proxySlots) {
appendHandleFormId(slot.proxyHandle);
}
// Placed fallback proxies are tracked outside the slot map.
appendHandleFormId(g_runtimeProxyHandle);
appendHandleFormId(g_proxyHandle);
std::string formIdList{ "[" };
for (std::size_t i = 0; i < proxyFormIds.size(); ++i) {
if (i != 0) {
formIdList += ", ";
}
formIdList += std::format("{:08X}", proxyFormIds[i]);
}
formIdList += "]";
std::uint64_t hookCalls = 0;
std::uint64_t hookSkips = 0;
F4T::ProxyPuppet::GetHookStats(hookCalls, hookSkips);
LogThrottledInfo(
"proxy_puppet_stats",
std::format(
"Proxy puppet diagnostics: publishedProxyFormIds={}, feedbackHookTotalCalls={}, feedbackHookProxySkips={}.",
formIdList,
hookCalls,
hookSkips),
1s);
F4T::ProxyPuppet::SetActiveProxyFormIDs(std::move(proxyFormIds));
}
void Update()
{
auto* player = RE::PlayerCharacter::GetSingleton();
@@ -5485,6 +5511,8 @@ namespace F4T::ProxyActorController
}
ApplySmoothFrameMovement();
PublishActiveProxyFormIds();
}
void UpdateSafetyTest()
+125 -46
View File
@@ -4,18 +4,23 @@
#include "F4TNetworking.h"
#include "F4AnimationDescriptor.h"
#include "F4TProxyPuppet.h"
#include "RE/B/BGSAnimationSystemUtils.h"
#include "RE/B/BSFixedString.h"
#include "RE/I/IAnimationGraphManagerHolder.h"
#include "RE/T/TESObjectREFR.h"
#include <algorithm>
#include <array>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <format>
#include <optional>
#include <string>
#include <string_view>
#include <unordered_map>
#include <unordered_set>
namespace
@@ -32,15 +37,19 @@ namespace
constexpr float kIdleSpeedThreshold = 1.0F;
constexpr float kSpeedEpsilon = 0.5F;
constexpr auto kProxyMovingStopHoldDuration = std::chrono::milliseconds{ 300 };
// Hold duration for movement stop debounce. Must be short so animation stop events
// fire promptly; long holds cause characters to glide while playing stop animations.
constexpr auto kProxyMovingStopHoldDuration = std::chrono::milliseconds{ 50 };
// Calibrated from local-player graph debug: walk/run graph Speed stays ~35-105 while
// network movementSpeed uses different units (fake client walk = 180).
// Graph Speed range confirmed from local-player read-by-name debug in
// Fallout4Together.log: walk starts ~47, full run ~373. FO4's graph "Speed" is in the
// same world-units/sec scale as our network movementSpeed, so the mapping is ~1:1.
// (The earlier 0-105 calibration was wrong and compressed run speed, causing foot slide.)
constexpr float kGraphSpeedMin = 0.0F;
constexpr float kGraphSpeedMax = 500.0F;
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;
// Used when a sprinting remote provides no usable speed sample.
constexpr float kGraphSpeedSprintFloor = 350.0F;
std::string GetLocalPlayerLogMessage(std::string_view a_message)
{
@@ -141,28 +150,52 @@ namespace
return a_holder.SetGraphVariableFloat(variableName, a_value);
}
float ClampGraphSpeed(float a_speed)
{
if (!std::isfinite(a_speed)) {
return kGraphSpeedIdle;
}
return std::clamp(a_speed, kGraphSpeedMin, kGraphSpeedMax);
}
void PopulateLocomotionSpeedSnapshot(
F4T::AnimationDescriptor::AnimationVariableSnapshot& a_snapshot,
const F4T::AnimationDescriptor::F4AnimationDescriptor& a_descriptor,
float a_graphSpeed)
{
const auto graphSpeed = ClampGraphSpeed(a_graphSpeed);
const auto speedIdx = a_descriptor.GetFloatVariableIndex(F4T::ProxyAnimationSync::GraphVar::kSpeed);
if (speedIdx != static_cast<std::size_t>(-1) && speedIdx < a_snapshot.floats.size()) {
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;
}
}
float ComputeGraphSpeed(const F4T::RemotePlayerState::RemotePlayerState& a_remotePlayer)
{
if (!a_remotePlayer.isMoving || a_remotePlayer.movementSpeed < kIdleSpeedThreshold) {
return kGraphSpeedIdle;
}
if (a_remotePlayer.isSprinting) {
return kGraphSpeedSprint;
// Prefer the sender's live graph Speed (TiltedEvolution sends full variable snapshots).
if (a_remotePlayer.animationGraphSpeed >= 0.0F && std::isfinite(a_remotePlayer.animationGraphSpeed)) {
return ClampGraphSpeed(a_remotePlayer.animationGraphSpeed);
}
// 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;
return F4T::ProxyAnimationSync::MapMovementSpeedToGraphSpeed(
a_remotePlayer.movementSpeed,
a_remotePlayer.isSprinting);
}
struct DesiredProxyAnimationState
@@ -208,18 +241,13 @@ namespace
{
const auto rawDesired = BuildDesiredState(a_remotePlayer);
auto desired = rawDesired;
// Apply minimal hold (50ms) for debounce, but don't coast; snap to idle immediately.
desired.isMoving = ApplyMovingHoldState(a_state, rawDesired.isMoving);
if (!desired.isMoving) {
desired.isSprinting = false;
desired.graphSpeed = kGraphSpeedIdle;
} else {
if (!rawDesired.isMoving &&
a_state.hasAppliedGraphState &&
a_state.lastGraphSpeed > kGraphSpeedIdle) {
desired.graphSpeed = a_state.lastGraphSpeed;
}
if (!a_remotePlayer.isSprinting) {
desired.isSprinting = false;
}
@@ -255,15 +283,20 @@ namespace
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);
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 && sprintWritten;
success &= speedWritten && speedLowerWritten && speedSampledWritten && speedDampedWritten && sprintWritten;
if (a_desired.isMoving) {
float readBackSpeed = 0.0F;
@@ -318,11 +351,7 @@ namespace
snapshot.bools.resize(descriptor.GetBoolVariableCount(), false);
snapshot.ints.resize(descriptor.GetIntVariableCount(), 0);
// Populate float variables
const auto speedIdx = descriptor.GetFloatVariableIndex("Speed");
if (speedIdx != static_cast<std::size_t>(-1)) {
snapshot.floats[speedIdx] = a_desired.graphSpeed;
}
PopulateLocomotionSpeedSnapshot(snapshot, descriptor, a_desired.graphSpeed);
const auto directionIdx = descriptor.GetFloatVariableIndex("direction");
if (directionIdx != static_cast<std::size_t>(-1)) {
@@ -427,6 +456,24 @@ namespace
namespace F4T::ProxyAnimationSync
{
float MapMovementSpeedToGraphSpeed(float a_movementSpeed, bool a_isSprinting)
{
if (a_movementSpeed < kIdleSpeedThreshold) {
return kGraphSpeedIdle;
}
// FO4 graph "Speed" is in the same world-units/sec scale as our derived
// movementSpeed (confirmed in Fallout4Together.log: run movementSpeed ~306 lines up
// with graph Speed ~296-373). Map ~1:1 so the walk/run foot cadence matches the
// proxy's actual ground translation and does not slide.
float graphSpeed = a_movementSpeed;
if (a_isSprinting) {
graphSpeed = std::max(graphSpeed, kGraphSpeedSprintFloor);
}
return ClampGraphSpeed(graphSpeed);
}
void ResetAppliedAnimationState(ProxyAppliedAnimationState& a_state)
{
a_state = {};
@@ -447,10 +494,49 @@ namespace F4T::ProxyAnimationSync
}
const auto desired = BuildDebouncedDesiredState(a_state, a_remotePlayer);
// Hand the puppet Update hook the speed to re-assert after the engine resets
// it each frame. Negative when idle so the engine's idle Speed (0) stands.
F4T::ProxyPuppet::SetProxyDesiredSpeed(
actorId,
desired.isMoving ? desired.graphSpeed : -1.0F);
// Throttled per-update diagnostic: shows what speed the proxy actually receives
// vs. computes, and reads back the live graph variables to detect engine clobber.
{
static std::unordered_map<std::uint32_t, std::chrono::steady_clock::time_point> s_lastDiag;
const auto now = std::chrono::steady_clock::now();
auto& last = s_lastDiag[a_remotePlayer.playerId];
if (last.time_since_epoch().count() == 0 || (now - last) >= std::chrono::milliseconds{ 500 }) {
last = now;
auto* diagHolder = static_cast<RE::IAnimationGraphManagerHolder*>(&a_proxy);
float readSpeed = -1.0F;
float readSpeedSampled = -1.0F;
const auto haveSpeed =
diagHolder->GetGraphVariableImplFloat(RE::BSFixedString{ "Speed" }, readSpeed);
const auto haveSpeedSampled =
diagHolder->GetGraphVariableImplFloat(RE::BSFixedString{ "speedSampled" }, readSpeedSampled);
LogInfoWithLocalPlayerPrefix(std::format(
"Proxy anim diag p{} actor={:08X}: recvMoving={}, recvSprint={}, recvMovementSpeed={:.1f}, recvAnimGraphSpeed={:.1f}, computedGraphSpeed={:.1f}, graphSpeedReadBack={}({:.1f}), speedSampledReadBack={}({:.1f}).",
a_remotePlayer.playerId,
actorId,
a_remotePlayer.isMoving,
a_remotePlayer.isSprinting,
a_remotePlayer.movementSpeed,
a_remotePlayer.animationGraphSpeed,
desired.graphSpeed,
haveSpeed,
readSpeed,
haveSpeedSampled,
readSpeedSampled));
}
}
// Apply every frame (not just when moving) so animation Speed blends smoothly.
// Havok needs continuous graph updates to match foot placement to velocity.
const auto shouldApply =
!a_state.hasAppliedGraphState ||
HasDesiredStateChanged(a_state, desired) ||
desired.isMoving;
HasDesiredStateChanged(a_state, desired);
if (!shouldApply) {
return;
@@ -532,15 +618,11 @@ namespace F4T::ProxyAnimationSync
// Prepare float variables (Speed, direction, etc.)
snapshot.floats.resize(descriptor.GetFloatVariableCount(), 0.0F);
// Speed: calculated from movement speed
if (descriptor.GetFloatVariableIndex("Speed") != static_cast<std::size_t>(-1)) {
const auto desired = BuildDesiredState(a_remotePlayer);
snapshot.floats[descriptor.GetFloatVariableIndex("Speed")] = desired.graphSpeed;
}
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)) {
const auto desired = BuildDesiredState(a_remotePlayer);
snapshot.floats[descriptor.GetFloatVariableIndex("direction")] = desired.direction;
}
@@ -548,17 +630,14 @@ namespace F4T::ProxyAnimationSync
snapshot.bools.resize(descriptor.GetBoolVariableCount(), false);
if (descriptor.GetBoolVariableIndex("isSprinting") != static_cast<std::size_t>(-1)) {
const auto desired = BuildDesiredState(a_remotePlayer);
snapshot.bools[descriptor.GetBoolVariableIndex("isSprinting")] = desired.isSprinting;
}
if (descriptor.GetBoolVariableIndex("isSneaking") != static_cast<std::size_t>(-1)) {
const auto desired = BuildDesiredState(a_remotePlayer);
snapshot.bools[descriptor.GetBoolVariableIndex("isSneaking")] = desired.isSneaking;
}
if (descriptor.GetBoolVariableIndex("isMoving") != static_cast<std::size_t>(-1)) {
const auto desired = BuildDesiredState(a_remotePlayer);
snapshot.bools[descriptor.GetBoolVariableIndex("isMoving")] = desired.isMoving;
}
+157
View File
@@ -0,0 +1,157 @@
#include "pch.h"
#include "F4TProxyPuppet.h"
#include "F4TNetworking.h"
#include <atomic>
#include <cmath>
#include <iterator>
#include <shared_mutex>
#include <string>
#include <string_view>
#include <unordered_map>
#include <unordered_set>
namespace F4T::ProxyPuppet
{
namespace
{
std::shared_mutex g_proxyFormIdMutex;
std::unordered_set<std::uint32_t> g_proxyFormIds;
std::unordered_map<std::uint32_t, float> g_proxyDesiredSpeed;
bool g_hookInstalled = false;
// Actor primary-vtable index of Update(float). CommonLibF4 documents this
// virtual at offset 0xCF (see RE/A/Actor.h). This is the per-frame actor
// update that is genuinely dispatched through the vtable (unlike the
// internal feedback routine at 0x129, which the engine calls by direct
// address, so hooking its vtable slot did nothing).
//
// On a SetPosition-driven proxy this update resets the locomotion graph
// "Speed" to 0 (controller velocity is ~0), which is why every proxy played
// the slowest in-state walk. We let Update run, then re-assert our Speed.
constexpr std::size_t kUpdateVtableIndex = 0xCF;
// x64 has a single calling convention, so the virtual member
// `void Update(float)` is binary-compatible with this free function
// (this -> rcx, delta -> xmm1).
using UpdateFn = void (*)(RE::Actor*, float);
UpdateFn g_originalUpdate = nullptr;
std::atomic<std::uint64_t> g_hookCallCount{ 0 };
std::atomic<std::uint64_t> g_proxyReassertCount{ 0 };
float GetProxyDesiredSpeed(std::uint32_t a_formID)
{
const std::shared_lock lock{ g_proxyFormIdMutex };
const auto it = g_proxyDesiredSpeed.find(a_formID);
return it != g_proxyDesiredSpeed.end() ? it->second : -1.0F;
}
void WriteProxySpeed(RE::Actor* a_this, float a_speed)
{
if (a_speed < 0.0F || !std::isfinite(a_speed)) {
return;
}
auto* graphHolder = static_cast<RE::IAnimationGraphManagerHolder*>(a_this);
graphHolder->SetGraphVariableFloat(RE::BSFixedString{ "Speed" }, a_speed);
graphHolder->SetGraphVariableFloat(RE::BSFixedString{ "SpeedSampled" }, a_speed);
}
void UpdateHook(RE::Actor* a_this, float a_delta)
{
g_hookCallCount.fetch_add(1, std::memory_order_relaxed);
const auto isProxy = a_this && IsProxyFormID(a_this->GetFormID());
if (!isProxy) {
// Real actors (and the player, on a separate vtable) update normally.
g_originalUpdate(a_this, a_delta);
return;
}
const auto speed = GetProxyDesiredSpeed(a_this->GetFormID());
// Puppet model: set the networked locomotion speed, then run the actor
// update WITHOUT AI. The full Update's AI step recomputes/zeroes the
// locomotion Speed from the (near-zero, SetPosition-driven) controller
// velocity BEFORE the pose is generated, which is why writing Speed after
// Update held the value but never changed the visible cadence. UpdateNoAI
// is the engine's own "update minus AI" path, so the pose is generated
// against the Speed we set here.
WriteProxySpeed(a_this, speed);
a_this->UpdateNoAI(a_delta);
WriteProxySpeed(a_this, speed);
g_proxyReassertCount.fetch_add(1, std::memory_order_relaxed);
}
}
void GetHookStats(std::uint64_t& a_outTotalCalls, std::uint64_t& a_outProxySkips)
{
a_outTotalCalls = g_hookCallCount.load(std::memory_order_relaxed);
a_outProxySkips = g_proxyReassertCount.load(std::memory_order_relaxed);
}
bool Install()
{
if (g_hookInstalled) {
return true;
}
REL::Relocation<std::uintptr_t> actorVtable{ RE::Actor::VTABLE[0] };
g_originalUpdate = reinterpret_cast<UpdateFn>(
actorVtable.write_vfunc(kUpdateVtableIndex, &UpdateHook));
g_hookInstalled = g_originalUpdate != nullptr;
const auto message =
F4T::Networking::GetLocalPlayerLogPrefix() +
(g_hookInstalled
? " Proxy puppet Update hook installed: networked proxy locomotion Speed is re-asserted after each engine actor update."
: " Proxy puppet Update hook FAILED to install: original Actor::Update was null.");
REX::INFO(std::string_view{ message });
return g_hookInstalled;
}
void SetActiveProxyFormIDs(std::vector<std::uint32_t> a_formIDs)
{
std::unordered_set<std::uint32_t> updated;
updated.reserve(a_formIDs.size());
for (const auto formId : a_formIDs) {
if (formId != 0) {
updated.insert(formId);
}
}
const std::unique_lock lock{ g_proxyFormIdMutex };
g_proxyFormIds = std::move(updated);
// Drop desired-speed entries for proxies that are no longer active.
for (auto it = g_proxyDesiredSpeed.begin(); it != g_proxyDesiredSpeed.end();) {
it = g_proxyFormIds.contains(it->first) ? std::next(it) : g_proxyDesiredSpeed.erase(it);
}
}
void SetProxyDesiredSpeed(std::uint32_t a_formID, float a_speed)
{
if (a_formID == 0) {
return;
}
const std::unique_lock lock{ g_proxyFormIdMutex };
g_proxyDesiredSpeed[a_formID] = a_speed;
}
bool IsProxyFormID(std::uint32_t a_formID)
{
if (a_formID == 0) {
return false;
}
const std::shared_lock lock{ g_proxyFormIdMutex };
return g_proxyFormIds.find(a_formID) != g_proxyFormIds.end();
}
}
+46 -1
View File
@@ -1,8 +1,12 @@
#include "F4TNetworking.h"
#include "F4TLocalAnimationGraphDebug.h"
#include "F4TProxyActorController.h"
#include "F4TProxyAnimationSync.h"
#include "F4TProxyPuppet.h"
#include "RE/A/ACTOR_STANCE.h"
#include "RE/B/BSFixedString.h"
#include "RE/I/IAnimationGraphManagerHolder.h"
#include <cmath>
#include <cstdlib>
@@ -81,6 +85,21 @@ namespace
return RE::PlayerCharacter::GetSingleton();
}
float TryReadLocalAnimationGraphSpeed(RE::PlayerCharacter& a_player)
{
auto* graphHolder = static_cast<RE::IAnimationGraphManagerHolder*>(&a_player);
float speed = -1.0F;
if (graphHolder->GetGraphVariableImplFloat(
RE::BSFixedString{ F4T::ProxyAnimationSync::GraphVar::kSpeed },
speed) &&
std::isfinite(speed) &&
speed >= 0.0F) {
return speed;
}
return -1.0F;
}
PlayerTransform GetPlayerTransform(const RE::PlayerCharacter& a_player)
{
const auto position = a_player.GetPosition();
@@ -491,6 +510,26 @@ namespace
return;
}
const auto animationGraphSpeed =
networkMovementState.isMoving ? TryReadLocalAnimationGraphSpeed(*player) : -1.0F;
// Sender-side diagnostic: reveals, from whichever instance owns the log,
// what this local player computes and transmits while moving. Throttled.
{
static auto s_lastSendDiag = std::chrono::steady_clock::time_point{};
const auto now = std::chrono::steady_clock::now();
if (networkMovementState.isMoving &&
(s_lastSendDiag.time_since_epoch().count() == 0 || (now - s_lastSendDiag) >= 500ms)) {
s_lastSendDiag = now;
LogInfoWithLocalPlayerPrefix(std::format(
"Local send diag: isMoving={}, isSprinting={}, movementSpeed={:.1f}, localGraphSpeedRead={:.1f}.",
networkMovementState.isMoving,
networkMovementState.isSprinting,
networkMovementState.movementSpeed,
animationGraphSpeed));
}
}
// Normal movement sends more often than logs, but still avoids packet-per-frame noise.
const auto sentTransform = F4T::Networking::SendTransformPacket(
currentTransform.x,
@@ -507,7 +546,8 @@ namespace
networkMovementState.isSneaking,
networkMovementState.isJumping,
networkMovementState.weaponDrawn,
networkMovementState.movementSpeed);
networkMovementState.movementSpeed,
animationGraphSpeed);
if (sentTransform) {
lastSentTransform = currentTransform;
lastKnownLocation = currentLocation;
@@ -540,6 +580,11 @@ namespace
return;
}
// Install the proxy puppet hook now that the executable is loaded and REL
// addresses resolve. This stops the engine from recomputing (and clobbering)
// the networked locomotion Speed on proxy actors.
F4T::ProxyPuppet::Install();
// The player reference may not exist at plugin load time. A permanent task lets us
// check on the game thread after load, while separate send and log gates keep updates useful.
taskInterface->AddTaskPermanent([]() {