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
+169
View File
@@ -4399,3 +4399,172 @@ The crash correlated with disabling `g_preserveProxyAIForAnimationTesting` and r
### Build Status ### Build Status
✅ **SUCCESS** - Rollback build succeeded and the DLL was installed to the F4SE folder. ✅ **SUCCESS** - Rollback build succeeded and the DLL was installed to the F4SE folder.
---
## 2026-06-04 - Proxy Locomotion Cadence: Engine Speed Clobber Root Cause + Puppet Hook
### Symptom
Remote proxy actors always played a single fixed "medium walk" cadence regardless
of the sender's real speed (slow walk, normal walk, run all looked the same).
Neither the earlier 0-105 bucket mapping nor the corrected ~1:1
`movementSpeed -> graph Speed` mapping changed the cadence at all.
### Root Cause (confirmed)
Fallout 4 recomputes the locomotion graph `Speed`/`Direction` every frame from the
actor's **actual character-controller velocity**, via the actor virtual
`UpdateFeedbackGraphSpeedAndDirection` (CommonLibF4 Actor vtable index `0x129`,
fed by `ComputeMotionFeedbackSpeedAndDirection` at `0x128`).
Proxies move via `SetPosition` (kinematic teleport), so their controller velocity
is ~0. With the proxy's AI/process left active (`g_preserveProxyAIForAnimationTesting`),
the engine writes Speed≈0 into the graph every frame, **overwriting** whatever
networked Speed we set. The `isMoving` bool still forces a walk state, so the graph
plays the slowest in-state walk cadence — the observed fixed "medium walk".
This is the same architectural limitation noted earlier, now pinned to the exact
engine routine responsible for the clobber.
### Why TiltedEvolution Doesn't Have This
TiltedEvolution remotes are **puppets**: it hooks `Actor::Process(float)` and returns
early for remote actors (`Code/client/Games/Skyrim/Actor.cpp` `HookActorProcess`),
so the engine never recomputes their locomotion. The proxy is driven entirely by
replayed graph variables (`LoadAnimationVariables`, raw behavior-graph memory writes)
+ `SetPosition` each interpolation tick. Note their locomotion variable is
`SpeedSampled` (see `Actor::GetSpeed`/`SetSpeed`).
### Fix (this change)
Surgically scoped puppet hook instead of disabling the whole AI update (the broad
neutralization path previously crashed):
- New module `plugin/src/F4TProxyPuppet.cpp` + `plugin/include/F4TProxyPuppet.h`.
- Hooks `RE::Actor::VTABLE[0]` index `0x129` (`UpdateFeedbackGraphSpeedAndDirection`).
For actors that back active proxy slots, the hook **skips** the engine call (returns
`false`) so our networked Speed/Direction persists; all real NPCs fall through to
the original implementation unchanged. The local player is a `PlayerCharacter`
(separate vtable) and is never affected.
- `F4TProxyActorController::Update()` publishes the current proxy actor FormIDs each
tick via `ProxyPuppet::SetActiveProxyFormIDs()`; the hook gates on
`ProxyPuppet::IsProxyFormID()` (shared_mutex-guarded set).
- Hook installed once from `StartPlayerPositionPolling()` (after game load, so REL
addresses resolve). No trampoline needed for a vtable swap.
With the engine no longer clobbering Speed, the existing per-frame graph-variable
writes (now using the ~1:1 `movementSpeed -> Speed` mapping, range ~0-373 confirmed
from local-player debug) should finally vary the proxy walk/run cadence.
### Build Status
✅ **SUCCESS** - `xmake build` clean (only pre-existing C4702/C4100 warnings).
### Next Step / To Verify In-Game
- Confirm slow vs normal vs run now produce visibly different proxy cadence.
- Confirm proxies still animate idle correctly when stopped (Speed -> idle).
- Watch for any side effects of skipping feedback Speed/Direction (e.g. strafing
direction, foot IK). Direction is no longer engine-driven for proxies; forward
locomotion assumed.
---
## 2026-06-04 - Proxy Cadence: Diagnostics Disprove 0x129 Hook; Move to Actor::Update Re-assert
### Decisive Diagnostic Run (fake-client Walk Circle, single instance)
Added per-update proxy diagnostics + hook call counters and drove a moving proxy
via the dev server's fake client (movementSpeed=180) so a single instance both
owns the log and shows a moving proxy. Result (`Proxy anim diag p2 ...`):
- `recvMoving=true, recvMovementSpeed=180, recvAnimGraphSpeed=180, computedGraphSpeed=180`
→ the receive + compute path is correct; the proxy gets a good speed value.
- `graphSpeedReadBack=true(0.0)` every frame → our written graph "Speed" is reset
to 0 before the next read. The write does NOT persist. This is the real reason
the cadence never varies: the walk plays from `isMoving`, but at Speed 0 it's
the slowest in-state blend, always.
- `feedbackHookTotalCalls=0` even with the proxy moving → the previously hooked
`UpdateFeedbackGraphSpeedAndDirection` (vtable 0x129) is NEVER dispatched through
the vtable. The engine calls it by direct address, so the vtable hook was inert.
- `speedSampledReadBack=false` → "speedSampled" is not a real FO4 graph variable
(only "Speed" exists / reads back). Correct FO4 name is "SpeedSampled".
Also from sender-side diag: local graph Speed scale is ~57 (slow walk) up to ~500
(sprint), confirming the ~1:1-with-clamp mapping is roughly right, and that the
correct path is the transmitted `animationGraphSpeed`, not the noisy
position-delta `movementSpeed` (which ranged 811-6650).
### Fix Pivot
- Re-targeted the hook from 0x129 to `Actor::Update(float)` (vtable 0xCF), which
IS dispatched virtually (the FO4 analog of TiltedEvolution's `Actor::Process`).
- Strategy changed from "skip" to "re-assert": the hook calls the original
`Update` (so the actor still renders/animates/positions; no freeze/crash risk),
then for proxy FormIDs immediately re-writes graph "Speed"/"SpeedSampled" to the
networked value via `SetProxyDesiredSpeed` published by the animation sync.
- Idle proxies publish speed = -1 (no override) so the engine's idle Speed (0)
stands.
### Open Risk
If FO4 ticks/generates the animation pose INSIDE `Actor::Update` (before our
re-assert), the post-Update write will be too late and cadence still won't change.
If pose generation runs in a later/separate animation pass, the re-assert lands in
time and cadence should track speed. Next in-game test (fake-client Walk Circle)
will show whether the proxy now jogs (Speed 180) vs. the old fixed slow walk, and
the diagnostics will confirm the Update hook fires (`feedbackHookTotalCalls` large,
`feedbackHookProxySkips` = re-assert count > 0).
### Build Status
✅ **SUCCESS** - `xmake build` clean.
---
## 2026-06-04 - Proxy Cadence: 0xCF Re-assert Holds Speed But Pose Is Generated Inside Update → Pivot to UpdateNoAI Puppet
### Diagnostic Run Confirms The Hook Works At The Data Level
With the `Actor::Update` (0xCF) re-assert in place, the fake-client Walk Circle run
shows the hook is now correctly wired:
- `feedbackHookTotalCalls` climbs (848 → 1271) → the 0xCF `Actor::Update` hook IS
dispatched virtually. Correct vtable slot (unlike the inert 0x129).
- `feedbackHookProxySkips` climbs (257 → 647) → we re-assert Speed on the proxy
every frame.
- **`graphSpeedReadBack=true(180.0)`** while moving (was `0.0` before) → our written
graph "Speed" now PERSISTS at the networked value. The per-frame clobber is
defeated. Idle correctly drops to `0.0`.
### But Cadence Is Still Visually Unchanged
User confirms: the proxy still plays the same slow "medium walk"; no visible change
in cadence. This proves the **Open Risk** from the prior entry: FO4 generates the
animation pose INSIDE `Actor::Update` — after the engine's AI step resets Speed to 0
(from the ~0 SetPosition controller velocity) but BEFORE our post-Update re-assert.
So Speed holds the right number when we read it back at task time, yet the pose for
the frame was already blended against Speed 0.
### Fix Pivot: UpdateNoAI Puppet
- For proxy FormIDs the hook no longer calls the full `Actor::Update`. Instead it:
1. Writes the networked Speed/SpeedSampled,
2. Calls `Actor::UpdateNoAI(float)` (vtable 0x0D0) — the engine's own
"update minus AI" path, so the AI-driven Speed reset never runs,
3. Re-writes Speed/SpeedSampled (belt-and-suspenders).
- Real NPCs and the player are untouched (full `Update`), so their engine-driven
locomotion is preserved.
### Open Risk
If `UpdateNoAI` omits work the proxy needs (e.g. 3D/position application, fade,
attach), the proxy may stutter, fail to move, or T-pose. If so, revert to full
`Update` and instead hook a finer point between the Speed reset and pose generation,
or drive locomotion via controller velocity. Next in-game test (fake-client Walk
Circle) decides: proxy should now jog at Speed 180 with a visibly faster cadence.
### Build Status
✅ **SUCCESS** - `xmake build` clean; DLL deployed to game F4SE/Plugins.
+2 -1
View File
@@ -24,5 +24,6 @@ namespace F4T::Networking
bool a_isSneaking = false, bool a_isSneaking = false,
bool a_isJumping = false, bool a_isJumping = false,
bool a_weaponDrawn = false, bool a_weaponDrawn = false,
float a_movementSpeed = 0.0F); float a_movementSpeed = 0.0F,
float a_animationGraphSpeed = -1.0F);
} }
+6
View File
@@ -22,9 +22,15 @@ namespace F4T::ProxyAnimationSync
{ {
inline constexpr const char* kSpeed = "Speed"; inline constexpr const char* kSpeed = "Speed";
inline constexpr const char* kSpeedLower = "speed"; inline constexpr const char* kSpeedLower = "speed";
inline constexpr const char* kSpeedSampled = "speedSampled";
inline constexpr const char* kSpeedDamped = "speedDamped";
inline constexpr const char* kIsSprinting = "IsSprinting"; inline constexpr const char* kIsSprinting = "IsSprinting";
} }
// Maps network movementSpeed (units/sec) to graph Speed (~0-105). Used when the sender
// does not provide animationGraphSpeed.
float MapMovementSpeedToGraphSpeed(float a_movementSpeed, bool a_isSprinting);
struct ProxyAppliedAnimationState struct ProxyAppliedAnimationState
{ {
bool hasAppliedGraphState{ false }; bool hasAppliedGraphState{ false };
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#include <cstdint>
#include <vector>
namespace F4T::ProxyPuppet
{
// Installs the Actor vtable hook that stops the engine from recomputing the
// locomotion graph Speed/Direction for networked proxy actors.
//
// Without this, Fallout 4 derives the graph "Speed" from the proxy's actual
// character-controller velocity every frame. Because proxies move via
// SetPosition (near-zero controller velocity), the engine overwrites whatever
// Speed we drive over the network, so every proxy plays a single fixed walk
// pace regardless of the remote player's real speed. This mirrors
// TiltedEvolution's puppet model (HookActorProcess), but is surgically scoped
// to just the Speed/Direction feedback write instead of the whole AI update.
//
// Safe to call repeatedly; the hook is installed at most once.
bool Install();
// Publishes the set of FormIDs that currently back active proxy actors. Called
// 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.
void SetProxyDesiredSpeed(std::uint32_t a_formID, float a_speed);
// 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);
// Diagnostics: total hook invocations (all actors) and proxy feedback skips
// since process start. Used to confirm the hook actually intercepts proxies.
void GetHookStats(std::uint64_t& a_outTotalCalls, std::uint64_t& a_outProxySkips);
}
+2
View File
@@ -40,6 +40,8 @@ namespace F4T::RemotePlayerState
bool isJumping{ false }; bool isJumping{ false };
bool weaponDrawn{ false }; bool weaponDrawn{ false };
float movementSpeed{ 0.0F }; float movementSpeed{ 0.0F };
// Authoritative Havok graph Speed from sender when available (>= 0). -1 means unset.
float animationGraphSpeed{ -1.0F };
std::chrono::steady_clock::time_point lastReceivedLocalTime{}; std::chrono::steady_clock::time_point lastReceivedLocalTime{};
// Phase 2: Actor state and action events (TiltedEvolution alignment) // Phase 2: Actor state and action events (TiltedEvolution alignment)
+23 -1
View File
@@ -361,6 +361,13 @@ namespace
movementSpeed = 0.0; 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) // Phase 2: Parse actor state flags (optional, for action replay + animation control)
auto actorStateFlags1 = 0U; auto actorStateFlags1 = 0U;
auto actorStateFlags2 = 0U; auto actorStateFlags2 = 0U;
@@ -388,6 +395,7 @@ namespace
ReadBool(a_packet, "isJumping").value_or(false), ReadBool(a_packet, "isJumping").value_or(false),
ReadBool(a_packet, "weaponDrawn").value_or(false), ReadBool(a_packet, "weaponDrawn").value_or(false),
static_cast<float>(movementSpeed), static_cast<float>(movementSpeed),
static_cast<float>(animationGraphSpeed),
std::chrono::steady_clock::now() std::chrono::steady_clock::now()
}; };
@@ -690,7 +698,8 @@ namespace F4T::Networking
bool a_isSneaking, bool a_isSneaking,
bool a_isJumping, bool a_isJumping,
bool a_weaponDrawn, bool a_weaponDrawn,
float a_movementSpeed) float a_movementSpeed,
float a_animationGraphSpeed)
{ {
if (!IsConnectedToServer() && !ConnectToLocalServer()) { if (!IsConnectedToServer() && !ConnectToLocalServer()) {
return false; return false;
@@ -718,6 +727,19 @@ namespace F4T::Networking
a_weaponDrawn ? "true" : "false", a_weaponDrawn ? "true" : "false",
a_movementSpeed); 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()) { if (packetSize <= 0 || static_cast<std::size_t>(packetSize) >= packet.size()) {
LogWarningWithLocalPlayerPrefix("Could not format Fallout 4 Together transform packet."); LogWarningWithLocalPlayerPrefix("Could not format Fallout 4 Together transform packet.");
return false; return false;
+66 -38
View File
@@ -4,6 +4,7 @@
#include "F4TNetworking.h" #include "F4TNetworking.h"
#include "F4TProxyAnimationSync.h" #include "F4TProxyAnimationSync.h"
#include "F4TProxyPuppet.h"
#include "F4TRemotePlayerState.h" #include "F4TRemotePlayerState.h"
#include "F4RemoteActionComponent.h" #include "F4RemoteActionComponent.h"
#include "F4TInterpolationSystem.h" #include "F4TInterpolationSystem.h"
@@ -499,31 +500,10 @@ namespace
return std::sqrt((deltaX * deltaX) + (deltaY * deltaY) + (deltaZ * deltaZ)); return std::sqrt((deltaX * deltaX) + (deltaY * deltaY) + (deltaZ * deltaZ));
} }
void InjectProxyMovementVelocity(RE::bhkCharacterController& a_charController, const RE::NiPoint3& a_deltaPos, float a_deltaSeconds) // NOTE: Character-controller velocity injection was removed. SetLinearVelocityImpl
{ // physically drives the Havok controller, which fights the SetPosition pin and the
if (a_deltaSeconds <= 0.0F) { // proxy's active AIProcess and causes forward/back glitching. Proxy locomotion
return; // animation is driven by graph variables (Speed/speedSampled) instead of real velocity.
}
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);
}
// Hybrid movement strategy for AI-driven animations // Hybrid movement strategy for AI-driven animations
// Phase 1: Let the proxy's natural AI/character controller drive movement // Phase 1: Let the proxy's natural AI/character controller drive movement
@@ -620,15 +600,10 @@ namespace
a_proxy.SetHeading(a_heading); a_proxy.SetHeading(a_heading);
a_proxy.SetPosition(positionToApply, true); a_proxy.SetPosition(positionToApply, true);
// Phase 6.1: Inject character controller velocity // Character controller velocity is injected once per frame in ApplySmoothFrameMovement
// The animation system requires velocity on the character controller to evaluate // (derived from the actual applied displacement). Injecting it here too, at packet rate,
// animation graphs and blend between locomotion states. Without velocity, the // produced a velocity that disagreed with the per-frame translation and caused the walk
// "Speed" graph variable alone doesn't trigger animations. // animation to glide. Velocity is intentionally NOT set on the packet path.
// 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);
}
if (a_optSlot) { if (a_optSlot) {
a_optSlot->lastPositionSyncTime = now; a_optSlot->lastPositionSyncTime = now;
@@ -5346,10 +5321,11 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
} }
// Kinematic smoothing toward the network target. // Kinematic smoothing toward the network target.
// We intentionally avoid proxy->Move() + velocity injection: physics-driven // We intentionally avoid proxy->Move() + character-controller velocity
// movement combined with the proxy's active AIProcess caused unstable // injection: SetLinearVelocityImpl physically drives the Havok controller,
// wandering/overshoot. SetPosition pins the actor deterministically while // which fights this SetPosition pin and the proxy's active AIProcess, causing
// the locomotion animation is driven by the "Speed" graph variable (Phase 1). // 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) {
@@ -5369,6 +5345,56 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
namespace F4T::ProxyActorController 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() void Update()
{ {
auto* player = RE::PlayerCharacter::GetSingleton(); auto* player = RE::PlayerCharacter::GetSingleton();
@@ -5485,6 +5511,8 @@ namespace F4T::ProxyActorController
} }
ApplySmoothFrameMovement(); ApplySmoothFrameMovement();
PublishActiveProxyFormIds();
} }
void UpdateSafetyTest() void UpdateSafetyTest()
+125 -46
View File
@@ -4,18 +4,23 @@
#include "F4TNetworking.h" #include "F4TNetworking.h"
#include "F4AnimationDescriptor.h" #include "F4AnimationDescriptor.h"
#include "F4TProxyPuppet.h"
#include "RE/B/BGSAnimationSystemUtils.h" #include "RE/B/BGSAnimationSystemUtils.h"
#include "RE/B/BSFixedString.h" #include "RE/B/BSFixedString.h"
#include "RE/I/IAnimationGraphManagerHolder.h" #include "RE/I/IAnimationGraphManagerHolder.h"
#include "RE/T/TESObjectREFR.h" #include "RE/T/TESObjectREFR.h"
#include <algorithm>
#include <array>
#include <chrono>
#include <cmath> #include <cmath>
#include <cstdint> #include <cstdint>
#include <format> #include <format>
#include <optional> #include <optional>
#include <string> #include <string>
#include <string_view> #include <string_view>
#include <unordered_map>
#include <unordered_set> #include <unordered_set>
namespace namespace
@@ -32,15 +37,19 @@ namespace
constexpr float kIdleSpeedThreshold = 1.0F; constexpr float kIdleSpeedThreshold = 1.0F;
constexpr float kSpeedEpsilon = 0.5F; 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 // Graph Speed range confirmed from local-player read-by-name debug in
// network movementSpeed uses different units (fake client walk = 180). // 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 kGraphSpeedIdle = 0.0F;
constexpr float kGraphSpeedSlow = 35.0F; // Used when a sprinting remote provides no usable speed sample.
constexpr float kGraphSpeedWalk = 55.0F; constexpr float kGraphSpeedSprintFloor = 350.0F;
constexpr float kGraphSpeedRun = 85.0F;
constexpr float kGraphSpeedSprint = 105.0F;
std::string GetLocalPlayerLogMessage(std::string_view a_message) std::string GetLocalPlayerLogMessage(std::string_view a_message)
{ {
@@ -141,28 +150,52 @@ namespace
return a_holder.SetGraphVariableFloat(variableName, a_value); 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) float ComputeGraphSpeed(const F4T::RemotePlayerState::RemotePlayerState& a_remotePlayer)
{ {
if (!a_remotePlayer.isMoving || a_remotePlayer.movementSpeed < kIdleSpeedThreshold) { if (!a_remotePlayer.isMoving || a_remotePlayer.movementSpeed < kIdleSpeedThreshold) {
return kGraphSpeedIdle; return kGraphSpeedIdle;
} }
if (a_remotePlayer.isSprinting) { // Prefer the sender's live graph Speed (TiltedEvolution sends full variable snapshots).
return kGraphSpeedSprint; if (a_remotePlayer.animationGraphSpeed >= 0.0F && std::isfinite(a_remotePlayer.animationGraphSpeed)) {
return ClampGraphSpeed(a_remotePlayer.animationGraphSpeed);
} }
// Same buckets as GetProxyAnimationSpeedBucket in F4TProxyActorController.cpp. return F4T::ProxyAnimationSync::MapMovementSpeedToGraphSpeed(
if (a_remotePlayer.movementSpeed < 75.0F) { a_remotePlayer.movementSpeed,
return kGraphSpeedSlow; a_remotePlayer.isSprinting);
}
if (a_remotePlayer.movementSpeed < 150.0F) {
return kGraphSpeedWalk;
}
if (a_remotePlayer.movementSpeed < 300.0F) {
return kGraphSpeedRun;
}
return kGraphSpeedSprint;
} }
struct DesiredProxyAnimationState struct DesiredProxyAnimationState
@@ -208,18 +241,13 @@ namespace
{ {
const auto rawDesired = BuildDesiredState(a_remotePlayer); const auto rawDesired = BuildDesiredState(a_remotePlayer);
auto desired = rawDesired; auto desired = rawDesired;
// Apply minimal hold (50ms) for debounce, but don't coast; snap to idle immediately.
desired.isMoving = ApplyMovingHoldState(a_state, rawDesired.isMoving); desired.isMoving = ApplyMovingHoldState(a_state, rawDesired.isMoving);
if (!desired.isMoving) { if (!desired.isMoving) {
desired.isSprinting = false; desired.isSprinting = false;
desired.graphSpeed = kGraphSpeedIdle; desired.graphSpeed = kGraphSpeedIdle;
} else { } else {
if (!rawDesired.isMoving &&
a_state.hasAppliedGraphState &&
a_state.lastGraphSpeed > kGraphSpeedIdle) {
desired.graphSpeed = a_state.lastGraphSpeed;
}
if (!a_remotePlayer.isSprinting) { if (!a_remotePlayer.isSprinting) {
desired.isSprinting = false; desired.isSprinting = false;
} }
@@ -255,15 +283,20 @@ namespace
auto success = true; auto success = true;
if constexpr (kEnableProxyLocomotionSync) { if constexpr (kEnableProxyLocomotionSync) {
const auto speedWritten = TrySetGraphFloat(*graphHolder, F4T::ProxyAnimationSync::GraphVar::kSpeed, a_desired.graphSpeed); const auto graphSpeed = ClampGraphSpeed(a_desired.graphSpeed);
const auto speedLowerWritten = TrySetGraphFloat(*graphHolder, F4T::ProxyAnimationSync::GraphVar::kSpeedLower, 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; bool sprintWritten = false;
if (a_desired.isSprinting) { if (a_desired.isSprinting) {
sprintWritten = TrySetGraphBool(*graphHolder, F4T::ProxyAnimationSync::GraphVar::kIsSprinting, true); sprintWritten = TrySetGraphBool(*graphHolder, F4T::ProxyAnimationSync::GraphVar::kIsSprinting, true);
} else { } else {
sprintWritten = TrySetGraphBool(*graphHolder, F4T::ProxyAnimationSync::GraphVar::kIsSprinting, false); sprintWritten = TrySetGraphBool(*graphHolder, F4T::ProxyAnimationSync::GraphVar::kIsSprinting, false);
} }
success &= speedWritten && speedLowerWritten && sprintWritten; success &= speedWritten && speedLowerWritten && speedSampledWritten && speedDampedWritten && sprintWritten;
if (a_desired.isMoving) { if (a_desired.isMoving) {
float readBackSpeed = 0.0F; float readBackSpeed = 0.0F;
@@ -318,11 +351,7 @@ namespace
snapshot.bools.resize(descriptor.GetBoolVariableCount(), false); snapshot.bools.resize(descriptor.GetBoolVariableCount(), false);
snapshot.ints.resize(descriptor.GetIntVariableCount(), 0); snapshot.ints.resize(descriptor.GetIntVariableCount(), 0);
// Populate float variables PopulateLocomotionSpeedSnapshot(snapshot, descriptor, a_desired.graphSpeed);
const auto speedIdx = descriptor.GetFloatVariableIndex("Speed");
if (speedIdx != static_cast<std::size_t>(-1)) {
snapshot.floats[speedIdx] = a_desired.graphSpeed;
}
const auto directionIdx = descriptor.GetFloatVariableIndex("direction"); const auto directionIdx = descriptor.GetFloatVariableIndex("direction");
if (directionIdx != static_cast<std::size_t>(-1)) { if (directionIdx != static_cast<std::size_t>(-1)) {
@@ -427,6 +456,24 @@ namespace
namespace F4T::ProxyAnimationSync 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) void ResetAppliedAnimationState(ProxyAppliedAnimationState& a_state)
{ {
a_state = {}; a_state = {};
@@ -447,10 +494,49 @@ namespace F4T::ProxyAnimationSync
} }
const auto desired = BuildDebouncedDesiredState(a_state, a_remotePlayer); 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 = const auto shouldApply =
!a_state.hasAppliedGraphState || !a_state.hasAppliedGraphState ||
HasDesiredStateChanged(a_state, desired) || HasDesiredStateChanged(a_state, desired);
desired.isMoving;
if (!shouldApply) { if (!shouldApply) {
return; return;
@@ -532,15 +618,11 @@ namespace F4T::ProxyAnimationSync
// Prepare float variables (Speed, direction, etc.) // Prepare float variables (Speed, direction, etc.)
snapshot.floats.resize(descriptor.GetFloatVariableCount(), 0.0F); snapshot.floats.resize(descriptor.GetFloatVariableCount(), 0.0F);
// Speed: calculated from movement speed const auto desired = BuildDesiredState(a_remotePlayer);
if (descriptor.GetFloatVariableIndex("Speed") != static_cast<std::size_t>(-1)) { PopulateLocomotionSpeedSnapshot(snapshot, descriptor, desired.graphSpeed);
const auto desired = BuildDesiredState(a_remotePlayer);
snapshot.floats[descriptor.GetFloatVariableIndex("Speed")] = desired.graphSpeed;
}
// Direction: from remote player's heading // Direction: from remote player's heading
if (descriptor.GetFloatVariableIndex("direction") != static_cast<std::size_t>(-1)) { if (descriptor.GetFloatVariableIndex("direction") != static_cast<std::size_t>(-1)) {
const auto desired = BuildDesiredState(a_remotePlayer);
snapshot.floats[descriptor.GetFloatVariableIndex("direction")] = desired.direction; snapshot.floats[descriptor.GetFloatVariableIndex("direction")] = desired.direction;
} }
@@ -548,17 +630,14 @@ namespace F4T::ProxyAnimationSync
snapshot.bools.resize(descriptor.GetBoolVariableCount(), false); snapshot.bools.resize(descriptor.GetBoolVariableCount(), false);
if (descriptor.GetBoolVariableIndex("isSprinting") != static_cast<std::size_t>(-1)) { if (descriptor.GetBoolVariableIndex("isSprinting") != static_cast<std::size_t>(-1)) {
const auto desired = BuildDesiredState(a_remotePlayer);
snapshot.bools[descriptor.GetBoolVariableIndex("isSprinting")] = desired.isSprinting; snapshot.bools[descriptor.GetBoolVariableIndex("isSprinting")] = desired.isSprinting;
} }
if (descriptor.GetBoolVariableIndex("isSneaking") != static_cast<std::size_t>(-1)) { if (descriptor.GetBoolVariableIndex("isSneaking") != static_cast<std::size_t>(-1)) {
const auto desired = BuildDesiredState(a_remotePlayer);
snapshot.bools[descriptor.GetBoolVariableIndex("isSneaking")] = desired.isSneaking; snapshot.bools[descriptor.GetBoolVariableIndex("isSneaking")] = desired.isSneaking;
} }
if (descriptor.GetBoolVariableIndex("isMoving") != static_cast<std::size_t>(-1)) { if (descriptor.GetBoolVariableIndex("isMoving") != static_cast<std::size_t>(-1)) {
const auto desired = BuildDesiredState(a_remotePlayer);
snapshot.bools[descriptor.GetBoolVariableIndex("isMoving")] = desired.isMoving; 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 "F4TNetworking.h"
#include "F4TLocalAnimationGraphDebug.h" #include "F4TLocalAnimationGraphDebug.h"
#include "F4TProxyActorController.h" #include "F4TProxyActorController.h"
#include "F4TProxyAnimationSync.h"
#include "F4TProxyPuppet.h"
#include "RE/A/ACTOR_STANCE.h" #include "RE/A/ACTOR_STANCE.h"
#include "RE/B/BSFixedString.h"
#include "RE/I/IAnimationGraphManagerHolder.h"
#include <cmath> #include <cmath>
#include <cstdlib> #include <cstdlib>
@@ -81,6 +85,21 @@ namespace
return RE::PlayerCharacter::GetSingleton(); 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) PlayerTransform GetPlayerTransform(const RE::PlayerCharacter& a_player)
{ {
const auto position = a_player.GetPosition(); const auto position = a_player.GetPosition();
@@ -491,6 +510,26 @@ namespace
return; 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. // Normal movement sends more often than logs, but still avoids packet-per-frame noise.
const auto sentTransform = F4T::Networking::SendTransformPacket( const auto sentTransform = F4T::Networking::SendTransformPacket(
currentTransform.x, currentTransform.x,
@@ -507,7 +546,8 @@ namespace
networkMovementState.isSneaking, networkMovementState.isSneaking,
networkMovementState.isJumping, networkMovementState.isJumping,
networkMovementState.weaponDrawn, networkMovementState.weaponDrawn,
networkMovementState.movementSpeed); networkMovementState.movementSpeed,
animationGraphSpeed);
if (sentTransform) { if (sentTransform) {
lastSentTransform = currentTransform; lastSentTransform = currentTransform;
lastKnownLocation = currentLocation; lastKnownLocation = currentLocation;
@@ -540,6 +580,11 @@ namespace
return; 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 // 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. // check on the game thread after load, while separate send and log gates keep updates useful.
taskInterface->AddTaskPermanent([]() { taskInterface->AddTaskPermanent([]() {
+21
View File
@@ -48,6 +48,22 @@ ClientSnapshotProvider = Callable[[], list[dict[str, Any]]]
WalkTargetProvider = Callable[[int], dict[str, Any] | None] WalkTargetProvider = Callable[[int], dict[str, Any] | None]
def map_movement_speed_to_graph_speed(movement_speed: float, *, is_sprinting: bool = False) -> float:
"""Match plugin MapMovementSpeedToGraphSpeed.
FO4 graph "Speed" shares the same world-units/sec scale as movementSpeed, so the
mapping is ~1:1 (clamped). Confirmed from local-player graph debug: run movementSpeed
~306 lines up with graph Speed ~296-373.
"""
if movement_speed < 1.0:
return 0.0
graph_speed = movement_speed
if is_sprinting:
graph_speed = max(graph_speed, 350.0)
return min(graph_speed, 500.0)
class FakePlayerClient: class FakePlayerClient:
def __init__( def __init__(
self, self,
@@ -537,6 +553,11 @@ class FakePlayerClient:
"weaponDrawn": False, "weaponDrawn": False,
"movementSpeed": movement_speed, "movementSpeed": movement_speed,
} }
if is_moving and movement_speed >= 1.0:
packet["animationGraphSpeed"] = round(
map_movement_speed_to_graph_speed(movement_speed),
1,
)
encoded = json.dumps(packet, separators=(",", ":")).encode("utf-8") + b"\n" encoded = json.dumps(packet, separators=(",", ":")).encode("utf-8") + b"\n"
with self._send_lock: with self._send_lock:
connection.sendall(encoded) connection.sendall(encoded)