Fix proxy stop/land animation linger & snap

Prevent proxies from continuing jog/landing animations after a remote stops and fix snap desyncs. Treat network-authoritative idle (graphSpeed < 0) as definitive and stop falling back to measured lerp speed; pin/snap position when remote is not locomoting and ensure transforms snap on stop receive. Shorten sender-side hold times for movement and jump states to reduce stale motion in packets, and use isMoving (not stale graphSpeed) for landing resume decisions. Changes in plugin sources and main config, plus an updated dev log entry.
This commit is contained in:
2026-06-05 16:12:34 +12:00
parent 9412fcf8ea
commit c4a7b6dedb
5 changed files with 69 additions and 13 deletions
+36
View File
@@ -5107,3 +5107,39 @@ won't change cadence and we move to the velocity-driven movement rework. Next te
### Build Status ### Build Status
✅ **SUCCESS** - `xmake build` clean; DLL deployed to game F4SE/Plugins. ✅ **SUCCESS** - `xmake build` clean; DLL deployed to game F4SE/Plugins.
---
## 2026-06-05 — Proxy stop/land animation linger and snap desync
### Symptoms
- Jog (and other locomotion) kept playing ~1s after the remote stopped moving.
- Jump pose lingered after landing.
- On stop or small corrections, proxy snapped to server position but legs kept playing forward locomotion.
### Root Cause
`ApplyProxyLocomotionFrame` treated `puppetGraphSpeed = -1` (idle) as "use measured horizontal
speed from lerp/Move()". While the proxy still converged toward `targetPosition` after a stop
packet, measured speed stayed above the locomotion threshold and re-fed jog cadence via the
puppet hook even though animation sync had already requested idle.
Sender-side holds (`kMovementStopHoldDuration` 300ms, `kJumpStateHoldDuration` 400ms) also kept
`isMoving`/`isJumping` true in packets briefly after the actual stop/land.
### Fix
1. **Puppet idle is authoritative** — `ApplyProxyLocomotionFrame` no longer falls back to measured
speed when `a_graphSpeed < 0`; it zeros controller velocity and skips motion feedback.
2. **Decouple position from animation on stop** — `ApplySmoothFrameMovement` snaps to
`targetPosition` when the remote is not locomoting (no lerp glide). Same path when already at
target.
3. **Snap on stop receive** — `MoveSlotProxyToRemotePlayer` snaps transform when
`!isMoving || movementSpeed < 1`.
4. **Shorter sender holds** — movement stop 300→100ms, jump 150ms (was 400ms).
5. **Jump land tier** — landing locomotion resume uses `isMoving` only, not stale `graphSpeed`.
### Build Status
✅ **SUCCESS** — `xmake build` in `plugin/`.
+17 -4
View File
@@ -4992,7 +4992,10 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
a_slot.puppetGraphSpeed, a_slot.puppetGraphSpeed,
a_slot.puppetDirection); a_slot.puppetDirection);
const auto shouldSnap = ShouldSnapRemoteMovement(a_remotePlayer.movementType); const bool remoteLocomoting =
a_remotePlayer.isMoving && a_remotePlayer.movementSpeed >= 1.0F;
const auto shouldSnap =
ShouldSnapRemoteMovement(a_remotePlayer.movementType) || !remoteLocomoting;
ApplyRuntimeProxyTransform(a_proxy, targetPosition, a_remotePlayer.angleZ, a_remotePlayer, shouldSnap, &a_slot); ApplyRuntimeProxyTransform(a_proxy, targetPosition, a_remotePlayer.angleZ, a_remotePlayer, shouldSnap, &a_slot);
// Phase 4.3: Apply actor state flags before animation sync // Phase 4.3: Apply actor state flags before animation sync
@@ -5400,10 +5403,20 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
slot.lastSmoothMoveTime = frameNow; slot.lastSmoothMoveTime = frameNow;
frameDeltaSeconds = std::clamp(frameDeltaSeconds, 1.0F / 120.0F, 0.1F); frameDeltaSeconds = std::clamp(frameDeltaSeconds, 1.0F / 120.0F, 0.1F);
// Already at target: pin exactly and stop. This prevents the actor from const bool remoteLocomoting =
// drifting/wandering around a fixed target while the remote is idle. slot.lastRemotePlayerSnapshot &&
if (distanceToTarget < 0.1F) { slot.lastRemotePlayerSnapshot->isMoving &&
slot.lastRemotePlayerSnapshot->movementSpeed >= 1.0F;
// Animation follows network state, not interpolation remainder. When the remote
// stops, pin position immediately so we don't jog-in-place while lerping.
if (!remoteLocomoting || distanceToTarget < 0.1F) {
proxy->SetPosition(slot.targetPosition, true); proxy->SetPosition(slot.targetPosition, true);
slot.puppetGraphSpeed = -1.0F;
F4T::ProxyPuppet::SetProxyLocomotionState(
proxy->GetFormID(),
-1.0F,
slot.puppetDirection);
F4T::ProxyPuppet::ApplyProxyLocomotionFrame( F4T::ProxyPuppet::ApplyProxyLocomotionFrame(
*proxy, *proxy,
RE::NiPoint3{}, RE::NiPoint3{},
+1 -2
View File
@@ -536,8 +536,7 @@ namespace
const auto landTier = a_previous.lastLocomotionTier.value_or(currentTier); const auto landTier = a_previous.lastLocomotionTier.value_or(currentTier);
fireEvent(GraphEvent::kJumpDown); fireEvent(GraphEvent::kJumpDown);
fireEvent(GraphEvent::kJumpLand); fireEvent(GraphEvent::kJumpLand);
const auto remoteMoving = const auto remoteMoving = a_desired.isMoving;
a_desired.isMoving || a_desired.graphSpeed >= kIdleSpeedThreshold;
if (remoteMoving) { if (remoteMoving) {
if (landTier == F4T::ProxyAnimationSync::LocomotionTier::kRun) { if (landTier == F4T::ProxyAnimationSync::LocomotionTier::kRun) {
fireEvent(GraphEvent::kJumpLandToRun); fireEvent(GraphEvent::kJumpLandToRun);
+10 -3
View File
@@ -259,9 +259,16 @@ namespace F4T::ProxyPuppet
velocity.z = a_worldDelta.z / safeDelta; velocity.z = a_worldDelta.z / safeDelta;
} }
const float horizontalSpeed = std::sqrt((velocity.x * velocity.x) + (velocity.y * velocity.y)); // -1 means network-authoritative idle. Do not fall back to measured lerp speed:
const float feedbackSpeed = // convergence Move() deltas were re-driving jog cadence for ~1s after remote stop.
(a_graphSpeed >= 0.0F && std::isfinite(a_graphSpeed)) ? a_graphSpeed : horizontalSpeed; if (a_graphSpeed < 0.0F || !std::isfinite(a_graphSpeed)) {
const std::unique_lock lock{ g_proxyFormIdMutex };
g_proxyLocomotion[a_actor.GetFormID()] = ProxyLocomotionState{ -1.0F, a_direction, {}, {} };
SyncControllerMotionFields(a_actor, {}, 0.0F);
return;
}
const float feedbackSpeed = a_graphSpeed;
if (feedbackSpeed < 8.0F) { if (feedbackSpeed < 8.0F) {
const std::unique_lock lock{ g_proxyFormIdMutex }; const std::unique_lock lock{ g_proxyFormIdMutex };
+4 -3
View File
@@ -58,10 +58,11 @@ namespace
constexpr auto kTransformSendRotationThreshold = 0.02F; constexpr auto kTransformSendRotationThreshold = 0.02F;
constexpr auto kTeleportDistanceThreshold = 5000.0F; constexpr auto kTeleportDistanceThreshold = 5000.0F;
constexpr auto kMovingSpeedThreshold = 1.0F; constexpr auto kMovingSpeedThreshold = 1.0F;
constexpr auto kMovementStopHoldDuration = 300ms; // Short tail so stop packets still coalesce without keeping jog anim ~1s after halt.
constexpr auto kMovementStopHoldDuration = 100ms;
constexpr auto kJumpVerticalSpeedThreshold = 80.0F; constexpr auto kJumpVerticalSpeedThreshold = 80.0F;
// Tail hold after IsJumping() clears so transform packets still carry isJumping=true. // Brief tail after IsJumping() clears so landing packets are not missed.
constexpr auto kJumpStateHoldDuration = 400ms; constexpr auto kJumpStateHoldDuration = 150ms;
constexpr auto kJumpDiagnosticLogInterval = 1s; constexpr auto kJumpDiagnosticLogInterval = 1s;
constexpr auto kSneakDiagnosticLogInterval = 2s; constexpr auto kSneakDiagnosticLogInterval = 2s;