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
+89
View File
@@ -4568,3 +4568,92 @@ Circle) decides: proxy should now jog at Speed 180 with a visibly faster cadence
### Build Status ### Build Status
✅ **SUCCESS** - `xmake build` clean; DLL deployed to game F4SE/Plugins. ✅ **SUCCESS** - `xmake build` clean; DLL deployed to game F4SE/Plugins.
---
## 2026-06-04 - Proxy Cadence WORKS (UpdateNoAI) + Glide Fix: Drive Cadence From Measured Translation
### Result
The `UpdateNoAI` puppet works in-game: proxies now show varying cadence including a
real run. Confirmed by the user. Remaining issue: "a little glidy" (mild foot slide).
### Root Cause Of Residual Glide
`ApplySmoothFrameMovement()` moves the proxy by `SetPosition` with exponential Lerp
easing (`Lerp(current, target, 0.25)` per frame). The leg cadence, however, was a
constant *transmitted* graph Speed. Because the eased translation speed varies frame
to frame (fast right after a packet, slowing as it converges) while the cadence is
constant, the feet slide.
### Fix
Drive the puppet cadence from the proxy's ACTUAL horizontal translation each frame
instead of the transmitted speed:
- In `ApplySmoothFrameMovement`, after computing `nextPosition`, measure
`horizontalDistance(current, next) / frameDelta` and EMA-smooth it
(`measuredGraphSpeed`, alpha 0.35).
- Feed that to `F4T::ProxyPuppet::SetProxyDesiredSpeed` (units/sec ≈ graph Speed,
~1:1). Below an 8 u/s idle threshold we publish -1 so the engine's idle stands.
- `ApplySmoothFrameMovement` runs LAST in `ProxyActorController::Update`, after the
per-player anim sync (which also sets a transmitted desired speed), so the measured
value authoritatively wins for the puppet re-assert.
This ties foot cadence to real on-screen motion regardless of interpolation easing,
which also makes the animation decelerate to idle naturally as the proxy converges on
a stopped target (no stop-overstay).
### Build Status
✅ **SUCCESS** - `xmake build` clean; DLL deployed to game F4SE/Plugins.
---
## 2026-06-04 - Within-State Walk Cadence: middleProcess->animationSpeed Is The FO4 Speed-Warp Lever
### What The User Observed
After the measured-cadence build: only the RUN animation is distinct. Slow walk,
medium walk, and jog all play the SAME walk clip. So `Speed` switches the locomotion
STATE (walk<->run via a threshold) but does NOT vary the foot cadence WITHIN the walk
state.
### Evidence
- Sender-side `Local send diag`: the local player's own graph `Speed` saturates into
bands (walk ~60-160, run 373, sprint 500). `Speed` is a state selector, not a
continuous cadence driver.
- Writing `Speed`/`SpeedSampled` on the proxy (even persisting via the UpdateNoAI
puppet) only flips walk<->run; sub-walk paces look identical.
- `speedSampled` (lowercase) reads back false → not a real FO4 graph variable.
### TiltedEvolution Comparison
TE (Skyrim) pins position with `ForcePosition`, disables AI (`HookActorProcess`
returns 0), and replays a curated graph-variable snapshot incl. `Speed`,
`SpeedSampled`, `Direction`, `bMotionDriven`, `bIsSynced`. In Skyrim the leg blend is
driven by the `SpeedSampled` graph variable, so pure variable replay reproduces
cadence. FO4's player graph instead WARPS the clip from the actor's real velocity, so
variable replay alone can't reproduce sub-state cadence on a SetPosition puppet.
### Fix: Drive middleProcess->animationSpeed / desiredSpeed
`MiddleHighProcessData` (CommonLibF4, reached via `actor->currentProcess->middleHigh`)
exposes `float desiredSpeed (0x428)` and `float animationSpeed (0x42C)`. These are the
engine's locomotion speed-warp inputs, normally derived from controller velocity (~0
on a SetPosition puppet → fixed cadence). In `F4TProxyPuppet::WriteProxySpeed` (called
around the UpdateNoAI puppet for proxy FormIDs) we now also write both fields to the
proxy's measured ground speed (units/sec, same scale as graph Speed). This is the FO4
analog of TE poking `middleProcess->direction`, and should make the legs cycle at the
proxy's real pace within the walk state.
### Open Risk / Next Test
If `animationSpeed` is an engine OUTPUT (graph writes it) rather than an input, this
won't change cadence and we move to the velocity-driven movement rework. Next test
(slow walk vs medium vs jog) decides: sub-walk cadence should now visibly differ.
### Build Status
✅ **SUCCESS** - `xmake build` clean; DLL deployed to game F4SE/Plugins.
+16 -4
View File
@@ -1,5 +1,7 @@
#pragma once #pragma once
#include "RE/N/NiPoint3.h"
#include <cstdint> #include <cstdint>
#include <vector> #include <vector>
@@ -23,12 +25,22 @@ namespace F4T::ProxyPuppet
// from the game-thread proxy controller each tick. Thread-safe. // from the game-thread proxy controller each tick. Thread-safe.
void SetActiveProxyFormIDs(std::vector<std::uint32_t> a_formIDs); void SetActiveProxyFormIDs(std::vector<std::uint32_t> a_formIDs);
// Records the locomotion graph Speed the proxy should hold. The Update hook // Records locomotion state the puppet should hold. Negative speed means idle
// re-asserts this immediately after the engine's per-frame actor update (which // (no override). Thread-safe.
// otherwise resets Speed to 0 on a SetPosition-driven actor). Negative speed void SetProxyLocomotionState(std::uint32_t a_formID, float a_speed, float a_direction);
// means "no override" (idle). Thread-safe.
// Legacy alias for speed-only callers.
void SetProxyDesiredSpeed(std::uint32_t a_formID, float a_speed); 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 // Thread-safe membership test used by the vtable hook to gate proxies from
// real NPCs (which must keep their engine-driven locomotion). // real NPCs (which must keep their engine-driven locomotion).
bool IsProxyFormID(std::uint32_t a_formID); 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/ActorItemEquipped.h"
#include "RE/A/ActorKill.h" #include "RE/A/ActorKill.h"
#include "RE/A/ActorMotionFeedbackData.h" #include "RE/A/ActorMotionFeedbackData.h"
#include "RE/A/ActorMotionFeedbackOutput.h"
#include "RE/A/ActorMovementData.h" #include "RE/A/ActorMovementData.h"
#include "RE/A/ActorPackage.h" #include "RE/A/ActorPackage.h"
#include "RE/A/ActorPackageData.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 settleStartTime{};
std::chrono::steady_clock::time_point heldSinceTime{}; std::chrono::steady_clock::time_point heldSinceTime{};
std::chrono::steady_clock::time_point lastPositionSyncTime{}; 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 RE::NiPoint3 targetPosition{}; // Current target position for smooth movement
float targetHeading{ 0.0F }; // Current target heading for smooth movement float targetHeading{ 0.0F }; // Current target heading for smooth movement
float initialRemoteSpawnHeading{ 0.0F }; 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 // Store latest target for smooth frame movement
a_slot.targetPosition = targetPosition; a_slot.targetPosition = targetPosition;
a_slot.targetHeading = a_remotePlayer.angleZ; 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); const auto shouldSnap = ShouldSnapRemoteMovement(a_remotePlayer.movementType);
ApplyRuntimeProxyTransform(a_proxy, targetPosition, a_remotePlayer.angleZ, a_remotePlayer, shouldSnap, &a_slot); 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); 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 // Already at target: pin exactly and stop. This prevents the actor from
// drifting/wandering around a fixed target while the remote is idle. // drifting/wandering around a fixed target while the remote is idle.
if (distanceToTarget < 0.1F) { if (distanceToTarget < 0.1F) {
proxy->SetPosition(slot.targetPosition, true); proxy->SetPosition(slot.targetPosition, true);
F4T::ProxyPuppet::ApplyProxyLocomotionFrame(
*proxy,
RE::NiPoint3{},
frameDeltaSeconds,
-1.0F,
slot.puppetDirection);
continue; 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; constexpr float kLargeDriftSnapDistance = 1500.0F;
RE::NiPoint3 nextPosition; RE::NiPoint3 nextPosition;
if (distanceToTarget > kLargeDriftSnapDistance) { if (distanceToTarget > kLargeDriftSnapDistance) {
// Recover instantly from very large gaps (e.g. cell change / teleport).
nextPosition = slot.targetPosition; nextPosition = slot.targetPosition;
} else { } else {
// Smoothly converge a fraction of the remaining distance each frame.
constexpr float kPerFrameApproachAlpha = 0.25F; constexpr float kPerFrameApproachAlpha = 0.25F;
nextPosition = F4T::InterpolationSystem::Lerp( nextPosition = F4T::InterpolationSystem::Lerp(
currentPosition, slot.targetPosition, kPerFrameApproachAlpha); 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 // 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. // it each frame. Negative when idle so the engine's idle Speed (0) stands.
F4T::ProxyPuppet::SetProxyDesiredSpeed( F4T::ProxyPuppet::SetProxyLocomotionState(
actorId, 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 // Throttled per-update diagnostic: shows what speed the proxy actually receives
// vs. computes, and reads back the live graph variables to detect engine clobber. // 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 "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 <atomic>
#include <cmath> #include <cmath>
#include <iterator> #include <iterator>
@@ -17,40 +26,41 @@ namespace F4T::ProxyPuppet
{ {
namespace namespace
{ {
struct ProxyLocomotionState
{
float speed{ -1.0F };
float direction{ 0.0F };
};
std::shared_mutex g_proxyFormIdMutex; std::shared_mutex g_proxyFormIdMutex;
std::unordered_set<std::uint32_t> g_proxyFormIds; 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; 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 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 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; UpdateFn g_originalUpdate = nullptr;
std::atomic<std::uint64_t> g_hookCallCount{ 0 }; std::atomic<std::uint64_t> g_hookCallCount{ 0 };
std::atomic<std::uint64_t> g_proxyReassertCount{ 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 std::shared_lock lock{ g_proxyFormIdMutex };
const auto it = g_proxyDesiredSpeed.find(a_formID); const auto it = g_proxyLocomotion.find(a_formID);
return it != g_proxyDesiredSpeed.end() ? it->second : -1.0F; 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)) { if (a_speed < 0.0F || !std::isfinite(a_speed)) {
return; return;
@@ -60,29 +70,103 @@ namespace F4T::ProxyPuppet
graphHolder->SetGraphVariableFloat(RE::BSFixedString{ "SpeedSampled" }, a_speed); 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) void UpdateHook(RE::Actor* a_this, float a_delta)
{ {
g_hookCallCount.fetch_add(1, std::memory_order_relaxed); g_hookCallCount.fetch_add(1, std::memory_order_relaxed);
const auto isProxy = a_this && IsProxyFormID(a_this->GetFormID()); const auto isProxy = a_this && IsProxyFormID(a_this->GetFormID());
if (!isProxy) { if (!isProxy) {
// Real actors (and the player, on a separate vtable) update normally.
g_originalUpdate(a_this, a_delta); g_originalUpdate(a_this, a_delta);
return; 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 WriteProxyGraphSpeed(a_this, locomotion.speed);
// 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); 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); g_proxyReassertCount.fetch_add(1, std::memory_order_relaxed);
} }
@@ -109,7 +193,7 @@ namespace F4T::ProxyPuppet
const auto message = const auto message =
F4T::Networking::GetLocalPlayerLogPrefix() + F4T::Networking::GetLocalPlayerLogPrefix() +
(g_hookInstalled (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."); : " Proxy puppet Update hook FAILED to install: original Actor::Update was null.");
REX::INFO(std::string_view{ message }); REX::INFO(std::string_view{ message });
@@ -129,20 +213,65 @@ namespace F4T::ProxyPuppet
const std::unique_lock lock{ g_proxyFormIdMutex }; const std::unique_lock lock{ g_proxyFormIdMutex };
g_proxyFormIds = std::move(updated); g_proxyFormIds = std::move(updated);
// Drop desired-speed entries for proxies that are no longer active. for (auto it = g_proxyLocomotion.begin(); it != g_proxyLocomotion.end();) {
for (auto it = g_proxyDesiredSpeed.begin(); it != g_proxyDesiredSpeed.end();) { it = g_proxyFormIds.contains(it->first) ? std::next(it) : g_proxyLocomotion.erase(it);
it = g_proxyFormIds.contains(it->first) ? std::next(it) : g_proxyDesiredSpeed.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) { if (a_formID == 0) {
return; return;
} }
const std::unique_lock lock{ g_proxyFormIdMutex }; 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) bool IsProxyFormID(std::uint32_t a_formID)