Drive proxy cadence from measured motion

Tie proxy locomotion/cadence to actual per-frame movement and motion-feedback instead of only replaying transmitted Speed.

Changes:
- docs/dev-log.md: add detailed dev log describing measured-cadence fix and cadence/animationSpeed work.
- plugin/include/F4TProxyPuppet.h: added NiPoint3 include and new API: SetProxyLocomotionState, SetProxyDesiredSpeed (legacy alias), and ApplyProxyLocomotionFrame.
- plugin/lib/commonlibf4/include/RE/A/ActorMotionFeedbackOutput.h: new tentative struct for motion-feedback output.
- plugin/lib/commonlibf4/include/RE/Fallout.h: include new ActorMotionFeedbackOutput header.
- plugin/src/F4TProxyActorController.cpp: track per-frame timing/puppet state, compute frame delta, use proxy->Move() with measured frameDelta and call ApplyProxyLocomotionFrame (instead of SetPosition/Lerp-only), and pass measured graph speed/direction to proxy.
- plugin/src/F4TProxyAnimationSync.cpp: use SetProxyLocomotionState to include direction when writing proxy locomotion state.
- plugin/src/F4TProxyPuppet.cpp: introduce ProxyLocomotionState map, sync controller motion fields, compute/push engine motion-feedback (via Compute/Update feedback vtable calls), use UpdateNoAI + manual motion feedback to drive cadence, implement ApplyProxyLocomotionFrame which derives velocity from world delta, clamps/chooses feedback speed (uses measured horizontal speed unless provided graph speed is valid), applies an idle threshold (<8 u/s -> idle), and writes graph speed back to the animation graph.

Why: This makes foot cadence reflect on-screen translation (eliminating mild foot slide/stop-overstay) by measuring applied displacement each frame, updating controller velocity/motion-feedback, and re-asserting animation graph inputs after UpdateNoAI.
This commit is contained in:
2026-06-04 21:39:26 +12:00
parent abef899505
commit 65553a0c61
7 changed files with 347 additions and 49 deletions
+16 -4
View File
@@ -1,5 +1,7 @@
#pragma once
#include "RE/N/NiPoint3.h"
#include <cstdint>
#include <vector>
@@ -23,12 +25,22 @@ namespace F4T::ProxyPuppet
// from the game-thread proxy controller each tick. Thread-safe.
void SetActiveProxyFormIDs(std::vector<std::uint32_t> a_formIDs);
// Records the locomotion graph Speed the proxy should hold. The Update hook
// re-asserts this immediately after the engine's per-frame actor update (which
// otherwise resets Speed to 0 on a SetPosition-driven actor). Negative speed
// means "no override" (idle). Thread-safe.
// Records locomotion state the puppet should hold. Negative speed means idle
// (no override). Thread-safe.
void SetProxyLocomotionState(std::uint32_t a_formID, float a_speed, float a_direction);
// Legacy alias for speed-only callers.
void SetProxyDesiredSpeed(std::uint32_t a_formID, float a_speed);
// Syncs controller/movement-output velocity and pushes motion feedback into the
// animation graph. Call once per frame after applying proxy displacement (e.g. Move).
void ApplyProxyLocomotionFrame(
RE::Actor& a_actor,
const RE::NiPoint3& a_worldDelta,
float a_deltaSeconds,
float a_graphSpeed,
float a_direction);
// Thread-safe membership test used by the vtable hook to gate proxies from
// real NPCs (which must keep their engine-driven locomotion).
bool IsProxyFormID(std::uint32_t a_formID);
@@ -0,0 +1,17 @@
#pragma once
namespace RE
{
// Tentative layout for the output of Actor::ComputeMotionFeedbackSpeedAndDirection /
// Actor::UpdateFeedbackGraphSpeedAndDirection. Only speed + direction are required
// for the graph feedback write path; extra padding guards against struct growth.
class ActorMotionFeedbackOutput
{
public:
float speed{ 0.0F }; // 00 - locomotion graph speed (world units/sec scale)
float direction{ 0.0F }; // 04 - facing / movement direction (radians)
float field08{ 0.0F }; // 08
float field0C{ 0.0F }; // 0C
};
static_assert(sizeof(ActorMotionFeedbackOutput) == 0x10);
}
@@ -35,6 +35,7 @@
#include "RE/A/ActorItemEquipped.h"
#include "RE/A/ActorKill.h"
#include "RE/A/ActorMotionFeedbackData.h"
#include "RE/A/ActorMotionFeedbackOutput.h"
#include "RE/A/ActorMovementData.h"
#include "RE/A/ActorPackage.h"
#include "RE/A/ActorPackageData.h"
+58 -9
View File
@@ -251,6 +251,14 @@ namespace
std::chrono::steady_clock::time_point settleStartTime{};
std::chrono::steady_clock::time_point heldSinceTime{};
std::chrono::steady_clock::time_point lastPositionSyncTime{};
// Per-frame translation cadence tracking. The proxy is moved by SetPosition with
// exponential Lerp easing, so its instantaneous ground speed varies even when the
// remote moves at a constant pace. We measure the actual applied displacement each
// frame and feed an EMA of it to the animation puppet so the walk/run foot cadence
// tracks real on-screen motion (eliminates foot slide / glide and stop-overstay).
std::chrono::steady_clock::time_point lastSmoothMoveTime{};
float puppetGraphSpeed{ -1.0F };
float puppetDirection{ 0.0F };
RE::NiPoint3 targetPosition{}; // Current target position for smooth movement
float targetHeading{ 0.0F }; // Current target heading for smooth movement
float initialRemoteSpawnHeading{ 0.0F };
@@ -4960,6 +4968,25 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
// Store latest target for smooth frame movement
a_slot.targetPosition = targetPosition;
a_slot.targetHeading = a_remotePlayer.angleZ;
a_slot.puppetDirection = a_remotePlayer.angleZ;
if (a_remotePlayer.isMoving && a_remotePlayer.movementSpeed >= 1.0F) {
if (a_remotePlayer.animationGraphSpeed >= 0.0F &&
std::isfinite(a_remotePlayer.animationGraphSpeed)) {
a_slot.puppetGraphSpeed = a_remotePlayer.animationGraphSpeed;
} else {
a_slot.puppetGraphSpeed = F4T::ProxyAnimationSync::MapMovementSpeedToGraphSpeed(
a_remotePlayer.movementSpeed,
a_remotePlayer.isSprinting);
}
} else {
a_slot.puppetGraphSpeed = -1.0F;
}
F4T::ProxyPuppet::SetProxyLocomotionState(
a_proxy.GetFormID(),
a_slot.puppetGraphSpeed,
a_slot.puppetDirection);
const auto shouldSnap = ShouldSnapRemoteMovement(a_remotePlayer.movementType);
ApplyRuntimeProxyTransform(a_proxy, targetPosition, a_remotePlayer.angleZ, a_remotePlayer, shouldSnap, &a_slot);
@@ -5313,32 +5340,54 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
proxy->SetHeading(slot.targetHeading);
const auto frameNow = std::chrono::steady_clock::now();
float frameDeltaSeconds = 1.0F / 60.0F;
if (slot.lastSmoothMoveTime != std::chrono::steady_clock::time_point{}) {
frameDeltaSeconds =
std::chrono::duration<float>(frameNow - slot.lastSmoothMoveTime).count();
}
slot.lastSmoothMoveTime = frameNow;
frameDeltaSeconds = std::clamp(frameDeltaSeconds, 1.0F / 120.0F, 0.1F);
// Already at target: pin exactly and stop. This prevents the actor from
// drifting/wandering around a fixed target while the remote is idle.
if (distanceToTarget < 0.1F) {
proxy->SetPosition(slot.targetPosition, true);
F4T::ProxyPuppet::ApplyProxyLocomotionFrame(
*proxy,
RE::NiPoint3{},
frameDeltaSeconds,
-1.0F,
slot.puppetDirection);
continue;
}
// Kinematic smoothing toward the network target.
// 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) {
// Recover instantly from very large gaps (e.g. cell change / teleport).
nextPosition = slot.targetPosition;
} else {
// Smoothly converge a fraction of the remaining distance each frame.
constexpr float kPerFrameApproachAlpha = 0.25F;
nextPosition = F4T::InterpolationSystem::Lerp(
currentPosition, slot.targetPosition, kPerFrameApproachAlpha);
}
proxy->SetPosition(nextPosition, true);
const RE::NiPoint3 frameDelta{
nextPosition.x - currentPosition.x,
nextPosition.y - currentPosition.y,
nextPosition.z - currentPosition.z
};
// Move through the character controller so FO4's motion-feedback path sees
// real velocity (within-state walk cadence). SetPosition alone leaves ~0
// controller velocity, so every sub-run pace used the same authored walk.
proxy->Move(frameDeltaSeconds, frameDelta, true);
F4T::ProxyPuppet::ApplyProxyLocomotionFrame(
*proxy,
frameDelta,
frameDeltaSeconds,
slot.puppetGraphSpeed,
slot.puppetDirection);
}
}
}
+3 -2
View File
@@ -497,9 +497,10 @@ namespace F4T::ProxyAnimationSync
// 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(
F4T::ProxyPuppet::SetProxyLocomotionState(
actorId,
desired.isMoving ? desired.graphSpeed : -1.0F);
desired.isMoving ? desired.graphSpeed : -1.0F,
a_remotePlayer.angleZ);
// Throttled per-update diagnostic: shows what speed the proxy actually receives
// vs. computes, and reads back the live graph variables to detect engine clobber.
+163 -34
View File
@@ -4,6 +4,15 @@
#include "F4TNetworking.h"
#include "RE/A/AIProcess.h"
#include "RE/A/ActorMotionFeedbackData.h"
#include "RE/A/ActorMotionFeedbackOutput.h"
#include "RE/B/bhkCharacterController.h"
#include "RE/H/HighProcessData.h"
#include "RE/I/IAnimationGraphManagerHolder.h"
#include "RE/M/MiddleHighProcessData.h"
#include "RE/N/NiPoint3.h"
#include <atomic>
#include <cmath>
#include <iterator>
@@ -17,40 +26,41 @@ namespace F4T::ProxyPuppet
{
namespace
{
struct ProxyLocomotionState
{
float speed{ -1.0F };
float direction{ 0.0F };
};
std::shared_mutex g_proxyFormIdMutex;
std::unordered_set<std::uint32_t> g_proxyFormIds;
std::unordered_map<std::uint32_t, float> g_proxyDesiredSpeed;
std::unordered_map<std::uint32_t, ProxyLocomotionState> g_proxyLocomotion;
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;
constexpr std::size_t kComputeFeedbackVtableIndex = 0x128;
constexpr std::size_t kUpdateFeedbackVtableIndex = 0x129;
// 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);
using ComputeFeedbackFn =
bool (*)(RE::Actor*, const RE::ActorMotionFeedbackData&, float, RE::ActorMotionFeedbackOutput&);
using UpdateFeedbackFn = bool (*)(RE::Actor*, const RE::ActorMotionFeedbackOutput&);
UpdateFn g_originalUpdate = nullptr;
std::atomic<std::uint64_t> g_hookCallCount{ 0 };
std::atomic<std::uint64_t> g_proxyReassertCount{ 0 };
std::atomic<std::uint64_t> g_motionFeedbackPushCount{ 0 };
float GetProxyDesiredSpeed(std::uint32_t a_formID)
ProxyLocomotionState GetProxyLocomotionState(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;
const auto it = g_proxyLocomotion.find(a_formID);
return it != g_proxyLocomotion.end() ? it->second : ProxyLocomotionState{};
}
void WriteProxySpeed(RE::Actor* a_this, float a_speed)
void WriteProxyGraphSpeed(RE::Actor* a_this, float a_speed)
{
if (a_speed < 0.0F || !std::isfinite(a_speed)) {
return;
@@ -60,29 +70,103 @@ namespace F4T::ProxyPuppet
graphHolder->SetGraphVariableFloat(RE::BSFixedString{ "SpeedSampled" }, a_speed);
}
void SyncControllerMotionFields(RE::Actor& a_actor, const RE::NiPoint3& a_velocity, float a_speed)
{
auto* process = a_actor.currentProcess;
if (!process) {
return;
}
if (auto* middleHigh = process->middleHigh) {
middleHigh->desiredSpeed = a_speed;
middleHigh->animationSpeed = a_speed;
if (auto* controller = middleHigh->charController.get()) {
controller->cachedLinearVelocity = a_velocity;
controller->outVelocity = RE::hkVector4f{ a_velocity.x, a_velocity.y, a_velocity.z, 0.0F };
}
}
if (auto* high = process->high) {
high->movementOutput.handlerData.movementSpeed = a_speed;
high->movementOutput.tweenerData.velocity = a_velocity;
high->pathingCurrentMovementSpeed = a_velocity;
high->pathingDesiredMovementSpeed = a_velocity;
}
}
bool TryPushMotionFeedbackToGraph(
RE::Actor& a_actor,
const RE::NiPoint3& a_worldDelta,
float a_deltaSeconds,
float a_graphSpeed,
float a_direction)
{
if (a_graphSpeed < 0.0F || !std::isfinite(a_graphSpeed) || !std::isfinite(a_direction)) {
return false;
}
REL::Relocation<std::uintptr_t> actorVtable{ RE::Actor::VTABLE[0] };
const auto computeFeedback = reinterpret_cast<ComputeFeedbackFn>(
actorVtable.get()[kComputeFeedbackVtableIndex]);
const auto updateFeedback = reinterpret_cast<UpdateFeedbackFn>(
actorVtable.get()[kUpdateFeedbackVtableIndex]);
if (!updateFeedback) {
return false;
}
RE::ActorMotionFeedbackOutput feedback{};
feedback.speed = a_graphSpeed;
feedback.direction = a_direction;
if (computeFeedback) {
RE::ActorMotionFeedbackData motionData{};
motionData.desiredWorldDelta = a_worldDelta;
motionData.actualWorldDelta = a_worldDelta;
motionData.currentHeading = a_direction;
motionData.previousSpeed = a_graphSpeed;
motionData.previousDirection = a_direction;
const auto safeDelta = std::clamp(a_deltaSeconds, 1.0F / 120.0F, 0.1F);
if (computeFeedback(&a_actor, motionData, safeDelta, feedback)) {
feedback.speed = a_graphSpeed;
}
}
const auto pushed = updateFeedback(&a_actor, feedback);
if (pushed) {
g_motionFeedbackPushCount.fetch_add(1, std::memory_order_relaxed);
}
return pushed;
}
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());
const auto locomotion = GetProxyLocomotionState(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);
WriteProxyGraphSpeed(a_this, locomotion.speed);
a_this->UpdateNoAI(a_delta);
WriteProxySpeed(a_this, speed);
if (locomotion.speed >= 0.0F) {
const RE::NiPoint3 zeroVelocity{};
SyncControllerMotionFields(*a_this, zeroVelocity, locomotion.speed);
TryPushMotionFeedbackToGraph(
*a_this,
RE::NiPoint3{},
a_delta,
locomotion.speed,
locomotion.direction);
WriteProxyGraphSpeed(a_this, locomotion.speed);
}
g_proxyReassertCount.fetch_add(1, std::memory_order_relaxed);
}
@@ -109,7 +193,7 @@ namespace F4T::ProxyPuppet
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 installed: proxies use UpdateNoAI + manual motion feedback for cadence."
: " Proxy puppet Update hook FAILED to install: original Actor::Update was null.");
REX::INFO(std::string_view{ message });
@@ -129,20 +213,65 @@ namespace F4T::ProxyPuppet
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);
for (auto it = g_proxyLocomotion.begin(); it != g_proxyLocomotion.end();) {
it = g_proxyFormIds.contains(it->first) ? std::next(it) : g_proxyLocomotion.erase(it);
}
}
void SetProxyDesiredSpeed(std::uint32_t a_formID, float a_speed)
void SetProxyLocomotionState(std::uint32_t a_formID, float a_speed, float a_direction)
{
if (a_formID == 0) {
return;
}
const std::unique_lock lock{ g_proxyFormIdMutex };
g_proxyDesiredSpeed[a_formID] = a_speed;
g_proxyLocomotion[a_formID] = ProxyLocomotionState{ a_speed, a_direction };
}
void SetProxyDesiredSpeed(std::uint32_t a_formID, float a_speed)
{
const auto existing = GetProxyLocomotionState(a_formID);
SetProxyLocomotionState(a_formID, a_speed, existing.direction);
}
void ApplyProxyLocomotionFrame(
RE::Actor& a_actor,
const RE::NiPoint3& a_worldDelta,
float a_deltaSeconds,
float a_graphSpeed,
float a_direction)
{
if (!IsProxyFormID(a_actor.GetFormID())) {
return;
}
const float safeDelta = std::clamp(a_deltaSeconds, 1.0F / 120.0F, 0.1F);
RE::NiPoint3 velocity{};
if (safeDelta > 0.0F) {
velocity.x = a_worldDelta.x / safeDelta;
velocity.y = a_worldDelta.y / safeDelta;
velocity.z = a_worldDelta.z / safeDelta;
}
const float horizontalSpeed = std::sqrt((velocity.x * velocity.x) + (velocity.y * velocity.y));
const float feedbackSpeed =
(a_graphSpeed >= 0.0F && std::isfinite(a_graphSpeed)) ? a_graphSpeed : horizontalSpeed;
if (feedbackSpeed < 8.0F) {
SetProxyLocomotionState(a_actor.GetFormID(), -1.0F, a_direction);
SyncControllerMotionFields(a_actor, {}, 0.0F);
return;
}
SetProxyLocomotionState(a_actor.GetFormID(), feedbackSpeed, a_direction);
SyncControllerMotionFields(a_actor, velocity, feedbackSpeed);
TryPushMotionFeedbackToGraph(
a_actor,
a_worldDelta,
a_deltaSeconds,
feedbackSpeed,
a_direction);
WriteProxyGraphSpeed(&a_actor, feedbackSpeed);
}
bool IsProxyFormID(std::uint32_t a_formID)