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:
+170
-1
@@ -4398,4 +4398,173 @@ The crash correlated with disabling `g_preserveProxyAIForAnimationTesting` and r
|
||||
|
||||
### 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.
|
||||
@@ -24,5 +24,6 @@ namespace F4T::Networking
|
||||
bool a_isSneaking = false,
|
||||
bool a_isJumping = false,
|
||||
bool a_weaponDrawn = false,
|
||||
float a_movementSpeed = 0.0F);
|
||||
float a_movementSpeed = 0.0F,
|
||||
float a_animationGraphSpeed = -1.0F);
|
||||
}
|
||||
|
||||
@@ -22,9 +22,15 @@ namespace F4T::ProxyAnimationSync
|
||||
{
|
||||
inline constexpr const char* kSpeed = "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";
|
||||
}
|
||||
|
||||
// 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
|
||||
{
|
||||
bool hasAppliedGraphState{ false };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -40,6 +40,8 @@ namespace F4T::RemotePlayerState
|
||||
bool isJumping{ false };
|
||||
bool weaponDrawn{ false };
|
||||
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{};
|
||||
|
||||
// Phase 2: Actor state and action events (TiltedEvolution alignment)
|
||||
|
||||
@@ -361,6 +361,13 @@ namespace
|
||||
movementSpeed = 0.0;
|
||||
}
|
||||
|
||||
auto animationGraphSpeed = -1.0;
|
||||
if (const auto graphSpeed = ReadDouble(a_packet, "animationGraphSpeed")) {
|
||||
if (std::isfinite(*graphSpeed) && *graphSpeed >= 0.0) {
|
||||
animationGraphSpeed = *graphSpeed;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Parse actor state flags (optional, for action replay + animation control)
|
||||
auto actorStateFlags1 = 0U;
|
||||
auto actorStateFlags2 = 0U;
|
||||
@@ -388,6 +395,7 @@ namespace
|
||||
ReadBool(a_packet, "isJumping").value_or(false),
|
||||
ReadBool(a_packet, "weaponDrawn").value_or(false),
|
||||
static_cast<float>(movementSpeed),
|
||||
static_cast<float>(animationGraphSpeed),
|
||||
std::chrono::steady_clock::now()
|
||||
};
|
||||
|
||||
@@ -690,7 +698,8 @@ namespace F4T::Networking
|
||||
bool a_isSneaking,
|
||||
bool a_isJumping,
|
||||
bool a_weaponDrawn,
|
||||
float a_movementSpeed)
|
||||
float a_movementSpeed,
|
||||
float a_animationGraphSpeed)
|
||||
{
|
||||
if (!IsConnectedToServer() && !ConnectToLocalServer()) {
|
||||
return false;
|
||||
@@ -718,6 +727,19 @@ namespace F4T::Networking
|
||||
a_weaponDrawn ? "true" : "false",
|
||||
a_movementSpeed);
|
||||
|
||||
if (packetSize > 0 && a_animationGraphSpeed >= 0.0F && std::isfinite(a_animationGraphSpeed)) {
|
||||
const auto remaining = packet.size() - static_cast<std::size_t>(packetSize);
|
||||
const auto appended = std::snprintf(
|
||||
packet.data() + packetSize,
|
||||
remaining,
|
||||
",\"animationGraphSpeed\":%.1f",
|
||||
a_animationGraphSpeed);
|
||||
|
||||
if (appended > 0 && static_cast<std::size_t>(appended) < remaining) {
|
||||
packetSize += appended;
|
||||
}
|
||||
}
|
||||
|
||||
if (packetSize <= 0 || static_cast<std::size_t>(packetSize) >= packet.size()) {
|
||||
LogWarningWithLocalPlayerPrefix("Could not format Fallout 4 Together transform packet.");
|
||||
return false;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include "F4TNetworking.h"
|
||||
#include "F4TProxyAnimationSync.h"
|
||||
#include "F4TProxyPuppet.h"
|
||||
#include "F4TRemotePlayerState.h"
|
||||
#include "F4RemoteActionComponent.h"
|
||||
#include "F4TInterpolationSystem.h"
|
||||
@@ -499,31 +500,10 @@ namespace
|
||||
return std::sqrt((deltaX * deltaX) + (deltaY * deltaY) + (deltaZ * deltaZ));
|
||||
}
|
||||
|
||||
void InjectProxyMovementVelocity(RE::bhkCharacterController& a_charController, const RE::NiPoint3& a_deltaPos, float a_deltaSeconds)
|
||||
{
|
||||
if (a_deltaSeconds <= 0.0F) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto distance = a_deltaPos.Length();
|
||||
if (distance <= 0.001F) {
|
||||
const RE::hkVector4f stoppedVelocity{ 0.0F, 0.0F, 0.0F, 0.0F };
|
||||
a_charController.SetLinearVelocityImpl(stoppedVelocity);
|
||||
return;
|
||||
}
|
||||
|
||||
constexpr float kMaxInjectedSpeed = 400.0F;
|
||||
const auto unclampedSpeed = distance / a_deltaSeconds;
|
||||
const auto clampedSpeed = unclampedSpeed > kMaxInjectedSpeed ? kMaxInjectedSpeed : unclampedSpeed;
|
||||
const auto direction = a_deltaPos / distance;
|
||||
const RE::hkVector4f velocity{
|
||||
direction.x * clampedSpeed,
|
||||
direction.y * clampedSpeed,
|
||||
direction.z * clampedSpeed,
|
||||
0.0F
|
||||
};
|
||||
a_charController.SetLinearVelocityImpl(velocity);
|
||||
}
|
||||
// NOTE: Character-controller velocity injection was removed. SetLinearVelocityImpl
|
||||
// physically drives the Havok controller, which fights the SetPosition pin and the
|
||||
// proxy's active AIProcess and causes forward/back glitching. Proxy locomotion
|
||||
// animation is driven by graph variables (Speed/speedSampled) instead of real velocity.
|
||||
|
||||
// Hybrid movement strategy for AI-driven animations
|
||||
// Phase 1: Let the proxy's natural AI/character controller drive movement
|
||||
@@ -575,7 +555,7 @@ namespace
|
||||
ProxyActorSlot* a_optSlot = nullptr)
|
||||
{
|
||||
(void)a_remotePlayer;
|
||||
|
||||
|
||||
// DEBUG: Log AI state once per proxy
|
||||
if (g_loggedProxies.find(&a_proxy) == g_loggedProxies.end()) {
|
||||
g_loggedProxies.insert(&a_proxy);
|
||||
@@ -620,15 +600,10 @@ namespace
|
||||
a_proxy.SetHeading(a_heading);
|
||||
a_proxy.SetPosition(positionToApply, true);
|
||||
|
||||
// Phase 6.1: Inject character controller velocity
|
||||
// The animation system requires velocity on the character controller to evaluate
|
||||
// animation graphs and blend between locomotion states. Without velocity, the
|
||||
// "Speed" graph variable alone doesn't trigger animations.
|
||||
// Access controller via: Actor::currentProcess -> AIProcess::middleHigh -> MiddleHighProcessData::charController
|
||||
if (a_proxy.currentProcess && a_proxy.currentProcess->middleHigh && a_proxy.currentProcess->middleHigh->charController) {
|
||||
const RE::NiPoint3 latestDeltaPos = a_nextPosition - currentPosition;
|
||||
InjectProxyMovementVelocity(*a_proxy.currentProcess->middleHigh->charController, latestDeltaPos, deltaSeconds);
|
||||
}
|
||||
// Character controller velocity is injected once per frame in ApplySmoothFrameMovement
|
||||
// (derived from the actual applied displacement). Injecting it here too, at packet rate,
|
||||
// produced a velocity that disagreed with the per-frame translation and caused the walk
|
||||
// animation to glide. Velocity is intentionally NOT set on the packet path.
|
||||
|
||||
if (a_optSlot) {
|
||||
a_optSlot->lastPositionSyncTime = now;
|
||||
@@ -5346,10 +5321,11 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
|
||||
}
|
||||
|
||||
// Kinematic smoothing toward the network target.
|
||||
// We intentionally avoid proxy->Move() + velocity injection: physics-driven
|
||||
// movement combined with the proxy's active AIProcess caused unstable
|
||||
// wandering/overshoot. SetPosition pins the actor deterministically while
|
||||
// the locomotion animation is driven by the "Speed" graph variable (Phase 1).
|
||||
// We intentionally avoid proxy->Move() + character-controller velocity
|
||||
// injection: SetLinearVelocityImpl physically drives the Havok controller,
|
||||
// which fights this SetPosition pin and the proxy's active AIProcess, causing
|
||||
// forward/back oscillation. SetPosition pins the actor deterministically; the
|
||||
// locomotion animation is driven by the graph "Speed" variable instead.
|
||||
constexpr float kLargeDriftSnapDistance = 1500.0F;
|
||||
RE::NiPoint3 nextPosition;
|
||||
if (distanceToTarget > kLargeDriftSnapDistance) {
|
||||
@@ -5369,6 +5345,56 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
|
||||
|
||||
namespace F4T::ProxyActorController
|
||||
{
|
||||
// Publishes the FormIDs of all actors currently backing proxy slots so the
|
||||
// puppet hook (F4TProxyPuppet) can suppress engine locomotion Speed/Direction
|
||||
// recompute on exactly those actors and nothing else.
|
||||
void PublishActiveProxyFormIds()
|
||||
{
|
||||
std::vector<std::uint32_t> proxyFormIds;
|
||||
proxyFormIds.reserve(g_proxySlots.size());
|
||||
|
||||
const auto appendHandleFormId = [&proxyFormIds](const RE::ObjectRefHandle& a_handle) {
|
||||
if (!a_handle) {
|
||||
return;
|
||||
}
|
||||
if (const auto ref = a_handle.get()) {
|
||||
proxyFormIds.push_back(ref->GetFormID());
|
||||
}
|
||||
};
|
||||
|
||||
for (const auto& [_, slot] : g_proxySlots) {
|
||||
appendHandleFormId(slot.proxyHandle);
|
||||
}
|
||||
|
||||
// Placed fallback proxies are tracked outside the slot map.
|
||||
appendHandleFormId(g_runtimeProxyHandle);
|
||||
appendHandleFormId(g_proxyHandle);
|
||||
|
||||
std::string formIdList{ "[" };
|
||||
for (std::size_t i = 0; i < proxyFormIds.size(); ++i) {
|
||||
if (i != 0) {
|
||||
formIdList += ", ";
|
||||
}
|
||||
formIdList += std::format("{:08X}", proxyFormIds[i]);
|
||||
}
|
||||
formIdList += "]";
|
||||
|
||||
std::uint64_t hookCalls = 0;
|
||||
std::uint64_t hookSkips = 0;
|
||||
F4T::ProxyPuppet::GetHookStats(hookCalls, hookSkips);
|
||||
|
||||
LogThrottledInfo(
|
||||
"proxy_puppet_stats",
|
||||
std::format(
|
||||
"Proxy puppet diagnostics: publishedProxyFormIds={}, feedbackHookTotalCalls={}, feedbackHookProxySkips={}.",
|
||||
formIdList,
|
||||
hookCalls,
|
||||
hookSkips),
|
||||
1s);
|
||||
|
||||
F4T::ProxyPuppet::SetActiveProxyFormIDs(std::move(proxyFormIds));
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
auto* player = RE::PlayerCharacter::GetSingleton();
|
||||
@@ -5485,6 +5511,8 @@ namespace F4T::ProxyActorController
|
||||
}
|
||||
|
||||
ApplySmoothFrameMovement();
|
||||
|
||||
PublishActiveProxyFormIds();
|
||||
}
|
||||
|
||||
void UpdateSafetyTest()
|
||||
|
||||
@@ -4,18 +4,23 @@
|
||||
|
||||
#include "F4TNetworking.h"
|
||||
#include "F4AnimationDescriptor.h"
|
||||
#include "F4TProxyPuppet.h"
|
||||
|
||||
#include "RE/B/BGSAnimationSystemUtils.h"
|
||||
#include "RE/B/BSFixedString.h"
|
||||
#include "RE/I/IAnimationGraphManagerHolder.h"
|
||||
#include "RE/T/TESObjectREFR.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <format>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace
|
||||
@@ -32,15 +37,19 @@ namespace
|
||||
|
||||
constexpr float kIdleSpeedThreshold = 1.0F;
|
||||
constexpr float kSpeedEpsilon = 0.5F;
|
||||
constexpr auto kProxyMovingStopHoldDuration = std::chrono::milliseconds{ 300 };
|
||||
// Hold duration for movement stop debounce. Must be short so animation stop events
|
||||
// fire promptly; long holds cause characters to glide while playing stop animations.
|
||||
constexpr auto kProxyMovingStopHoldDuration = std::chrono::milliseconds{ 50 };
|
||||
|
||||
// Calibrated from local-player graph debug: walk/run graph Speed stays ~35-105 while
|
||||
// network movementSpeed uses different units (fake client walk = 180).
|
||||
// Graph Speed range confirmed from local-player read-by-name debug in
|
||||
// Fallout4Together.log: walk starts ~47, full run ~373. FO4's graph "Speed" is in the
|
||||
// same world-units/sec scale as our network movementSpeed, so the mapping is ~1:1.
|
||||
// (The earlier 0-105 calibration was wrong and compressed run speed, causing foot slide.)
|
||||
constexpr float kGraphSpeedMin = 0.0F;
|
||||
constexpr float kGraphSpeedMax = 500.0F;
|
||||
constexpr float kGraphSpeedIdle = 0.0F;
|
||||
constexpr float kGraphSpeedSlow = 35.0F;
|
||||
constexpr float kGraphSpeedWalk = 55.0F;
|
||||
constexpr float kGraphSpeedRun = 85.0F;
|
||||
constexpr float kGraphSpeedSprint = 105.0F;
|
||||
// Used when a sprinting remote provides no usable speed sample.
|
||||
constexpr float kGraphSpeedSprintFloor = 350.0F;
|
||||
|
||||
std::string GetLocalPlayerLogMessage(std::string_view a_message)
|
||||
{
|
||||
@@ -141,28 +150,52 @@ namespace
|
||||
return a_holder.SetGraphVariableFloat(variableName, a_value);
|
||||
}
|
||||
|
||||
float ClampGraphSpeed(float a_speed)
|
||||
{
|
||||
if (!std::isfinite(a_speed)) {
|
||||
return kGraphSpeedIdle;
|
||||
}
|
||||
|
||||
return std::clamp(a_speed, kGraphSpeedMin, kGraphSpeedMax);
|
||||
}
|
||||
|
||||
void PopulateLocomotionSpeedSnapshot(
|
||||
F4T::AnimationDescriptor::AnimationVariableSnapshot& a_snapshot,
|
||||
const F4T::AnimationDescriptor::F4AnimationDescriptor& a_descriptor,
|
||||
float a_graphSpeed)
|
||||
{
|
||||
const auto graphSpeed = ClampGraphSpeed(a_graphSpeed);
|
||||
|
||||
const auto speedIdx = a_descriptor.GetFloatVariableIndex(F4T::ProxyAnimationSync::GraphVar::kSpeed);
|
||||
if (speedIdx != static_cast<std::size_t>(-1) && speedIdx < a_snapshot.floats.size()) {
|
||||
a_snapshot.floats[speedIdx] = graphSpeed;
|
||||
}
|
||||
|
||||
const auto speedSampledIdx = a_descriptor.GetFloatVariableIndex(F4T::ProxyAnimationSync::GraphVar::kSpeedSampled);
|
||||
if (speedSampledIdx != static_cast<std::size_t>(-1) && speedSampledIdx < a_snapshot.floats.size()) {
|
||||
a_snapshot.floats[speedSampledIdx] = graphSpeed;
|
||||
}
|
||||
|
||||
const auto speedDampedIdx = a_descriptor.GetFloatVariableIndex(F4T::ProxyAnimationSync::GraphVar::kSpeedDamped);
|
||||
if (speedDampedIdx != static_cast<std::size_t>(-1) && speedDampedIdx < a_snapshot.floats.size()) {
|
||||
a_snapshot.floats[speedDampedIdx] = graphSpeed;
|
||||
}
|
||||
}
|
||||
|
||||
float ComputeGraphSpeed(const F4T::RemotePlayerState::RemotePlayerState& a_remotePlayer)
|
||||
{
|
||||
if (!a_remotePlayer.isMoving || a_remotePlayer.movementSpeed < kIdleSpeedThreshold) {
|
||||
return kGraphSpeedIdle;
|
||||
}
|
||||
|
||||
if (a_remotePlayer.isSprinting) {
|
||||
return kGraphSpeedSprint;
|
||||
// Prefer the sender's live graph Speed (TiltedEvolution sends full variable snapshots).
|
||||
if (a_remotePlayer.animationGraphSpeed >= 0.0F && std::isfinite(a_remotePlayer.animationGraphSpeed)) {
|
||||
return ClampGraphSpeed(a_remotePlayer.animationGraphSpeed);
|
||||
}
|
||||
|
||||
// Same buckets as GetProxyAnimationSpeedBucket in F4TProxyActorController.cpp.
|
||||
if (a_remotePlayer.movementSpeed < 75.0F) {
|
||||
return kGraphSpeedSlow;
|
||||
}
|
||||
if (a_remotePlayer.movementSpeed < 150.0F) {
|
||||
return kGraphSpeedWalk;
|
||||
}
|
||||
if (a_remotePlayer.movementSpeed < 300.0F) {
|
||||
return kGraphSpeedRun;
|
||||
}
|
||||
|
||||
return kGraphSpeedSprint;
|
||||
return F4T::ProxyAnimationSync::MapMovementSpeedToGraphSpeed(
|
||||
a_remotePlayer.movementSpeed,
|
||||
a_remotePlayer.isSprinting);
|
||||
}
|
||||
|
||||
struct DesiredProxyAnimationState
|
||||
@@ -208,18 +241,13 @@ namespace
|
||||
{
|
||||
const auto rawDesired = BuildDesiredState(a_remotePlayer);
|
||||
auto desired = rawDesired;
|
||||
// Apply minimal hold (50ms) for debounce, but don't coast; snap to idle immediately.
|
||||
desired.isMoving = ApplyMovingHoldState(a_state, rawDesired.isMoving);
|
||||
|
||||
if (!desired.isMoving) {
|
||||
desired.isSprinting = false;
|
||||
desired.graphSpeed = kGraphSpeedIdle;
|
||||
} else {
|
||||
if (!rawDesired.isMoving &&
|
||||
a_state.hasAppliedGraphState &&
|
||||
a_state.lastGraphSpeed > kGraphSpeedIdle) {
|
||||
desired.graphSpeed = a_state.lastGraphSpeed;
|
||||
}
|
||||
|
||||
if (!a_remotePlayer.isSprinting) {
|
||||
desired.isSprinting = false;
|
||||
}
|
||||
@@ -255,15 +283,20 @@ namespace
|
||||
auto success = true;
|
||||
|
||||
if constexpr (kEnableProxyLocomotionSync) {
|
||||
const auto speedWritten = TrySetGraphFloat(*graphHolder, F4T::ProxyAnimationSync::GraphVar::kSpeed, a_desired.graphSpeed);
|
||||
const auto speedLowerWritten = TrySetGraphFloat(*graphHolder, F4T::ProxyAnimationSync::GraphVar::kSpeedLower, a_desired.graphSpeed);
|
||||
const auto graphSpeed = ClampGraphSpeed(a_desired.graphSpeed);
|
||||
const auto speedWritten = TrySetGraphFloat(*graphHolder, F4T::ProxyAnimationSync::GraphVar::kSpeed, graphSpeed);
|
||||
const auto speedLowerWritten = TrySetGraphFloat(*graphHolder, F4T::ProxyAnimationSync::GraphVar::kSpeedLower, graphSpeed);
|
||||
const auto speedSampledWritten =
|
||||
TrySetGraphFloat(*graphHolder, F4T::ProxyAnimationSync::GraphVar::kSpeedSampled, graphSpeed);
|
||||
const auto speedDampedWritten =
|
||||
TrySetGraphFloat(*graphHolder, F4T::ProxyAnimationSync::GraphVar::kSpeedDamped, graphSpeed);
|
||||
bool sprintWritten = false;
|
||||
if (a_desired.isSprinting) {
|
||||
sprintWritten = TrySetGraphBool(*graphHolder, F4T::ProxyAnimationSync::GraphVar::kIsSprinting, true);
|
||||
} else {
|
||||
sprintWritten = TrySetGraphBool(*graphHolder, F4T::ProxyAnimationSync::GraphVar::kIsSprinting, false);
|
||||
}
|
||||
success &= speedWritten && speedLowerWritten && sprintWritten;
|
||||
success &= speedWritten && speedLowerWritten && speedSampledWritten && speedDampedWritten && sprintWritten;
|
||||
|
||||
if (a_desired.isMoving) {
|
||||
float readBackSpeed = 0.0F;
|
||||
@@ -318,11 +351,7 @@ namespace
|
||||
snapshot.bools.resize(descriptor.GetBoolVariableCount(), false);
|
||||
snapshot.ints.resize(descriptor.GetIntVariableCount(), 0);
|
||||
|
||||
// Populate float variables
|
||||
const auto speedIdx = descriptor.GetFloatVariableIndex("Speed");
|
||||
if (speedIdx != static_cast<std::size_t>(-1)) {
|
||||
snapshot.floats[speedIdx] = a_desired.graphSpeed;
|
||||
}
|
||||
PopulateLocomotionSpeedSnapshot(snapshot, descriptor, a_desired.graphSpeed);
|
||||
|
||||
const auto directionIdx = descriptor.GetFloatVariableIndex("direction");
|
||||
if (directionIdx != static_cast<std::size_t>(-1)) {
|
||||
@@ -427,6 +456,24 @@ namespace
|
||||
|
||||
namespace F4T::ProxyAnimationSync
|
||||
{
|
||||
float MapMovementSpeedToGraphSpeed(float a_movementSpeed, bool a_isSprinting)
|
||||
{
|
||||
if (a_movementSpeed < kIdleSpeedThreshold) {
|
||||
return kGraphSpeedIdle;
|
||||
}
|
||||
|
||||
// FO4 graph "Speed" is in the same world-units/sec scale as our derived
|
||||
// movementSpeed (confirmed in Fallout4Together.log: run movementSpeed ~306 lines up
|
||||
// with graph Speed ~296-373). Map ~1:1 so the walk/run foot cadence matches the
|
||||
// proxy's actual ground translation and does not slide.
|
||||
float graphSpeed = a_movementSpeed;
|
||||
if (a_isSprinting) {
|
||||
graphSpeed = std::max(graphSpeed, kGraphSpeedSprintFloor);
|
||||
}
|
||||
|
||||
return ClampGraphSpeed(graphSpeed);
|
||||
}
|
||||
|
||||
void ResetAppliedAnimationState(ProxyAppliedAnimationState& a_state)
|
||||
{
|
||||
a_state = {};
|
||||
@@ -447,10 +494,49 @@ namespace F4T::ProxyAnimationSync
|
||||
}
|
||||
|
||||
const auto desired = BuildDebouncedDesiredState(a_state, a_remotePlayer);
|
||||
|
||||
// Hand the puppet Update hook the speed to re-assert after the engine resets
|
||||
// it each frame. Negative when idle so the engine's idle Speed (0) stands.
|
||||
F4T::ProxyPuppet::SetProxyDesiredSpeed(
|
||||
actorId,
|
||||
desired.isMoving ? desired.graphSpeed : -1.0F);
|
||||
|
||||
// Throttled per-update diagnostic: shows what speed the proxy actually receives
|
||||
// vs. computes, and reads back the live graph variables to detect engine clobber.
|
||||
{
|
||||
static std::unordered_map<std::uint32_t, std::chrono::steady_clock::time_point> s_lastDiag;
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
auto& last = s_lastDiag[a_remotePlayer.playerId];
|
||||
if (last.time_since_epoch().count() == 0 || (now - last) >= std::chrono::milliseconds{ 500 }) {
|
||||
last = now;
|
||||
auto* diagHolder = static_cast<RE::IAnimationGraphManagerHolder*>(&a_proxy);
|
||||
float readSpeed = -1.0F;
|
||||
float readSpeedSampled = -1.0F;
|
||||
const auto haveSpeed =
|
||||
diagHolder->GetGraphVariableImplFloat(RE::BSFixedString{ "Speed" }, readSpeed);
|
||||
const auto haveSpeedSampled =
|
||||
diagHolder->GetGraphVariableImplFloat(RE::BSFixedString{ "speedSampled" }, readSpeedSampled);
|
||||
LogInfoWithLocalPlayerPrefix(std::format(
|
||||
"Proxy anim diag p{} actor={:08X}: recvMoving={}, recvSprint={}, recvMovementSpeed={:.1f}, recvAnimGraphSpeed={:.1f}, computedGraphSpeed={:.1f}, graphSpeedReadBack={}({:.1f}), speedSampledReadBack={}({:.1f}).",
|
||||
a_remotePlayer.playerId,
|
||||
actorId,
|
||||
a_remotePlayer.isMoving,
|
||||
a_remotePlayer.isSprinting,
|
||||
a_remotePlayer.movementSpeed,
|
||||
a_remotePlayer.animationGraphSpeed,
|
||||
desired.graphSpeed,
|
||||
haveSpeed,
|
||||
readSpeed,
|
||||
haveSpeedSampled,
|
||||
readSpeedSampled));
|
||||
}
|
||||
}
|
||||
|
||||
// Apply every frame (not just when moving) so animation Speed blends smoothly.
|
||||
// Havok needs continuous graph updates to match foot placement to velocity.
|
||||
const auto shouldApply =
|
||||
!a_state.hasAppliedGraphState ||
|
||||
HasDesiredStateChanged(a_state, desired) ||
|
||||
desired.isMoving;
|
||||
HasDesiredStateChanged(a_state, desired);
|
||||
|
||||
if (!shouldApply) {
|
||||
return;
|
||||
@@ -532,15 +618,11 @@ namespace F4T::ProxyAnimationSync
|
||||
// Prepare float variables (Speed, direction, etc.)
|
||||
snapshot.floats.resize(descriptor.GetFloatVariableCount(), 0.0F);
|
||||
|
||||
// Speed: calculated from movement speed
|
||||
if (descriptor.GetFloatVariableIndex("Speed") != static_cast<std::size_t>(-1)) {
|
||||
const auto desired = BuildDesiredState(a_remotePlayer);
|
||||
snapshot.floats[descriptor.GetFloatVariableIndex("Speed")] = desired.graphSpeed;
|
||||
}
|
||||
const auto desired = BuildDesiredState(a_remotePlayer);
|
||||
PopulateLocomotionSpeedSnapshot(snapshot, descriptor, desired.graphSpeed);
|
||||
|
||||
// Direction: from remote player's heading
|
||||
if (descriptor.GetFloatVariableIndex("direction") != static_cast<std::size_t>(-1)) {
|
||||
const auto desired = BuildDesiredState(a_remotePlayer);
|
||||
snapshot.floats[descriptor.GetFloatVariableIndex("direction")] = desired.direction;
|
||||
}
|
||||
|
||||
@@ -548,17 +630,14 @@ namespace F4T::ProxyAnimationSync
|
||||
snapshot.bools.resize(descriptor.GetBoolVariableCount(), false);
|
||||
|
||||
if (descriptor.GetBoolVariableIndex("isSprinting") != static_cast<std::size_t>(-1)) {
|
||||
const auto desired = BuildDesiredState(a_remotePlayer);
|
||||
snapshot.bools[descriptor.GetBoolVariableIndex("isSprinting")] = desired.isSprinting;
|
||||
}
|
||||
|
||||
if (descriptor.GetBoolVariableIndex("isSneaking") != static_cast<std::size_t>(-1)) {
|
||||
const auto desired = BuildDesiredState(a_remotePlayer);
|
||||
snapshot.bools[descriptor.GetBoolVariableIndex("isSneaking")] = desired.isSneaking;
|
||||
}
|
||||
|
||||
if (descriptor.GetBoolVariableIndex("isMoving") != static_cast<std::size_t>(-1)) {
|
||||
const auto desired = BuildDesiredState(a_remotePlayer);
|
||||
snapshot.bools[descriptor.GetBoolVariableIndex("isMoving")] = desired.isMoving;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -1,8 +1,12 @@
|
||||
#include "F4TNetworking.h"
|
||||
#include "F4TLocalAnimationGraphDebug.h"
|
||||
#include "F4TProxyActorController.h"
|
||||
#include "F4TProxyAnimationSync.h"
|
||||
#include "F4TProxyPuppet.h"
|
||||
|
||||
#include "RE/A/ACTOR_STANCE.h"
|
||||
#include "RE/B/BSFixedString.h"
|
||||
#include "RE/I/IAnimationGraphManagerHolder.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
@@ -81,6 +85,21 @@ namespace
|
||||
return RE::PlayerCharacter::GetSingleton();
|
||||
}
|
||||
|
||||
float TryReadLocalAnimationGraphSpeed(RE::PlayerCharacter& a_player)
|
||||
{
|
||||
auto* graphHolder = static_cast<RE::IAnimationGraphManagerHolder*>(&a_player);
|
||||
float speed = -1.0F;
|
||||
if (graphHolder->GetGraphVariableImplFloat(
|
||||
RE::BSFixedString{ F4T::ProxyAnimationSync::GraphVar::kSpeed },
|
||||
speed) &&
|
||||
std::isfinite(speed) &&
|
||||
speed >= 0.0F) {
|
||||
return speed;
|
||||
}
|
||||
|
||||
return -1.0F;
|
||||
}
|
||||
|
||||
PlayerTransform GetPlayerTransform(const RE::PlayerCharacter& a_player)
|
||||
{
|
||||
const auto position = a_player.GetPosition();
|
||||
@@ -491,6 +510,26 @@ namespace
|
||||
return;
|
||||
}
|
||||
|
||||
const auto animationGraphSpeed =
|
||||
networkMovementState.isMoving ? TryReadLocalAnimationGraphSpeed(*player) : -1.0F;
|
||||
|
||||
// Sender-side diagnostic: reveals, from whichever instance owns the log,
|
||||
// what this local player computes and transmits while moving. Throttled.
|
||||
{
|
||||
static auto s_lastSendDiag = std::chrono::steady_clock::time_point{};
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (networkMovementState.isMoving &&
|
||||
(s_lastSendDiag.time_since_epoch().count() == 0 || (now - s_lastSendDiag) >= 500ms)) {
|
||||
s_lastSendDiag = now;
|
||||
LogInfoWithLocalPlayerPrefix(std::format(
|
||||
"Local send diag: isMoving={}, isSprinting={}, movementSpeed={:.1f}, localGraphSpeedRead={:.1f}.",
|
||||
networkMovementState.isMoving,
|
||||
networkMovementState.isSprinting,
|
||||
networkMovementState.movementSpeed,
|
||||
animationGraphSpeed));
|
||||
}
|
||||
}
|
||||
|
||||
// Normal movement sends more often than logs, but still avoids packet-per-frame noise.
|
||||
const auto sentTransform = F4T::Networking::SendTransformPacket(
|
||||
currentTransform.x,
|
||||
@@ -507,7 +546,8 @@ namespace
|
||||
networkMovementState.isSneaking,
|
||||
networkMovementState.isJumping,
|
||||
networkMovementState.weaponDrawn,
|
||||
networkMovementState.movementSpeed);
|
||||
networkMovementState.movementSpeed,
|
||||
animationGraphSpeed);
|
||||
if (sentTransform) {
|
||||
lastSentTransform = currentTransform;
|
||||
lastKnownLocation = currentLocation;
|
||||
@@ -540,6 +580,11 @@ namespace
|
||||
return;
|
||||
}
|
||||
|
||||
// Install the proxy puppet hook now that the executable is loaded and REL
|
||||
// addresses resolve. This stops the engine from recomputing (and clobbering)
|
||||
// the networked locomotion Speed on proxy actors.
|
||||
F4T::ProxyPuppet::Install();
|
||||
|
||||
// The player reference may not exist at plugin load time. A permanent task lets us
|
||||
// check on the game thread after load, while separate send and log gates keep updates useful.
|
||||
taskInterface->AddTaskPermanent([]() {
|
||||
|
||||
@@ -48,6 +48,22 @@ ClientSnapshotProvider = Callable[[], list[dict[str, Any]]]
|
||||
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:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -537,6 +553,11 @@ class FakePlayerClient:
|
||||
"weaponDrawn": False,
|
||||
"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"
|
||||
with self._send_lock:
|
||||
connection.sendall(encoded)
|
||||
|
||||
Reference in New Issue
Block a user