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:
+169
@@ -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.
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
|
||||||
if (descriptor.GetFloatVariableIndex("Speed") != static_cast<std::size_t>(-1)) {
|
|
||||||
const auto desired = BuildDesiredState(a_remotePlayer);
|
const auto desired = BuildDesiredState(a_remotePlayer);
|
||||||
snapshot.floats[descriptor.GetFloatVariableIndex("Speed")] = desired.graphSpeed;
|
PopulateLocomotionSpeedSnapshot(snapshot, descriptor, 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 "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([]() {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user