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