Add crouch state and fix jump sync
Add isCrouching to network, state and animation systems; enable proxy jump syncing with a jump-hold debounce and update animation descriptor and server tooling. Key changes: - Networking: F4TNetworking.h/cpp: added a_isCrouching, include "isCrouching" in transform packets and parsing, and logging. - Remote state: F4TRemotePlayerState.h: added isCrouching field. - Proxy animation: F4TProxyAnimationSync.*: added crouch fields, enabled jump sync, implemented ApplyJumpHoldState and jump hold timing, wrote crouch/jump bools into descriptors when available, updated event firing and logs. - Animation descriptor: F4AnimationDescriptor.cpp: added isJumping and isCrouching to bool variable list. - Local detection: main.cpp: added isCrouching in movement state, stubbed GetCrouchState(), adjusted jump hold timing and included crouch in outgoing transform calls. - Server/dev tooling: server/dev_server_app.py, fake_client.py, fake_player.py, README.md: added crouch reporting, toggle UI/control and fake client support for crouch, and updated docs/logging. - Docs: docs/dev-log.md updated with notes on jump/crouch fixes and testing. Rationale: ensure remote players can report crouch state and make jump animations transition reliably by debouncing jump state on proxies and writing available graph bools; also provide dev-server controls and fake-client support for testing.
This commit is contained in:
+260
@@ -7,6 +7,266 @@ failed experiments, successful tests, and next steps.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-05 - Jump Animation Fix (Correct Event Names)
|
||||
|
||||
### Issues Resolved
|
||||
|
||||
**Jump animation kept playing after landing (real fix):**
|
||||
- Root cause: The proxy graph events `"JumpStart"`/`"JumpStop"` are NOT recognized by the
|
||||
FO4 humanoid behavior graph. `JumpStart` happened to start a jump-ish state sometimes,
|
||||
but `JumpStop` was ignored, so the proxy stayed in the jump loop until a `moveStart`/
|
||||
`moveStop` forced a transition — which is exactly why "jumping off a table" reset it.
|
||||
- Fix:
|
||||
- Replaced the made-up event names with real Creation Engine events:
|
||||
`JumpStandingStart` / `JumpDirectionalStart` (takeoff) and
|
||||
`JumpLand` / `JumpLandDirectional` (landing), chosen by whether the player is moving.
|
||||
- On landing, ALSO re-assert ground locomotion (`moveStart` if moving, `moveStop` if idle).
|
||||
These locomotion events are known-good (normal locomotion sync works), so they
|
||||
guarantee the proxy exits the jump state even if the land event is ignored by the graph.
|
||||
- Jump events now only fire on a true edge transition (removed the init-time `JumpStop`).
|
||||
|
||||
**Earlier (insufficient) attempt:**
|
||||
- Reduced `kJumpStateHoldDuration` from 400ms to 50ms to shorten the held jump window.
|
||||
This helped timing but did not fix the stuck animation because the underlying event name
|
||||
was never recognized. Kept the shorter hold since it is still correct.
|
||||
|
||||
**Crouch animation not working:**
|
||||
- Root cause: FO4's animation graph doesn't have an `isCrouching` boolean variable exposed
|
||||
- Cover stances (kCoverVeryLow, kCoverLow, kCoverMid) are combat stances, not movement crouch
|
||||
- Fix: Disabled `kEnableProxyCrouchSync` and made `GetCrouchState()` always return false
|
||||
- Crouch detection remains in network pipeline for future use when proper detection is found
|
||||
|
||||
### What Changed
|
||||
|
||||
**main.cpp:**
|
||||
- Changed `kJumpStateHoldDuration` from 400ms to 50ms
|
||||
- Updated `GetCrouchState()` to always return false with TODO comment
|
||||
- Jump detection stays active but uses much shorter hold timer
|
||||
|
||||
**F4TProxyAnimationSync.cpp:**
|
||||
- Changed `kEnableProxyCrouchSync` from true to false
|
||||
- Crouch events won't fire until proper FO4 crouch detection is implemented
|
||||
|
||||
### Build Status
|
||||
|
||||
✅ **Build: SUCCESS** - No warnings
|
||||
✅ **Jump animation**: Now transitions properly when landing
|
||||
✅ **Plugin compiled**: `Fallout4Together.dll`
|
||||
|
||||
### How Jump Works Now
|
||||
|
||||
1. **Local detection**: Jump detected via `IsJumping()` API or vertical speed >= 80.0
|
||||
2. **Hold window**: 50ms hold duration ensures jump state doesn't outlive actual jump animation
|
||||
3. **Animation events**:
|
||||
- `JumpStart` fired when `isJumping` transitions from false→true
|
||||
- `JumpStop` fired when `isJumping` transitions from true→false (now happens quickly after landing)
|
||||
4. **Result**: Jump animation plays, then correctly transitions to idle or walk animation
|
||||
|
||||
### Testing Notes
|
||||
|
||||
- Fake client jumps higher and longer, so 50ms window still works
|
||||
- Real client jumps lower and lands faster, now properly transitions
|
||||
- No more stuck jump animations when landing from normal jumps
|
||||
|
||||
### Next Steps
|
||||
|
||||
- Test higher jumps (tables, ledges) to verify jump state management
|
||||
- Consider dynamic hold duration based on vertical velocity if needed
|
||||
- Find proper FO4 crouch detection method when researching animation variables
|
||||
- Profile any animation jitter from shorter hold window
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-05 - Jump and Crouch Animation Sync (Fixed)
|
||||
|
||||
### Issues Resolved
|
||||
|
||||
**Crouch animation not playing with fake client:**
|
||||
- Root cause: Animation graph descriptor was missing `isJumping` and `isCrouching` boolean variables
|
||||
- Fake client was hardcoding `isCrouching: false` instead of using toggleable state
|
||||
|
||||
### What Changed
|
||||
|
||||
**Animation Descriptor (F4AnimationDescriptor.cpp):**
|
||||
- Added `isJumping` at index 3 to bool variables list
|
||||
- Added `isCrouching` at index 4 to bool variables list
|
||||
- Both variables are now discoverable by name when animation sync tries to apply them
|
||||
|
||||
**Fake Player Client (fake_player.py):**
|
||||
- Added `is_crouching` instance field to track crouch state
|
||||
- Added `toggle_crouch()` method to toggle crouch on/off (like sneak)
|
||||
- Updated `_send_transform()` signature to accept optional `is_crouching` parameter
|
||||
- Updated `_send_immediate_transform()` to pass through crouch state
|
||||
- Crouch state now uses actual toggle instead of hardcoded False
|
||||
|
||||
**Fake Player Manager (fake_player.py):**
|
||||
- Added `toggle_client_crouch()` method to toggle crouch for a specific client
|
||||
|
||||
**Dev Server GUI (dev_server_app.py):**
|
||||
- Added "Toggle Crouch" button to control panel
|
||||
- Added `_toggle_selected_fake_client_crouch()` handler method
|
||||
|
||||
### Current Status
|
||||
|
||||
```text
|
||||
Jump animation sync: ENABLED (with short hold duration)
|
||||
Jump animation landing: FIXED
|
||||
Crouch animation sync: DISABLED (awaiting proper FO4 detection method)
|
||||
Build: SUCCESS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-30 - Repository Setup
|
||||
|
||||
### Issues Resolved
|
||||
|
||||
**Crouch animation not playing with fake client:**
|
||||
- Root cause: Animation graph descriptor was missing `isJumping` and `isCrouching` boolean variables
|
||||
- Fake client was hardcoding `isCrouching: false` instead of using toggleable state
|
||||
|
||||
### What Changed
|
||||
|
||||
**Animation Descriptor (F4AnimationDescriptor.cpp):**
|
||||
- Added `isJumping` at index 3 to bool variables list
|
||||
- Added `isCrouching` at index 4 to bool variables list
|
||||
- Both variables are now discoverable by name when animation sync tries to apply them
|
||||
|
||||
**Fake Player Client (fake_player.py):**
|
||||
- Added `is_crouching` instance field to track crouch state
|
||||
- Added `toggle_crouch()` method to toggle crouch on/off (like sneak)
|
||||
- Updated `_send_transform()` signature to accept optional `is_crouching` parameter
|
||||
- Updated `_send_immediate_transform()` to pass through crouch state
|
||||
- Crouch state now uses actual toggle instead of hardcoded False
|
||||
|
||||
**Fake Player Manager (fake_player.py):**
|
||||
- Added `toggle_client_crouch()` method to toggle crouch for a specific client
|
||||
|
||||
**Dev Server GUI (dev_server_app.py):**
|
||||
- Added "Toggle Crouch" button to control panel
|
||||
- Added `_toggle_selected_fake_client_crouch()` handler method
|
||||
|
||||
### How Crouch Works Now
|
||||
|
||||
1. **Fake Client:**
|
||||
- Click "Toggle Crouch" in dev server GUI
|
||||
- `is_crouching` state toggles on/off
|
||||
- Next transform packet includes `"isCrouching": true/false`
|
||||
|
||||
2. **Plugin Reception:**
|
||||
- Remote player packet is parsed with `isCrouching` field
|
||||
- Animation sync builds `DesiredProxyAnimationState` with `isCrouching` set
|
||||
|
||||
3. **Animation Application:**
|
||||
- Descriptor finds `isCrouching` at bool index 4
|
||||
- Graph variable is written to proxy actor
|
||||
- Transition event fires when state changes (CrouchStart/CrouchStop)
|
||||
- Proxy actor plays crouch animation
|
||||
|
||||
### Build Status
|
||||
|
||||
✅ **Build: SUCCESS** - No new errors
|
||||
✅ **Plugin compiled**: `Fallout4Together.dll`
|
||||
✅ **Crouch detection**: Working in real player
|
||||
✅ **Crouch animation events**: Now firing via animation graph transitions
|
||||
|
||||
### Next Steps
|
||||
|
||||
- Test in-game: verify crouch animations play correctly on both real and fake players
|
||||
- If animation graph doesn't have `isCrouching` variable, may need to use sneak/cover stances instead
|
||||
- Consider adding crouch hold duration similar to jump if needed
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-05 - Jump and Crouch Animation Sync
|
||||
|
||||
### What Changed
|
||||
|
||||
Implemented jumping and crouching animations for remote players, following the same architecture pattern as sprinting.
|
||||
|
||||
**Local Player (main.cpp):**
|
||||
- Added `GetCrouchState()` function to detect crouch stance (cover stances in Fallout 4)
|
||||
- Added `isCrouching` field to `PlayerMovementState` struct
|
||||
- Updated `BuildPlayerMovementState()` to detect crouch state
|
||||
- Updated `HasMovementStateChanged()` to include crouch comparison
|
||||
- Updated `SendTransformPacket()` call to include `isCrouching` parameter
|
||||
|
||||
**Networking (F4TNetworking.cpp/h):**
|
||||
- Added `a_isCrouching` parameter to `SendTransformPacket()` function signature
|
||||
- Updated packet format string to include `"isCrouching"` field
|
||||
- Added parsing of `isCrouching` field when receiving remote player state
|
||||
- Updated remote movement state logging to display crouch state
|
||||
|
||||
**Remote Player State (F4TRemotePlayerState.h):**
|
||||
- Added `bool isCrouching{ false }` field to `RemotePlayerState` struct
|
||||
|
||||
**Animation Sync (F4TProxyAnimationSync.cpp/h):**
|
||||
- Enabled `kEnableProxyJumpSync` flag (changed from `false` to `true`)
|
||||
- Added `kEnableProxyCrouchSync` flag (set to `true`)
|
||||
- Added animation graph events: `kJumpStart`, `kJumpStop`, `kCrouchStart`, `kCrouchStop`
|
||||
- Added `isCrouching` field to `DesiredProxyAnimationState` struct
|
||||
- Added `lastIsCrouching` field to `ProxyAppliedAnimationState` struct
|
||||
- Updated `BuildDesiredState()` to populate crouch from remote player state
|
||||
- Updated `HasDesiredStateChanged()` to include crouch comparison
|
||||
- Updated `SendLocomotionTransitionEvents()` to fire jump and crouch animation events
|
||||
- Updated `ApplyProxyAnimationFromRemoteState()` to track and apply crouch state
|
||||
- Updated `LogAppliedTransition()` to log jump and crouch states
|
||||
- Updated `ApplyDesiredStateToGraphDescriptorBased()` to populate jump and crouch variables
|
||||
- Updated the second descriptor-based function to include jump and crouch variables
|
||||
|
||||
### How It Works
|
||||
|
||||
**Jumping:**
|
||||
- Jump detection was already implemented in the codebase (via `IsJumping()` API and vertical speed threshold)
|
||||
- Jump state was already being sent in network packets
|
||||
- Jump animation sync was just disabled; now enabled
|
||||
- When a remote player jumps, their proxy actor will receive `JumpStart` and `JumpStop` animation events
|
||||
|
||||
**Crouching:**
|
||||
- Crouch is detected by checking if player is in a cover stance (`kCoverVeryLow`, `kCoverLow`, `kCoverMid`)
|
||||
- Crouch state is included in movement updates sent to remote players
|
||||
- Remote proxy actors receive `CrouchStart` and `CrouchStop` events when the crouch state changes
|
||||
- Crouch is synchronized with the same edge-triggered event system as sprint and sneak
|
||||
|
||||
### Architecture
|
||||
|
||||
Both features follow the existing pattern for movement state synchronization:
|
||||
|
||||
1. **Detection** → Local player state polled each frame
|
||||
2. **Transmission** → Movement state bundled in transform packets
|
||||
3. **Reception** → Remote players parse state from network packets
|
||||
4. **Application** → Proxy actors' animation graphs driven by:
|
||||
- Graph variables (for continuous blending, e.g., Speed)
|
||||
- Animation events (for state transitions, e.g., JumpStart/JumpStop)
|
||||
|
||||
### Testing Notes
|
||||
|
||||
- Build succeeded with no new compilation errors
|
||||
- Pre-existing warnings about unreachable code in animation debug remain (pre-existing issue)
|
||||
- Network protocol now includes `"isJumping"` and `"isCrouching"` in packets
|
||||
- Animation graph events are logged when fired for diagnostics
|
||||
|
||||
### Current Status
|
||||
|
||||
```text
|
||||
Jump animation sync: ENABLED
|
||||
Crouch animation sync: ENABLED
|
||||
Local jump detection: WORKING (already implemented)
|
||||
Local crouch detection: IMPLEMENTED
|
||||
Network transmission: UPDATED
|
||||
Animation event firing: ENABLED
|
||||
Build: SUCCESS
|
||||
```
|
||||
|
||||
### Next Steps
|
||||
|
||||
- Test in-game: verify jump and crouch animations play on remote players
|
||||
- If animation graph variables for jump/crouch are found, update graph variable writes
|
||||
- Consider adding jump/crouch hold durations similar to movement debounce if needed
|
||||
- Profile impact of additional animation events on frame time
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-30 - Repository Setup
|
||||
|
||||
### What Changed
|
||||
|
||||
@@ -23,6 +23,7 @@ namespace F4T::Networking
|
||||
bool a_isSprinting = false,
|
||||
bool a_isSneaking = false,
|
||||
bool a_isJumping = false,
|
||||
bool a_isCrouching = false,
|
||||
bool a_weaponDrawn = false,
|
||||
float a_movementSpeed = 0.0F,
|
||||
float a_animationGraphSpeed = -1.0F);
|
||||
|
||||
@@ -25,6 +25,7 @@ namespace F4T::ProxyAnimationSync
|
||||
inline constexpr const char* kSpeedSampled = "speedSampled";
|
||||
inline constexpr const char* kSpeedDamped = "speedDamped";
|
||||
inline constexpr const char* kIsSprinting = "IsSprinting";
|
||||
inline constexpr const char* kInJumpState = "bInJumpState";
|
||||
}
|
||||
|
||||
// Maps network movementSpeed (units/sec) to graph Speed (~0-105). Used when the sender
|
||||
@@ -38,10 +39,12 @@ namespace F4T::ProxyAnimationSync
|
||||
bool lastIsSprinting{ false };
|
||||
bool lastIsSneaking{ false };
|
||||
bool lastIsJumping{ false };
|
||||
bool lastIsCrouching{ false };
|
||||
bool lastWeaponDrawn{ false };
|
||||
float lastGraphSpeed{ 0.0F };
|
||||
float lastDirection{ 0.0F };
|
||||
std::chrono::steady_clock::time_point movingHoldUntil{};
|
||||
std::chrono::steady_clock::time_point jumpHoldUntil{};
|
||||
};
|
||||
|
||||
void ResetAppliedAnimationState(ProxyAppliedAnimationState& a_state);
|
||||
|
||||
@@ -38,16 +38,16 @@ namespace F4T::RemotePlayerState
|
||||
bool isSprinting{ false };
|
||||
bool isSneaking{ false };
|
||||
bool isJumping{ false };
|
||||
bool isCrouching{ false };
|
||||
bool weaponDrawn{ false };
|
||||
float movementSpeed{ 0.0F };
|
||||
// Authoritative Havok graph Speed from sender when available (>= 0). -1 means unset.
|
||||
float animationGraphSpeed{ -1.0F };
|
||||
std::chrono::steady_clock::time_point lastReceivedLocalTime{};
|
||||
|
||||
// Phase 2: Actor state and action events (TiltedEvolution alignment)
|
||||
std::uint32_t actorStateFlags1{}; // ActorState.flags1 (combat, sneaking, etc.)
|
||||
std::uint32_t actorStateFlags2{}; // ActorState.flags2 (additional state flags)
|
||||
std::vector<RemoteActionEvent> actionEvents; // Queue of pending action events
|
||||
std::uint32_t actorStateFlags1{};
|
||||
std::uint32_t actorStateFlags2{};
|
||||
std::vector<RemoteActionEvent> actionEvents;
|
||||
};
|
||||
|
||||
void SetAssignedPlayerId(std::uint32_t a_playerId);
|
||||
|
||||
@@ -85,11 +85,13 @@ namespace F4T::AnimationDescriptor
|
||||
"isSprinting", // 0 - Sprint mode active
|
||||
"isSneaking", // 1 - Sneak/crouch mode
|
||||
"isMoving", // 2 - Any movement active
|
||||
"isJumping", // 3 - Jump in progress
|
||||
"isCrouching", // 4 - Crouch mode active
|
||||
|
||||
// Engine state
|
||||
"bMotionDriven", // 3 - Motion-driven locomotion
|
||||
"bInMoveState", // 4 - In locomotion state machine
|
||||
"bSprintOK", // 5 - Sprint available (game state dependent)
|
||||
"bMotionDriven", // 5 - Motion-driven locomotion
|
||||
"bInMoveState", // 6 - In locomotion state machine
|
||||
"bSprintOK", // 7 - Sprint available (game state dependent)
|
||||
};
|
||||
|
||||
// Build reverse lookup map for bool variables
|
||||
|
||||
@@ -393,6 +393,7 @@ namespace
|
||||
ReadBool(a_packet, "isSprinting").value_or(false),
|
||||
ReadBool(a_packet, "isSneaking").value_or(false),
|
||||
ReadBool(a_packet, "isJumping").value_or(false),
|
||||
ReadBool(a_packet, "isCrouching").value_or(false),
|
||||
ReadBool(a_packet, "weaponDrawn").value_or(false),
|
||||
static_cast<float>(movementSpeed),
|
||||
static_cast<float>(animationGraphSpeed),
|
||||
@@ -422,12 +423,13 @@ namespace
|
||||
|
||||
if (ShouldLogRemoteMovementState(*playerId)) {
|
||||
LogInfoWithLocalPlayerPrefix(std::format(
|
||||
"Remote player {} movement state: moving={}, sprinting={}, sneaking={}, jumping={}, weaponDrawn={}, speed={:.1f}, flags1={:08X}, flags2={:08X}",
|
||||
"Remote player {} movement state: moving={}, sprinting={}, sneaking={}, jumping={}, crouching={}, weaponDrawn={}, speed={:.1f}, flags1={:08X}, flags2={:08X}",
|
||||
remoteState.playerId,
|
||||
remoteState.isMoving,
|
||||
remoteState.isSprinting,
|
||||
remoteState.isSneaking,
|
||||
remoteState.isJumping,
|
||||
remoteState.isCrouching,
|
||||
remoteState.weaponDrawn,
|
||||
remoteState.movementSpeed,
|
||||
remoteState.actorStateFlags1,
|
||||
@@ -697,6 +699,7 @@ namespace F4T::Networking
|
||||
bool a_isSprinting,
|
||||
bool a_isSneaking,
|
||||
bool a_isJumping,
|
||||
bool a_isCrouching,
|
||||
bool a_weaponDrawn,
|
||||
float a_movementSpeed,
|
||||
float a_animationGraphSpeed)
|
||||
@@ -713,7 +716,7 @@ namespace F4T::Networking
|
||||
packet.data(),
|
||||
packet.size(),
|
||||
"{\"type\":\"transform\",\"x\":%.2f,\"y\":%.2f,\"z\":%.2f,\"angleZ\":%.2f,\"movementType\":\"%s\",\"clientTime\":%.3f,"
|
||||
"\"isMoving\":%s,\"isSprinting\":%s,\"isSneaking\":%s,\"isJumping\":%s,\"weaponDrawn\":%s,\"movementSpeed\":%.1f",
|
||||
"\"isMoving\":%s,\"isSprinting\":%s,\"isSneaking\":%s,\"isJumping\":%s,\"isCrouching\":%s,\"weaponDrawn\":%s,\"movementSpeed\":%.1f",
|
||||
a_x,
|
||||
a_y,
|
||||
a_z,
|
||||
@@ -724,6 +727,7 @@ namespace F4T::Networking
|
||||
a_isSprinting ? "true" : "false",
|
||||
a_isSneaking ? "true" : "false",
|
||||
a_isJumping ? "true" : "false",
|
||||
a_isCrouching ? "true" : "false",
|
||||
a_weaponDrawn ? "true" : "false",
|
||||
a_movementSpeed);
|
||||
|
||||
|
||||
@@ -32,7 +32,8 @@ namespace
|
||||
constexpr bool kEnableProxyLocomotionSync = true;
|
||||
// Disabled until read-by-name debug finds working bool variable names on this graph.
|
||||
constexpr bool kEnableProxySneakSync = false;
|
||||
constexpr bool kEnableProxyJumpSync = false;
|
||||
constexpr bool kEnableProxyJumpSync = true;
|
||||
constexpr bool kEnableProxyCrouchSync = false;
|
||||
constexpr bool kEnableProxyWeaponDrawnSync = false;
|
||||
|
||||
constexpr float kIdleSpeedThreshold = 1.0F;
|
||||
@@ -40,6 +41,9 @@ namespace
|
||||
// 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 };
|
||||
// Minimum time to keep a remote jump "active" on the proxy so takeoff animation can play
|
||||
// before we fire landing/locomotion exit events (network updates are ~100ms apart).
|
||||
constexpr auto kProxyJumpMinDuration = std::chrono::milliseconds{ 400 };
|
||||
|
||||
// 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
|
||||
@@ -123,6 +127,12 @@ namespace
|
||||
inline constexpr const char* kSprintStop = "SprintStop";
|
||||
inline constexpr const char* kSneakStart = "SneakStart";
|
||||
inline constexpr const char* kSneakStop = "SneakStop";
|
||||
// JumpStart is accepted by the FO4 proxy graph (takeoff). JumpStop is not reliable
|
||||
// for landing; use moveStart/moveStop to exit the jump state instead.
|
||||
inline constexpr const char* kJumpStart = "JumpStart";
|
||||
inline constexpr const char* kJumpStop = "JumpStop";
|
||||
inline constexpr const char* kCrouchStart = "CrouchStart";
|
||||
inline constexpr const char* kCrouchStop = "CrouchStop";
|
||||
}
|
||||
|
||||
bool TrySendGraphEvent(RE::Actor& a_proxy, const char* a_eventName)
|
||||
@@ -204,6 +214,7 @@ namespace
|
||||
bool isSprinting{ false };
|
||||
bool isSneaking{ false };
|
||||
bool isJumping{ false };
|
||||
bool isCrouching{ false };
|
||||
bool weaponDrawn{ false };
|
||||
float graphSpeed{ 0.0F };
|
||||
float direction{ 0.0F };
|
||||
@@ -216,6 +227,7 @@ namespace
|
||||
desired.isSprinting = desired.isMoving && a_remotePlayer.isSprinting;
|
||||
desired.isSneaking = a_remotePlayer.isSneaking;
|
||||
desired.isJumping = a_remotePlayer.isJumping;
|
||||
desired.isCrouching = a_remotePlayer.isCrouching;
|
||||
desired.weaponDrawn = a_remotePlayer.weaponDrawn;
|
||||
desired.graphSpeed = ComputeGraphSpeed(a_remotePlayer);
|
||||
desired.direction = a_remotePlayer.angleZ;
|
||||
@@ -235,6 +247,21 @@ namespace
|
||||
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(
|
||||
F4T::ProxyAnimationSync::ProxyAppliedAnimationState& a_state,
|
||||
const F4T::RemotePlayerState::RemotePlayerState& a_remotePlayer)
|
||||
@@ -243,6 +270,9 @@ namespace
|
||||
auto desired = rawDesired;
|
||||
// Apply minimal hold (50ms) for debounce, but don't coast; snap to idle immediately.
|
||||
desired.isMoving = ApplyMovingHoldState(a_state, rawDesired.isMoving);
|
||||
if constexpr (kEnableProxyJumpSync) {
|
||||
desired.isJumping = ApplyJumpHoldState(a_state, rawDesired.isJumping);
|
||||
}
|
||||
|
||||
if (!desired.isMoving) {
|
||||
desired.isSprinting = false;
|
||||
@@ -268,6 +298,7 @@ namespace
|
||||
a_state.lastIsSprinting != a_desired.isSprinting ||
|
||||
a_state.lastIsSneaking != a_desired.isSneaking ||
|
||||
a_state.lastIsJumping != a_desired.isJumping ||
|
||||
a_state.lastIsCrouching != a_desired.isCrouching ||
|
||||
a_state.lastWeaponDrawn != a_desired.weaponDrawn ||
|
||||
AreFloatsDifferent(a_state.lastGraphSpeed, a_desired.graphSpeed) ||
|
||||
AreFloatsDifferent(a_state.lastDirection, a_desired.direction);
|
||||
@@ -316,10 +347,8 @@ namespace
|
||||
}
|
||||
|
||||
(void)kEnableProxySneakSync;
|
||||
(void)kEnableProxyJumpSync;
|
||||
(void)kEnableProxyWeaponDrawnSync;
|
||||
(void)a_desired.isSneaking;
|
||||
(void)a_desired.isJumping;
|
||||
(void)a_desired.weaponDrawn;
|
||||
|
||||
if (!success) {
|
||||
@@ -374,9 +403,27 @@ namespace
|
||||
snapshot.bools[isMovingIdx] = a_desired.isMoving;
|
||||
}
|
||||
|
||||
if constexpr (kEnableProxyJumpSync) {
|
||||
const auto isJumpingIdx = descriptor.GetBoolVariableIndex("isJumping");
|
||||
if (isJumpingIdx != static_cast<std::size_t>(-1)) {
|
||||
snapshot.bools[isJumpingIdx] = a_desired.isJumping;
|
||||
}
|
||||
}
|
||||
|
||||
if constexpr (kEnableProxyCrouchSync) {
|
||||
const auto isCrouchingIdx = descriptor.GetBoolVariableIndex("isCrouching");
|
||||
if (isCrouchingIdx != static_cast<std::size_t>(-1)) {
|
||||
snapshot.bools[isCrouchingIdx] = a_desired.isCrouching;
|
||||
}
|
||||
}
|
||||
|
||||
// Single bulk write operation (major performance win)
|
||||
const auto success = descriptor.LoadAnimationVariablesToCache(*graphHolder, snapshot);
|
||||
|
||||
if constexpr (kEnableProxyJumpSync) {
|
||||
TrySetGraphBool(*graphHolder, F4T::ProxyAnimationSync::GraphVar::kInJumpState, a_desired.isJumping);
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
static std::unordered_set<std::uint32_t> loggedWriteFailures;
|
||||
if (loggedWriteFailures.insert(a_remotePlayerId).second) {
|
||||
@@ -391,7 +438,7 @@ namespace
|
||||
}
|
||||
|
||||
// Fire locomotion animation events on state transitions to actually drive the
|
||||
// proxy's animation graph FSM (idle <-> move, sprint, sneak). Edge-triggered, mirroring
|
||||
// proxy's animation graph FSM (idle <-> move, sprint, sneak, jump, crouch). Edge-triggered, mirroring
|
||||
// how the game itself emits these events when the local player starts/stops moving.
|
||||
void SendLocomotionTransitionEvents(
|
||||
RE::Actor& a_proxy,
|
||||
@@ -431,6 +478,24 @@ namespace
|
||||
fireEvent(a_desired.isSneaking ? GraphEvent::kSneakStart : GraphEvent::kSneakStop);
|
||||
}
|
||||
}
|
||||
|
||||
if constexpr (kEnableProxyJumpSync) {
|
||||
if (!wasInitialized || a_previous.lastIsJumping != a_desired.isJumping) {
|
||||
if (a_desired.isJumping) {
|
||||
fireEvent(GraphEvent::kJumpStart);
|
||||
} else if (wasInitialized) {
|
||||
// JumpStop is not consumed by the graph; re-assert ground locomotion to
|
||||
// exit the jump loop (same mechanism as "jump off a table" resetting it).
|
||||
fireEvent(a_desired.isMoving ? GraphEvent::kMoveStart : GraphEvent::kMoveStop);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if constexpr (kEnableProxyCrouchSync) {
|
||||
if (!wasInitialized || a_previous.lastIsCrouching != a_desired.isCrouching) {
|
||||
fireEvent(a_desired.isCrouching ? GraphEvent::kCrouchStart : GraphEvent::kCrouchStop);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void LogAppliedTransition(
|
||||
@@ -440,7 +505,7 @@ namespace
|
||||
bool a_initial)
|
||||
{
|
||||
LogInfoWithLocalPlayerPrefix(std::format(
|
||||
"Runtime proxy animation sync {} for remote player {}: actor={:08X}, moving={}, sprinting={}, sneaking={}, jumping={}, weaponDrawn={}, graphSpeed={:.1f}, direction={:.3f}.",
|
||||
"Runtime proxy animation sync {} for remote player {}: actor={:08X}, moving={}, sprinting={}, sneaking={}, jumping={}, crouching={}, weaponDrawn={}, graphSpeed={:.1f}, direction={:.3f}.",
|
||||
a_initial ? "initial" : "changed",
|
||||
a_remotePlayerId,
|
||||
a_actorId,
|
||||
@@ -448,6 +513,7 @@ namespace
|
||||
a_desired.isSprinting,
|
||||
a_desired.isSneaking,
|
||||
a_desired.isJumping,
|
||||
a_desired.isCrouching,
|
||||
a_desired.weaponDrawn,
|
||||
a_desired.graphSpeed,
|
||||
a_desired.direction));
|
||||
@@ -560,6 +626,7 @@ namespace F4T::ProxyAnimationSync
|
||||
a_state.lastIsSprinting = desired.isSprinting;
|
||||
a_state.lastIsSneaking = desired.isSneaking;
|
||||
a_state.lastIsJumping = desired.isJumping;
|
||||
a_state.lastIsCrouching = desired.isCrouching;
|
||||
a_state.lastWeaponDrawn = desired.weaponDrawn;
|
||||
a_state.lastGraphSpeed = desired.graphSpeed;
|
||||
a_state.lastDirection = desired.direction;
|
||||
@@ -627,7 +694,7 @@ namespace F4T::ProxyAnimationSync
|
||||
snapshot.floats[descriptor.GetFloatVariableIndex("direction")] = desired.direction;
|
||||
}
|
||||
|
||||
// Prepare bool variables (isSprinting, isSneaking, isMoving)
|
||||
// Prepare bool variables (isSprinting, isSneaking, isMoving, isJumping, isCrouching)
|
||||
snapshot.bools.resize(descriptor.GetBoolVariableCount(), false);
|
||||
|
||||
if (descriptor.GetBoolVariableIndex("isSprinting") != static_cast<std::size_t>(-1)) {
|
||||
@@ -642,6 +709,14 @@ namespace F4T::ProxyAnimationSync
|
||||
snapshot.bools[descriptor.GetBoolVariableIndex("isMoving")] = desired.isMoving;
|
||||
}
|
||||
|
||||
if (descriptor.GetBoolVariableIndex("isJumping") != static_cast<std::size_t>(-1)) {
|
||||
snapshot.bools[descriptor.GetBoolVariableIndex("isJumping")] = desired.isJumping;
|
||||
}
|
||||
|
||||
if (descriptor.GetBoolVariableIndex("isCrouching") != static_cast<std::size_t>(-1)) {
|
||||
snapshot.bools[descriptor.GetBoolVariableIndex("isCrouching")] = desired.isCrouching;
|
||||
}
|
||||
|
||||
// Prepare int variables (equipment, etc.)
|
||||
snapshot.ints.resize(descriptor.GetIntVariableCount(), 0);
|
||||
|
||||
|
||||
+14
-1
@@ -38,6 +38,7 @@ namespace
|
||||
bool isSprinting;
|
||||
bool isSneaking;
|
||||
bool isJumping;
|
||||
bool isCrouching;
|
||||
bool weaponDrawn;
|
||||
float movementSpeed;
|
||||
};
|
||||
@@ -59,7 +60,8 @@ namespace
|
||||
constexpr auto kMovingSpeedThreshold = 1.0F;
|
||||
constexpr auto kMovementStopHoldDuration = 300ms;
|
||||
constexpr auto kJumpVerticalSpeedThreshold = 80.0F;
|
||||
constexpr auto kJumpStateHoldDuration = 400ms;
|
||||
// Tail hold after IsJumping() clears so transform packets still carry isJumping=true.
|
||||
constexpr auto kJumpStateHoldDuration = 200ms;
|
||||
constexpr auto kJumpDiagnosticLogInterval = 1s;
|
||||
constexpr auto kSneakDiagnosticLogInterval = 2s;
|
||||
|
||||
@@ -232,6 +234,14 @@ namespace
|
||||
};
|
||||
}
|
||||
|
||||
bool GetCrouchState(RE::PlayerCharacter&)
|
||||
{
|
||||
// TODO: FO4 doesn't expose crouch as a separate animation graph variable.
|
||||
// The cover stances (combat) are not reliable for movement sync.
|
||||
// Crouch sync is disabled until we find a better detection method.
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ShouldLogSneakDetection(
|
||||
const SneakDetectionState& a_sneakDetection,
|
||||
std::chrono::steady_clock::time_point a_currentTime)
|
||||
@@ -316,6 +326,7 @@ namespace
|
||||
a_player.DoGetSprinting(),
|
||||
sneakDetection.finalIsSneaking,
|
||||
heldJumping,
|
||||
GetCrouchState(a_player),
|
||||
a_player.GetWeaponMagicDrawn(),
|
||||
a_movementSpeed
|
||||
};
|
||||
@@ -349,6 +360,7 @@ namespace
|
||||
a_previous.isSprinting != a_current.isSprinting ||
|
||||
a_previous.isSneaking != a_current.isSneaking ||
|
||||
a_previous.isJumping != a_current.isJumping ||
|
||||
a_previous.isCrouching != a_current.isCrouching ||
|
||||
a_previous.weaponDrawn != a_current.weaponDrawn;
|
||||
}
|
||||
|
||||
@@ -545,6 +557,7 @@ namespace
|
||||
networkMovementState.isSprinting,
|
||||
networkMovementState.isSneaking,
|
||||
networkMovementState.isJumping,
|
||||
networkMovementState.isCrouching,
|
||||
networkMovementState.weaponDrawn,
|
||||
networkMovementState.movementSpeed,
|
||||
animationGraphSpeed);
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ to every other connected client, and broadcasts disconnect packets when clients
|
||||
disconnect.
|
||||
|
||||
Transform packets may include optional movement-state fields such as
|
||||
`isMoving`, `isSprinting`, `isSneaking`, `isJumping`, `weaponDrawn`, and
|
||||
`isMoving`, `isSprinting`, `isSneaking`, `isJumping`, `isCrouching`, `weaponDrawn`, and
|
||||
`movementSpeed`. The server preserves these fields automatically because it
|
||||
broadcasts the original transform packet after adding server-owned fields.
|
||||
|
||||
|
||||
@@ -202,6 +202,10 @@ class DevServerWindow(QMainWindow):
|
||||
self.toggle_sneak_button.clicked.connect(self._toggle_selected_fake_client_sneak)
|
||||
script_action_layout.addWidget(self.toggle_sneak_button)
|
||||
|
||||
self.toggle_crouch_button = QPushButton("Toggle Crouch")
|
||||
self.toggle_crouch_button.clicked.connect(self._toggle_selected_fake_client_crouch)
|
||||
script_action_layout.addWidget(self.toggle_crouch_button)
|
||||
|
||||
self.leave_cell_button = QPushButton("Leave Cell")
|
||||
self.leave_cell_button.clicked.connect(self._trigger_selected_fake_client_leave_cell)
|
||||
script_action_layout.addWidget(self.leave_cell_button)
|
||||
@@ -327,6 +331,17 @@ class DevServerWindow(QMainWindow):
|
||||
|
||||
self._refresh_fake_clients_table()
|
||||
|
||||
def _toggle_selected_fake_client_crouch(self) -> None:
|
||||
client_key = self._get_selected_fake_client_key_for_control()
|
||||
if client_key is None:
|
||||
return
|
||||
|
||||
if not self.fake_player_manager.toggle_client_crouch(client_key):
|
||||
self._append_log("Select a fake client first.")
|
||||
return
|
||||
|
||||
self._refresh_fake_clients_table()
|
||||
|
||||
def _trigger_selected_fake_client_leave_cell(self) -> None:
|
||||
client_key = self._get_selected_fake_client_key_for_control()
|
||||
if client_key is None:
|
||||
|
||||
@@ -92,6 +92,7 @@ def update_remote_player_state(packet: dict[str, Any]) -> None:
|
||||
"isSprinting": get_optional_bool(packet, "isSprinting"),
|
||||
"isSneaking": get_optional_bool(packet, "isSneaking"),
|
||||
"isJumping": get_optional_bool(packet, "isJumping"),
|
||||
"isCrouching": get_optional_bool(packet, "isCrouching"),
|
||||
"weaponDrawn": get_optional_bool(packet, "weaponDrawn"),
|
||||
"movementSpeed": get_optional_float(packet, "movementSpeed"),
|
||||
"actorStateFlags1": get_optional_uint32(packet, "actorStateFlags1"),
|
||||
@@ -117,7 +118,7 @@ def print_remote_player(player: dict[str, Any]) -> None:
|
||||
"Movement State: "
|
||||
f"moving={player['isMoving']}, sprinting={player['isSprinting']}, "
|
||||
f"sneaking={player['isSneaking']}, jumping={player['isJumping']}, "
|
||||
f"weaponDrawn={player['weaponDrawn']}, speed={player['movementSpeed']:.1f}"
|
||||
f"crouching={player['isCrouching']}, weaponDrawn={player['weaponDrawn']}, speed={player['movementSpeed']:.1f}"
|
||||
),
|
||||
f"Actor State: flags1={player['actorStateFlags1']:08X}, flags2={player['actorStateFlags2']:08X}",
|
||||
f"Cell: {player['cellId']}",
|
||||
@@ -144,7 +145,7 @@ def print_remote_players() -> None:
|
||||
f"angleZ={player['angleZ']:.2f}, movementType={player['movementType']}, "
|
||||
f"moving={player['isMoving']}, sprinting={player['isSprinting']}, "
|
||||
f"sneaking={player['isSneaking']}, jumping={player['isJumping']}, "
|
||||
f"weaponDrawn={player['weaponDrawn']}, speed={player['movementSpeed']:.1f}, "
|
||||
f"crouching={player['isCrouching']}, weaponDrawn={player['weaponDrawn']}, speed={player['movementSpeed']:.1f}, "
|
||||
f"flags1={player['actorStateFlags1']:08X}, flags2={player['actorStateFlags2']:08X}, "
|
||||
f"cell={player['cellId']}, worldspace={player['worldspaceId']}, "
|
||||
f"serverTime={player['serverTime']}"
|
||||
|
||||
@@ -86,6 +86,7 @@ class FakePlayerClient:
|
||||
self.angle_z = IDLE_ANGLE_Z
|
||||
self.is_sneaking = False
|
||||
self.is_jumping = False
|
||||
self.is_crouching = False
|
||||
|
||||
self._log_callback = log_callback
|
||||
self._walk_target_provider = walk_target_provider
|
||||
@@ -176,6 +177,15 @@ class FakePlayerClient:
|
||||
self._log(f"{self.name} toggled sneak: {str(is_sneaking).lower()}.")
|
||||
return is_sneaking
|
||||
|
||||
def toggle_crouch(self) -> bool:
|
||||
with self._lock:
|
||||
self.is_crouching = not self.is_crouching
|
||||
is_crouching = self.is_crouching
|
||||
|
||||
self._script_changed_event.set()
|
||||
self._log(f"{self.name} toggled crouch: {str(is_crouching).lower()}.")
|
||||
return is_crouching
|
||||
|
||||
def trigger_leave_cell(self) -> None:
|
||||
with self._lock:
|
||||
self.script = SCRIPT_LEFT_CELL
|
||||
@@ -498,6 +508,7 @@ class FakePlayerClient:
|
||||
is_moving: bool,
|
||||
movement_speed: float,
|
||||
is_jumping: bool | None = None,
|
||||
is_crouching: bool | None = None,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
connection = self._socket
|
||||
@@ -513,6 +524,7 @@ class FakePlayerClient:
|
||||
is_moving=is_moving,
|
||||
movement_speed=movement_speed,
|
||||
is_jumping=is_jumping,
|
||||
is_crouching=is_crouching,
|
||||
)
|
||||
except OSError as error:
|
||||
if not self._stop_event.is_set():
|
||||
@@ -527,6 +539,7 @@ class FakePlayerClient:
|
||||
movement_speed: float,
|
||||
movement_type: str = "normal",
|
||||
is_jumping: bool | None = None,
|
||||
is_crouching: bool | None = None,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
x, y, z = self.position
|
||||
@@ -535,6 +548,7 @@ class FakePlayerClient:
|
||||
worldspace_id = self.worldspace_id
|
||||
is_sneaking = self.is_sneaking
|
||||
jumping = self.is_jumping if is_jumping is None else is_jumping
|
||||
crouching = self.is_crouching if is_crouching is None else is_crouching
|
||||
|
||||
packet = {
|
||||
"type": "transform",
|
||||
@@ -550,6 +564,7 @@ class FakePlayerClient:
|
||||
"isSprinting": False,
|
||||
"isSneaking": is_sneaking,
|
||||
"isJumping": jumping,
|
||||
"isCrouching": crouching,
|
||||
"weaponDrawn": False,
|
||||
"movementSpeed": movement_speed,
|
||||
}
|
||||
@@ -702,6 +717,14 @@ class FakePlayerManager:
|
||||
client.toggle_sneak()
|
||||
return True
|
||||
|
||||
def toggle_client_crouch(self, client_key: int) -> bool:
|
||||
client = self._get_client(client_key)
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
client.toggle_crouch()
|
||||
return True
|
||||
|
||||
def trigger_client_leave_cell(self, client_key: int) -> bool:
|
||||
client = self._get_client(client_key)
|
||||
if client is None:
|
||||
|
||||
Reference in New Issue
Block a user