Improve proxy locomotion, tier & jump sync

Fix multiple proxy animation issues: stale graph Speed collapsing jog into walk, missing slow-walk/jog tiers, and jump takeoff/landing not playing. Key changes:

- Prefer position-derived movementSpeed when animationGraphSpeed is missing/stale (ResolveLocomotionGraphSpeed) and fall back to graph Speed only when it agrees within a tolerance; sender now transmits movementSpeed when local graph read is 0 and TryReadLocalAnimationGraphSpeed falls back to SpeedSmoothed.
- Introduce locomotion tiers (Walk/Jog/Run) with ComputeLocomotionTier and sync ints (iSyncWalkRun/iSyncLocomotionSpeed/iSyncJumpState) to the graph; fire corresponding tier events when starting or changing tier.
- Fix proxy motion application: persist per-proxy velocity and worldDelta so the Update hook does not zero-out controller motion; use measured frame motion to drive animation when network flags are sparse.
- Restore correct jump behavior: fire MT/weapon-compatible jump events for takeoff and landing (JumpUp/JumpStartFrom*/jumpLand/jumpLandTo*), clear bInJumpState on landing and remove the extra proxy-side jump hold so landings exit the jump loop.
- Misc: add constants and thresholds for tier/run/walk/run-land decisions and update logs to include tier labels.

Files updated: plugin/include/F4TProxyAnimationSync.h, plugin/src/F4TProxyAnimationSync.cpp, plugin/src/F4AnimationDescriptor.cpp, plugin/src/F4TProxyPuppet.cpp, plugin/src/F4TProxyActorController.cpp, plugin/src/main.cpp, and docs/dev-log.md.
This commit is contained in:
2026-06-05 15:48:16 +12:00
parent 6a38e7226c
commit a137821406
7 changed files with 368 additions and 73 deletions
+88
View File
@@ -7,6 +7,94 @@ failed experiments, successful tests, and next steps.
--- ---
## 2026-06-05 - Locomotion Tier + Jump Takeoff Fix (Round 2)
### Summary
Fixed three root causes from the 15:34 session log where proxies showed only medium-walk
cadence, default forward (jog) looked like walk, and jump takeoff never played (landing only).
### Files Changed
- `plugin/include/F4TProxyAnimationSync.h`
- `plugin/src/F4TProxyAnimationSync.cpp`
- `plugin/src/F4AnimationDescriptor.cpp`
- `plugin/src/F4TProxyPuppet.cpp`
### Details
**Stale animationGraphSpeed overriding movement tiers (log evidence):**
- `Proxy anim diag` showed `recvMovementSpeed=9.0, recvAnimGraphSpeed=148.8` and
`recvMovementSpeed=58.6, recvAnimGraphSpeed=182.2` — graph readings lagged a full send
interval behind position-derived speed, collapsing jog into walk-tier animation.
- Fix: `ResolveLocomotionGraphSpeed()` now uses `movementSpeed` as primary; only adopts
`animationGraphSpeed` when it agrees within 40% of movementSpeed.
**Within-walk cadence stuck at medium pace:**
- `F4TProxyPuppet` Update hook re-applied `SyncControllerMotionFields` with **zero velocity**
every frame, undoing the real velocity written by `ApplyProxyLocomotionFrame` after `Move()`.
- Fix: persist per-proxy `velocity` and `worldDelta` in locomotion state; hook re-applies them.
**Jump takeoff missing:**
- All RaiderRoot events (`JumpStandingStart`, `jumpLand`, etc.) logged `graphAccepted=false`.
MTBehavior/WeaponBehavior use `JumpUp`/`JumpFullBody`/`JumpLayerOn` for takeoff and
`JumpDown` for landing.
- Fix: fire MT/weapon jump events on takeoff/land; added `iSyncJumpState` + `iSyncWalkRun`
to the humanoid descriptor (0=walk, 1=jog, 2=run from graph Speed bands).
### Testing
- `xmake build` succeeds.
- In-game: slow-walk, jog (default W), run should differ; jump should show takeoff + land.
- Log: `computedGraphSpeed` should track `recvMovementSpeed` when graph was stale; jump events
should include `JumpUp`/`JumpFullBody`.
---
## 2026-06-05 - Slow-Walk/Jog Speed + Ground Jump Landing Fix
### Summary
Fixed the remaining two animation issues reported after the graph-variable name correction:
proxies only showed walk/run (no slow-walk/jog), and ground jumps never exited the jump loop.
### Files Changed
- `plugin/include/F4TProxyAnimationSync.h`
- `plugin/src/F4TProxyAnimationSync.cpp`
- `plugin/src/main.cpp`
- `plugin/src/F4TProxyActorController.cpp`
### Details
**Slow-walk / jog missing (log evidence):**
- `Local send diag` showed `movementSpeed=58.5` with `localGraphSpeedRead=0.0` on many frames.
- Receivers treated `animationGraphSpeed=0.0` as valid and drove graph Speed to 0, collapsing
all sub-run paces to idle/walk. Only when the live graph read returned ~176 or ~373 did
jog/run appear.
- Fix: `ResolveLocomotionGraphSpeed()` ignores animationGraphSpeed unless it is above the idle
threshold; falls back to position-derived `movementSpeed` (~1:1). Sender now transmits
`movementSpeed` when the local graph read is 0. `TryReadLocalAnimationGraphSpeed` also
tries `SpeedSmoothed` as a fallback.
**Ground jump never ends:**
- Takeoff used `JumpStart`; landing tried `moveStart`/`moveStop`, which does not exit the
ground-jump loop (table drops work via a different fall path).
- Proxy also held `isJumping` for 400ms after the network cleared, delaying landing events.
- Fix: use real FO4 events from behavior-graph research:
- Takeoff: `JumpStandingStart` / `JumpDirectionalStart`
- Landing: `jumpLand` + `jumpLandToWalk`/`jumpLandToRun` + `JumpStandingEnd`, clear
`bInJumpState`, then re-assert locomotion.
- Removed proxy-side jump hold; sender's 200ms hold is sufficient.
### Testing
- `xmake build` succeeds.
- In-game: slow-walk (~50-75), jog (~150-220), run (~300+) should each blend via graph Speed;
ground jumps should land and return to locomotion. Check log for `jumpLand` events with
`graphAccepted=true`.
### Known Issues
- Jump land run/walk threshold (150 u/s) may need tuning against in-game feel.
- Sprint animation tier still depends on `IsSprinting` + speed floor, not yet verified.
### Next Steps
- Verify all three locomotion tiers and ground-jump landing in a fresh log.
---
## 2026-06-05 - Fix False Jump State Masking Locomotion Animation ## 2026-06-05 - Fix False Jump State Masking Locomotion Animation
### Summary ### Summary
+26 -2
View File
@@ -30,12 +30,35 @@ namespace F4T::ProxyAnimationSync
inline constexpr const char* kIsSprinting = "IsSprinting"; inline constexpr const char* kIsSprinting = "IsSprinting";
inline constexpr const char* kInJumpState = "bInJumpState"; inline constexpr const char* kInJumpState = "bInJumpState";
inline constexpr const char* kIsInSneak = "iIsInSneak"; inline constexpr const char* kIsInSneak = "iIsInSneak";
inline constexpr const char* kSyncWalkRun = "iSyncWalkRun";
inline constexpr const char* kSyncLocomotionSpeed = "iSyncLocomotionSpeed";
inline constexpr const char* kSyncJumpState = "iSyncJumpState";
} }
// Maps network movementSpeed (units/sec) to graph Speed (~0-105). Used when the sender // MTLocomotionBlend_SM sub-states: Walk(0), Jog(1), Run(2). Selected by graph events
// does not provide animationGraphSpeed. // "Walk"/"Jog"/"Run" and mirrored in iSyncLocomotionSpeed.
enum class LocomotionTier : std::int32_t
{
kWalk = 0,
kJog = 1,
kRun = 2,
};
LocomotionTier ComputeLocomotionTier(float a_graphSpeed, bool a_isSprinting);
// Maps network movementSpeed (units/sec) to graph Speed. Used when animationGraphSpeed
// is missing or unreliable.
float MapMovementSpeedToGraphSpeed(float a_movementSpeed, bool a_isSprinting); float MapMovementSpeedToGraphSpeed(float a_movementSpeed, bool a_isSprinting);
// Picks the graph Speed to drive locomotion blending. Prefers animationGraphSpeed only
// when it is above the idle threshold; a read of 0.0 while moving is common and must
// not override the position-derived movementSpeed (which carries slow-walk/jog tiers).
float ResolveLocomotionGraphSpeed(
float a_movementSpeed,
float a_animationGraphSpeed,
bool a_isMoving,
bool a_isSprinting);
struct ProxyAppliedAnimationState struct ProxyAppliedAnimationState
{ {
bool hasAppliedGraphState{ false }; bool hasAppliedGraphState{ false };
@@ -47,6 +70,7 @@ namespace F4T::ProxyAnimationSync
bool lastWeaponDrawn{ false }; bool lastWeaponDrawn{ false };
float lastGraphSpeed{ 0.0F }; float lastGraphSpeed{ 0.0F };
float lastDirection{ 0.0F }; float lastDirection{ 0.0F };
std::optional<LocomotionTier> lastLocomotionTier{};
std::chrono::steady_clock::time_point movingHoldUntil{}; std::chrono::steady_clock::time_point movingHoldUntil{};
std::chrono::steady_clock::time_point jumpHoldUntil{}; std::chrono::steady_clock::time_point jumpHoldUntil{};
}; };
+3
View File
@@ -89,6 +89,9 @@ namespace F4T::AnimationDescriptor
// ===== INT VARIABLES (VARIABLE_TYPE_INT32) ===== // ===== INT VARIABLES (VARIABLE_TYPE_INT32) =====
m_intVariables = { m_intVariables = {
"iIsInSneak", // 0 - sneak/crouch state (0 = standing, 1 = sneaking) "iIsInSneak", // 0 - sneak/crouch state (0 = standing, 1 = sneaking)
"iSyncWalkRun", // 1 - locomotion tier sync (0 = walk, 1 = jog, 2 = run)
"iSyncLocomotionSpeed", // 2 - MTLocomotionBlend_SM start state (0/1/2)
"iSyncJumpState", // 3 - jump sync state (1 while airborne)
}; };
// Build reverse lookup map for int variables // Build reverse lookup map for int variables
+39 -6
View File
@@ -282,6 +282,7 @@ namespace
std::optional<std::string> lastObservedMovementType; std::optional<std::string> lastObservedMovementType;
std::optional<ProxyAnimationSpeedBucket> lastObservedMovementSpeedBucket; std::optional<ProxyAnimationSpeedBucket> lastObservedMovementSpeedBucket;
std::optional<bool> lastAppliedVisualSneakingState; std::optional<bool> lastAppliedVisualSneakingState;
std::optional<RemotePlayer> lastRemotePlayerSnapshot;
F4T::ProxyAnimationSync::ProxyAppliedAnimationState appliedAnimationState{}; F4T::ProxyAnimationSync::ProxyAppliedAnimationState appliedAnimationState{};
// Phase 5.1: Action queue for discrete action replay // Phase 5.1: Action queue for discrete action replay
@@ -4812,6 +4813,7 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
a_slot.restoredForCurrentState = true; a_slot.restoredForCurrentState = true;
a_slot.firstSmoothedMovementLogged = false; a_slot.firstSmoothedMovementLogged = false;
a_slot.remoteStateMovedLogged = false; a_slot.remoteStateMovedLogged = false;
a_slot.lastRemotePlayerSnapshot = a_remotePlayer;
LogInfoWithLocalPlayerPrefix(std::format( LogInfoWithLocalPlayerPrefix(std::format(
"Runtime proxy actor {:08X} restored/reused for remote player {} at visible X={:.2f}, Y={:.2f}, Z={:.2f} from remote X={:.2f}, Y={:.2f}, Z={:.2f}.", "Runtime proxy actor {:08X} restored/reused for remote player {} at visible X={:.2f}, Y={:.2f}, Z={:.2f} from remote X={:.2f}, Y={:.2f}, Z={:.2f}.",
@@ -4969,16 +4971,14 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
a_slot.targetPosition = targetPosition; a_slot.targetPosition = targetPosition;
a_slot.targetHeading = a_remotePlayer.angleZ; a_slot.targetHeading = a_remotePlayer.angleZ;
a_slot.puppetDirection = a_remotePlayer.angleZ; a_slot.puppetDirection = a_remotePlayer.angleZ;
a_slot.lastRemotePlayerSnapshot = a_remotePlayer;
if (a_remotePlayer.isMoving && a_remotePlayer.movementSpeed >= 1.0F) { if (a_remotePlayer.isMoving && a_remotePlayer.movementSpeed >= 1.0F) {
if (a_remotePlayer.animationGraphSpeed >= 0.0F && a_slot.puppetGraphSpeed = F4T::ProxyAnimationSync::ResolveLocomotionGraphSpeed(
std::isfinite(a_remotePlayer.animationGraphSpeed)) {
a_slot.puppetGraphSpeed = a_remotePlayer.animationGraphSpeed;
} else {
a_slot.puppetGraphSpeed = F4T::ProxyAnimationSync::MapMovementSpeedToGraphSpeed(
a_remotePlayer.movementSpeed, a_remotePlayer.movementSpeed,
a_remotePlayer.animationGraphSpeed,
a_remotePlayer.isMoving,
a_remotePlayer.isSprinting); a_remotePlayer.isSprinting);
}
} else { } else {
a_slot.puppetGraphSpeed = -1.0F; a_slot.puppetGraphSpeed = -1.0F;
} }
@@ -5359,6 +5359,12 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
frameDeltaSeconds, frameDeltaSeconds,
-1.0F, -1.0F,
slot.puppetDirection); slot.puppetDirection);
if (slot.lastRemotePlayerSnapshot) {
F4T::ProxyAnimationSync::ApplyProxyAnimationFromRemoteState(
slot.appliedAnimationState,
*proxy,
*slot.lastRemotePlayerSnapshot);
}
continue; continue;
} }
@@ -5388,6 +5394,33 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
frameDeltaSeconds, frameDeltaSeconds,
slot.puppetGraphSpeed, slot.puppetGraphSpeed,
slot.puppetDirection); slot.puppetDirection);
// Drive animation from measured proxy motion every frame. Network isMoving is
// often false while the proxy is visibly jogging (log: recvMoving=false during
// active translation). MTBehavior also needs explicit Jog/Run/Walk events.
if (slot.lastRemotePlayerSnapshot) {
auto effectiveRemote = *slot.lastRemotePlayerSnapshot;
const float horizontalDelta =
std::sqrt((frameDelta.x * frameDelta.x) + (frameDelta.y * frameDelta.y));
const float measuredSpeed =
frameDeltaSeconds > 0.0F ? horizontalDelta / frameDeltaSeconds : 0.0F;
const float puppetSpeed =
slot.puppetGraphSpeed >= 0.0F ? slot.puppetGraphSpeed : measuredSpeed;
const float locomotionSpeed = std::max(measuredSpeed, puppetSpeed);
if (locomotionSpeed >= 8.0F) {
effectiveRemote.isMoving = true;
effectiveRemote.movementSpeed = locomotionSpeed;
if (effectiveRemote.animationGraphSpeed < 1.0F) {
effectiveRemote.animationGraphSpeed = locomotionSpeed;
}
}
F4T::ProxyAnimationSync::ApplyProxyAnimationFromRemoteState(
slot.appliedAnimationState,
*proxy,
effectiveRemote);
}
} }
} }
} }
+174 -47
View File
@@ -45,9 +45,14 @@ namespace
// Hold duration for movement stop debounce. Must be short so animation stop events // 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. // fire promptly; long holds cause characters to glide while playing stop animations.
constexpr auto kProxyMovingStopHoldDuration = std::chrono::milliseconds{ 50 }; constexpr auto kProxyMovingStopHoldDuration = std::chrono::milliseconds{ 50 };
// Minimum time to keep a remote jump "active" on the proxy so takeoff animation can play // Graph Speed above ~150 selects run-tier landings (jumpLandToRun); below uses walk-tier.
// before we fire landing/locomotion exit events (network updates are ~100ms apart). constexpr float kJumpLandRunSpeedThreshold = 150.0F;
constexpr auto kProxyJumpMinDuration = std::chrono::milliseconds{ 400 }; // Reject animationGraphSpeed when it diverges this much from movementSpeed (stale graph
// readings lag a full send interval and collapse jog into walk-tier animation).
constexpr float kGraphSpeedAgreementRatio = 0.4F;
// FO4 default forward (W / full stick) is jog. Slow walk is below kWalkTierMax.
constexpr float kWalkTierMax = 90.0F;
constexpr float kRunGraphSpeedThreshold = 280.0F;
// Graph Speed range confirmed from local-player read-by-name debug in // 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 // Fallout4Together.log: walk starts ~47, full run ~373. FO4's graph "Speed" is in the
@@ -125,16 +130,26 @@ namespace
// which doubles as a diagnostic for which event names this FO4 graph accepts. // which doubles as a diagnostic for which event names this FO4 graph accepts.
namespace GraphEvent namespace GraphEvent
{ {
// MTBehavior event names (case-sensitive; from MTBehavior.hkx eventNames).
inline constexpr const char* kMoveStart = "moveStart"; inline constexpr const char* kMoveStart = "moveStart";
inline constexpr const char* kMoveStop = "moveStop"; inline constexpr const char* kMoveStop = "moveStop";
inline constexpr const char* kSprintStart = "SprintStart"; inline constexpr const char* kSprintStart = "sprintStart";
inline constexpr const char* kSprintStop = "SprintStop"; inline constexpr const char* kSprintStop = "sprintStop";
inline constexpr const char* kSneakStart = "SneakStart"; inline constexpr const char* kSneakStart = "sneakStart";
inline constexpr const char* kSneakStop = "SneakStop"; inline constexpr const char* kSneakStop = "sneakStop";
// JumpStart is accepted by the FO4 proxy graph (takeoff). JumpStop is not reliable // MTLocomotionBlend_SM tier transitions (Walk state 0, Jog state 1, Run state 2).
// for landing; use moveStart/moveStop to exit the jump state instead. inline constexpr const char* kWalk = "Walk";
inline constexpr const char* kJumpStart = "JumpStart"; inline constexpr const char* kJog = "Jog";
inline constexpr const char* kJumpStop = "JumpStop"; inline constexpr const char* kRun = "Run";
inline constexpr const char* kJumpStart = "jumpStart";
inline constexpr const char* kJumpStartFromWalk = "jumpStartFromWalk";
inline constexpr const char* kJumpStartFromRun = "jumpStartFromRun";
inline constexpr const char* kJumpUp = "JumpUp";
inline constexpr const char* kJumpDown = "JumpDown";
inline constexpr const char* kJumpLand = "jumpLand";
inline constexpr const char* kJumpLandToWalk = "jumpLandToWalk";
inline constexpr const char* kJumpLandToRun = "jumpLandToRun";
inline constexpr const char* kJumpEnd = "jumpEnd";
inline constexpr const char* kCrouchStart = "CrouchStart"; inline constexpr const char* kCrouchStart = "CrouchStart";
inline constexpr const char* kCrouchStop = "CrouchStop"; inline constexpr const char* kCrouchStop = "CrouchStop";
} }
@@ -173,6 +188,20 @@ namespace
return std::clamp(a_speed, kGraphSpeedMin, kGraphSpeedMax); return std::clamp(a_speed, kGraphSpeedMin, kGraphSpeedMax);
} }
const char* GetLocomotionTierEventName(F4T::ProxyAnimationSync::LocomotionTier a_tier)
{
switch (a_tier) {
case F4T::ProxyAnimationSync::LocomotionTier::kWalk:
return GraphEvent::kWalk;
case F4T::ProxyAnimationSync::LocomotionTier::kJog:
return GraphEvent::kJog;
case F4T::ProxyAnimationSync::LocomotionTier::kRun:
return GraphEvent::kRun;
default:
return GraphEvent::kJog;
}
}
void PopulateLocomotionSpeedSnapshot( void PopulateLocomotionSpeedSnapshot(
F4T::AnimationDescriptor::AnimationVariableSnapshot& a_snapshot, F4T::AnimationDescriptor::AnimationVariableSnapshot& a_snapshot,
const F4T::AnimationDescriptor::F4AnimationDescriptor& a_descriptor, const F4T::AnimationDescriptor::F4AnimationDescriptor& a_descriptor,
@@ -193,17 +222,10 @@ namespace
float ComputeGraphSpeed(const F4T::RemotePlayerState::RemotePlayerState& a_remotePlayer) float ComputeGraphSpeed(const F4T::RemotePlayerState::RemotePlayerState& a_remotePlayer)
{ {
if (!a_remotePlayer.isMoving || a_remotePlayer.movementSpeed < kIdleSpeedThreshold) { return F4T::ProxyAnimationSync::ResolveLocomotionGraphSpeed(
return kGraphSpeedIdle;
}
// 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);
}
return F4T::ProxyAnimationSync::MapMovementSpeedToGraphSpeed(
a_remotePlayer.movementSpeed, a_remotePlayer.movementSpeed,
a_remotePlayer.animationGraphSpeed,
a_remotePlayer.isMoving,
a_remotePlayer.isSprinting); a_remotePlayer.isSprinting);
} }
@@ -222,7 +244,9 @@ namespace
DesiredProxyAnimationState BuildDesiredState(const F4T::RemotePlayerState::RemotePlayerState& a_remotePlayer) DesiredProxyAnimationState BuildDesiredState(const F4T::RemotePlayerState::RemotePlayerState& a_remotePlayer)
{ {
DesiredProxyAnimationState desired{}; DesiredProxyAnimationState desired{};
desired.isMoving = a_remotePlayer.isMoving && a_remotePlayer.movementSpeed >= kIdleSpeedThreshold; // movementSpeed can be valid while isMoving is false (sparse network flags).
const bool hasLocomotionSpeed = a_remotePlayer.movementSpeed >= kIdleSpeedThreshold;
desired.isMoving = (a_remotePlayer.isMoving || hasLocomotionSpeed) && hasLocomotionSpeed;
desired.isSprinting = desired.isMoving && a_remotePlayer.isSprinting; desired.isSprinting = desired.isMoving && a_remotePlayer.isSprinting;
desired.isSneaking = a_remotePlayer.isSneaking; desired.isSneaking = a_remotePlayer.isSneaking;
desired.isJumping = a_remotePlayer.isJumping; desired.isJumping = a_remotePlayer.isJumping;
@@ -246,21 +270,6 @@ namespace
return a_state.movingHoldUntil.time_since_epoch().count() != 0 && now < a_state.movingHoldUntil; return a_state.movingHoldUntil.time_since_epoch().count() != 0 && now < a_state.movingHoldUntil;
} }
bool ApplyJumpHoldState(
F4T::ProxyAnimationSync::ProxyAppliedAnimationState& a_state,
bool a_rawIsJumping)
{
const auto now = std::chrono::steady_clock::now();
if (a_rawIsJumping) {
if (a_state.jumpHoldUntil.time_since_epoch().count() == 0 || now >= a_state.jumpHoldUntil) {
a_state.jumpHoldUntil = now + kProxyJumpMinDuration;
}
return true;
}
return a_state.jumpHoldUntil.time_since_epoch().count() != 0 && now < a_state.jumpHoldUntil;
}
DesiredProxyAnimationState BuildDebouncedDesiredState( DesiredProxyAnimationState BuildDebouncedDesiredState(
F4T::ProxyAnimationSync::ProxyAppliedAnimationState& a_state, F4T::ProxyAnimationSync::ProxyAppliedAnimationState& a_state,
const F4T::RemotePlayerState::RemotePlayerState& a_remotePlayer) const F4T::RemotePlayerState::RemotePlayerState& a_remotePlayer)
@@ -269,9 +278,9 @@ namespace
auto desired = rawDesired; auto desired = rawDesired;
// Apply minimal hold (50ms) for debounce, but don't coast; snap to idle immediately. // 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 constexpr (kEnableProxyJumpSync) { // Jump state follows the network value directly. The sender already holds isJumping
desired.isJumping = ApplyJumpHoldState(a_state, rawDesired.isJumping); // briefly for packet coalescing; an extra proxy-side hold delayed landing events and
} // kept bInJumpState true after ground landings.
if (!desired.isMoving) { if (!desired.isMoving) {
desired.isSprinting = false; desired.isSprinting = false;
@@ -293,12 +302,16 @@ namespace
return true; return true;
} }
const auto currentTier =
F4T::ProxyAnimationSync::ComputeLocomotionTier(a_desired.graphSpeed, a_desired.isSprinting);
return a_state.lastIsMoving != a_desired.isMoving || return a_state.lastIsMoving != a_desired.isMoving ||
a_state.lastIsSprinting != a_desired.isSprinting || a_state.lastIsSprinting != a_desired.isSprinting ||
a_state.lastIsSneaking != a_desired.isSneaking || a_state.lastIsSneaking != a_desired.isSneaking ||
a_state.lastIsJumping != a_desired.isJumping || a_state.lastIsJumping != a_desired.isJumping ||
a_state.lastIsCrouching != a_desired.isCrouching || a_state.lastIsCrouching != a_desired.isCrouching ||
a_state.lastWeaponDrawn != a_desired.weaponDrawn || a_state.lastWeaponDrawn != a_desired.weaponDrawn ||
!a_state.lastLocomotionTier || *a_state.lastLocomotionTier != currentTier ||
AreFloatsDifferent(a_state.lastGraphSpeed, a_desired.graphSpeed) || AreFloatsDifferent(a_state.lastGraphSpeed, a_desired.graphSpeed) ||
AreFloatsDifferent(a_state.lastDirection, a_desired.direction); AreFloatsDifferent(a_state.lastDirection, a_desired.direction);
} }
@@ -389,6 +402,11 @@ namespace
if (inJumpIdx != static_cast<std::size_t>(-1)) { if (inJumpIdx != static_cast<std::size_t>(-1)) {
snapshot.bools[inJumpIdx] = a_desired.isJumping; snapshot.bools[inJumpIdx] = a_desired.isJumping;
} }
const auto syncJumpIdx = descriptor.GetIntVariableIndex(F4T::ProxyAnimationSync::GraphVar::kSyncJumpState);
if (syncJumpIdx != static_cast<std::size_t>(-1)) {
snapshot.ints[syncJumpIdx] = a_desired.isJumping ? 1U : 0U;
}
} }
if constexpr (kEnableProxySneakSync) { if constexpr (kEnableProxySneakSync) {
@@ -398,6 +416,23 @@ namespace
} }
} }
if constexpr (kEnableProxyLocomotionSync) {
const auto tier = static_cast<std::int32_t>(
F4T::ProxyAnimationSync::ComputeLocomotionTier(a_desired.graphSpeed, a_desired.isSprinting));
const auto tierValue = static_cast<std::uint32_t>(tier);
const auto syncWalkRunIdx = descriptor.GetIntVariableIndex(F4T::ProxyAnimationSync::GraphVar::kSyncWalkRun);
if (syncWalkRunIdx != static_cast<std::size_t>(-1)) {
snapshot.ints[syncWalkRunIdx] = tierValue;
}
const auto syncLocomotionIdx =
descriptor.GetIntVariableIndex(F4T::ProxyAnimationSync::GraphVar::kSyncLocomotionSpeed);
if (syncLocomotionIdx != static_cast<std::size_t>(-1)) {
snapshot.ints[syncLocomotionIdx] = tierValue;
}
}
// Single bulk write operation (major performance win) // Single bulk write operation (major performance win)
const auto success = descriptor.LoadAnimationVariablesToCache(*graphHolder, snapshot); const auto success = descriptor.LoadAnimationVariablesToCache(*graphHolder, snapshot);
@@ -440,9 +475,21 @@ namespace
accepted)); accepted));
}; };
// Movement start/stop is the critical transition that takes the graph out of idle. const auto currentTier =
if (!wasInitialized || a_previous.lastIsMoving != a_desired.isMoving) { F4T::ProxyAnimationSync::ComputeLocomotionTier(a_desired.graphSpeed, a_desired.isSprinting);
fireEvent(a_desired.isMoving ? GraphEvent::kMoveStart : GraphEvent::kMoveStop); const bool startedMoving = a_desired.isMoving && (!wasInitialized || !a_previous.lastIsMoving);
const bool stoppedMoving = wasInitialized && a_previous.lastIsMoving && !a_desired.isMoving;
const bool tierChanged =
a_desired.isMoving &&
(!a_previous.lastLocomotionTier || *a_previous.lastLocomotionTier != currentTier);
if (startedMoving) {
fireEvent(GraphEvent::kMoveStart);
fireEvent(GetLocomotionTierEventName(currentTier));
} else if (stoppedMoving) {
fireEvent(GraphEvent::kMoveStop);
} else if (tierChanged) {
fireEvent(GetLocomotionTierEventName(currentTier));
} }
// Sprint sub-state transition (only meaningful while moving). // Sprint sub-state transition (only meaningful while moving).
@@ -457,13 +504,39 @@ namespace
} }
if constexpr (kEnableProxyJumpSync) { if constexpr (kEnableProxyJumpSync) {
auto* graphHolder = static_cast<RE::IAnimationGraphManagerHolder*>(&a_proxy);
if (!wasInitialized || a_previous.lastIsJumping != a_desired.isJumping) { if (!wasInitialized || a_previous.lastIsJumping != a_desired.isJumping) {
if (a_desired.isJumping) { if (a_desired.isJumping) {
const auto jumpTier = a_previous.lastLocomotionTier.value_or(currentTier);
switch (jumpTier) {
case F4T::ProxyAnimationSync::LocomotionTier::kRun:
fireEvent(GraphEvent::kJumpStartFromRun);
break;
case F4T::ProxyAnimationSync::LocomotionTier::kWalk:
fireEvent(GraphEvent::kJumpStartFromWalk);
break;
default:
fireEvent(GraphEvent::kJumpStart); fireEvent(GraphEvent::kJumpStart);
break;
}
fireEvent(GraphEvent::kJumpUp);
TrySetGraphBool(*graphHolder, F4T::ProxyAnimationSync::GraphVar::kInJumpState, true);
} else if (wasInitialized) { } else if (wasInitialized) {
// JumpStop is not consumed by the graph; re-assert ground locomotion to fireEvent(GraphEvent::kJumpDown);
// exit the jump loop (same mechanism as "jump off a table" resetting it). fireEvent(GraphEvent::kJumpLand);
fireEvent(a_desired.isMoving ? GraphEvent::kMoveStart : GraphEvent::kMoveStop); if (a_desired.isMoving) {
if (currentTier == F4T::ProxyAnimationSync::LocomotionTier::kRun) {
fireEvent(GraphEvent::kJumpLandToRun);
} else {
fireEvent(GraphEvent::kJumpLandToWalk);
}
fireEvent(GraphEvent::kMoveStart);
fireEvent(GetLocomotionTierEventName(currentTier));
} else {
fireEvent(GraphEvent::kJumpEnd);
fireEvent(GraphEvent::kMoveStop);
}
TrySetGraphBool(*graphHolder, F4T::ProxyAnimationSync::GraphVar::kInJumpState, false);
} }
} }
} }
@@ -481,12 +554,23 @@ namespace
const DesiredProxyAnimationState& a_desired, const DesiredProxyAnimationState& a_desired,
bool a_initial) bool a_initial)
{ {
const auto tier = F4T::ProxyAnimationSync::ComputeLocomotionTier(
a_desired.graphSpeed,
a_desired.isSprinting);
const char* tierLabel = "jog";
if (tier == F4T::ProxyAnimationSync::LocomotionTier::kWalk) {
tierLabel = "walk";
} else if (tier == F4T::ProxyAnimationSync::LocomotionTier::kRun) {
tierLabel = "run";
}
LogInfoWithLocalPlayerPrefix(std::format( LogInfoWithLocalPlayerPrefix(std::format(
"Runtime proxy animation sync {} for remote player {}: actor={:08X}, moving={}, sprinting={}, sneaking={}, jumping={}, crouching={}, weaponDrawn={}, graphSpeed={:.1f}, direction={:.3f}.", "Runtime proxy animation sync {} for remote player {}: actor={:08X}, moving={}, tier={}, sprinting={}, sneaking={}, jumping={}, crouching={}, weaponDrawn={}, graphSpeed={:.1f}, direction={:.3f}.",
a_initial ? "initial" : "changed", a_initial ? "initial" : "changed",
a_remotePlayerId, a_remotePlayerId,
a_actorId, a_actorId,
a_desired.isMoving, a_desired.isMoving,
tierLabel,
a_desired.isSprinting, a_desired.isSprinting,
a_desired.isSneaking, a_desired.isSneaking,
a_desired.isJumping, a_desired.isJumping,
@@ -517,6 +601,32 @@ namespace F4T::ProxyAnimationSync
return ClampGraphSpeed(graphSpeed); return ClampGraphSpeed(graphSpeed);
} }
float ResolveLocomotionGraphSpeed(
float a_movementSpeed,
float a_animationGraphSpeed,
bool a_isMoving,
bool a_isSprinting)
{
if (!a_isMoving || a_movementSpeed < kIdleSpeedThreshold) {
return kGraphSpeedIdle;
}
// Position-derived movementSpeed tracks the sender's real pace tiers. Graph Speed
// can lag a full network interval (log: movement=9, graph=148), which collapses jog
// into walk-tier animation when we preferred the stale graph reading.
auto graphSpeed = MapMovementSpeedToGraphSpeed(a_movementSpeed, a_isSprinting);
if (a_animationGraphSpeed > kIdleSpeedThreshold && std::isfinite(a_animationGraphSpeed)) {
const float baseline = std::max(a_movementSpeed, 1.0F);
const float divergence = std::fabs(a_animationGraphSpeed - a_movementSpeed) / baseline;
if (divergence <= kGraphSpeedAgreementRatio) {
graphSpeed = ClampGraphSpeed(a_animationGraphSpeed);
}
}
return graphSpeed;
}
void ResetAppliedAnimationState(ProxyAppliedAnimationState& a_state) void ResetAppliedAnimationState(ProxyAppliedAnimationState& a_state)
{ {
a_state = {}; a_state = {};
@@ -607,8 +717,25 @@ namespace F4T::ProxyAnimationSync
a_state.lastWeaponDrawn = desired.weaponDrawn; a_state.lastWeaponDrawn = desired.weaponDrawn;
a_state.lastGraphSpeed = desired.graphSpeed; a_state.lastGraphSpeed = desired.graphSpeed;
a_state.lastDirection = desired.direction; a_state.lastDirection = desired.direction;
if (desired.isMoving) {
a_state.lastLocomotionTier =
ComputeLocomotionTier(desired.graphSpeed, desired.isSprinting);
} else {
a_state.lastLocomotionTier.reset();
} }
} }
}
LocomotionTier ComputeLocomotionTier(float a_graphSpeed, bool a_isSprinting)
{
if (a_isSprinting || a_graphSpeed >= kRunGraphSpeedThreshold) {
return LocomotionTier::kRun;
}
if (a_graphSpeed > kWalkTierMax) {
return LocomotionTier::kJog;
}
return LocomotionTier::kWalk;
}
void ClearProxyAnimationState( void ClearProxyAnimationState(
ProxyAppliedAnimationState& a_state, ProxyAppliedAnimationState& a_state,
+14 -6
View File
@@ -30,6 +30,8 @@ namespace F4T::ProxyPuppet
{ {
float speed{ -1.0F }; float speed{ -1.0F };
float direction{ 0.0F }; float direction{ 0.0F };
RE::NiPoint3 velocity{};
RE::NiPoint3 worldDelta{};
}; };
std::shared_mutex g_proxyFormIdMutex; std::shared_mutex g_proxyFormIdMutex;
@@ -160,11 +162,10 @@ namespace F4T::ProxyPuppet
a_this->UpdateNoAI(a_delta); a_this->UpdateNoAI(a_delta);
if (locomotion.speed >= 0.0F) { if (locomotion.speed >= 0.0F) {
const RE::NiPoint3 zeroVelocity{}; SyncControllerMotionFields(*a_this, locomotion.velocity, locomotion.speed);
SyncControllerMotionFields(*a_this, zeroVelocity, locomotion.speed);
TryPushMotionFeedbackToGraph( TryPushMotionFeedbackToGraph(
*a_this, *a_this,
RE::NiPoint3{}, locomotion.worldDelta,
a_delta, a_delta,
locomotion.speed, locomotion.speed,
locomotion.direction); locomotion.direction);
@@ -228,7 +229,9 @@ namespace F4T::ProxyPuppet
} }
const std::unique_lock lock{ g_proxyFormIdMutex }; const std::unique_lock lock{ g_proxyFormIdMutex };
g_proxyLocomotion[a_formID] = ProxyLocomotionState{ a_speed, a_direction }; auto& state = g_proxyLocomotion[a_formID];
state.speed = a_speed;
state.direction = a_direction;
} }
void SetProxyDesiredSpeed(std::uint32_t a_formID, float a_speed) void SetProxyDesiredSpeed(std::uint32_t a_formID, float a_speed)
@@ -261,12 +264,17 @@ namespace F4T::ProxyPuppet
(a_graphSpeed >= 0.0F && std::isfinite(a_graphSpeed)) ? a_graphSpeed : horizontalSpeed; (a_graphSpeed >= 0.0F && std::isfinite(a_graphSpeed)) ? a_graphSpeed : horizontalSpeed;
if (feedbackSpeed < 8.0F) { if (feedbackSpeed < 8.0F) {
SetProxyLocomotionState(a_actor.GetFormID(), -1.0F, a_direction); const std::unique_lock lock{ g_proxyFormIdMutex };
g_proxyLocomotion[a_actor.GetFormID()] = ProxyLocomotionState{ -1.0F, a_direction, {}, {} };
SyncControllerMotionFields(a_actor, {}, 0.0F); SyncControllerMotionFields(a_actor, {}, 0.0F);
return; return;
} }
SetProxyLocomotionState(a_actor.GetFormID(), feedbackSpeed, a_direction); {
const std::unique_lock lock{ g_proxyFormIdMutex };
g_proxyLocomotion[a_actor.GetFormID()] =
ProxyLocomotionState{ feedbackSpeed, a_direction, velocity, a_worldDelta };
}
SyncControllerMotionFields(a_actor, velocity, feedbackSpeed); SyncControllerMotionFields(a_actor, velocity, feedbackSpeed);
TryPushMotionFeedbackToGraph( TryPushMotionFeedbackToGraph(
a_actor, a_actor,
+17 -5
View File
@@ -90,16 +90,23 @@ namespace
float TryReadLocalAnimationGraphSpeed(RE::PlayerCharacter& a_player) float TryReadLocalAnimationGraphSpeed(RE::PlayerCharacter& a_player)
{ {
auto* graphHolder = static_cast<RE::IAnimationGraphManagerHolder*>(&a_player); auto* graphHolder = static_cast<RE::IAnimationGraphManagerHolder*>(&a_player);
auto tryRead = [&](const char* a_name) -> float {
float speed = -1.0F; float speed = -1.0F;
if (graphHolder->GetGraphVariableImplFloat( if (graphHolder->GetGraphVariableImplFloat(RE::BSFixedString{ a_name }, speed) &&
RE::BSFixedString{ F4T::ProxyAnimationSync::GraphVar::kSpeed },
speed) &&
std::isfinite(speed) && std::isfinite(speed) &&
speed >= 0.0F) { speed > kMovingSpeedThreshold) {
return speed; return speed;
} }
return -1.0F; return -1.0F;
};
if (const auto speed = tryRead(F4T::ProxyAnimationSync::GraphVar::kSpeed); speed >= 0.0F) {
return speed;
}
return tryRead(F4T::ProxyAnimationSync::GraphVar::kSpeedSmoothed);
} }
PlayerTransform GetPlayerTransform(const RE::PlayerCharacter& a_player) PlayerTransform GetPlayerTransform(const RE::PlayerCharacter& a_player)
@@ -526,8 +533,13 @@ namespace
return; return;
} }
const auto animationGraphSpeed = auto animationGraphSpeed =
networkMovementState.isMoving ? TryReadLocalAnimationGraphSpeed(*player) : -1.0F; networkMovementState.isMoving ? TryReadLocalAnimationGraphSpeed(*player) : -1.0F;
if (networkMovementState.isMoving && animationGraphSpeed < kMovingSpeedThreshold) {
// Graph Speed is frequently 0.0 on movement frames; send movementSpeed so receivers
// can blend slow-walk/jog/run instead of snapping to idle speed.
animationGraphSpeed = networkMovementState.movementSpeed;
}
// Sender-side diagnostic: reveals, from whichever instance owns the log, // Sender-side diagnostic: reveals, from whichever instance owns the log,
// what this local player computes and transmits while moving. Throttled. // what this local player computes and transmits while moving. Throttled.