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:
2026-06-05 14:22:17 +12:00
parent 7f1aa11281
commit 5ba440ab5d
12 changed files with 464 additions and 67 deletions
+13 -11
View File
@@ -79,18 +79,20 @@ namespace F4T::AnimationDescriptor
m_floatVariableIndexMap[m_floatVariables[i]] = i;
}
// ===== BOOL VARIABLES (State Flags) =====
m_boolVariables = {
// Movement state
"isSprinting", // 0 - Sprint mode active
"isSneaking", // 1 - Sneak/crouch mode
"isMoving", // 2 - Any movement active
// ===== BOOL VARIABLES (State Flags) =====
m_boolVariables = {
// Movement state
"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)
};
// Engine state
"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
for (std::size_t i = 0; i < m_boolVariables.size(); ++i) {
+6 -2
View File
@@ -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);
+111 -36
View File
@@ -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) {
@@ -358,25 +387,43 @@ namespace
snapshot.floats[directionIdx] = a_desired.direction;
}
// Populate bool variables
const auto isSprintingIdx = descriptor.GetBoolVariableIndex("isSprinting");
if (isSprintingIdx != static_cast<std::size_t>(-1)) {
snapshot.bools[isSprintingIdx] = a_desired.isSprinting;
}
// Populate bool variables
const auto isSprintingIdx = descriptor.GetBoolVariableIndex("isSprinting");
if (isSprintingIdx != static_cast<std::size_t>(-1)) {
snapshot.bools[isSprintingIdx] = a_desired.isSprinting;
}
const auto isSneakingIdx = descriptor.GetBoolVariableIndex("isSneaking");
if (isSneakingIdx != static_cast<std::size_t>(-1)) {
snapshot.bools[isSneakingIdx] = a_desired.isSneaking;
}
const auto isSneakingIdx = descriptor.GetBoolVariableIndex("isSneaking");
if (isSneakingIdx != static_cast<std::size_t>(-1)) {
snapshot.bools[isSneakingIdx] = a_desired.isSneaking;
}
const auto isMovingIdx = descriptor.GetBoolVariableIndex("isMoving");
if (isMovingIdx != static_cast<std::size_t>(-1)) {
snapshot.bools[isMovingIdx] = a_desired.isMoving;
const auto isMovingIdx = descriptor.GetBoolVariableIndex("isMoving");
if (isMovingIdx != static_cast<std::size_t>(-1)) {
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));
@@ -555,14 +621,15 @@ namespace F4T::ProxyAnimationSync
LogAppliedTransition(a_remotePlayer.playerId, actorId, desired, initial);
}
a_state.hasAppliedGraphState = true;
a_state.lastIsMoving = desired.isMoving;
a_state.lastIsSprinting = desired.isSprinting;
a_state.lastIsSneaking = desired.isSneaking;
a_state.lastIsJumping = desired.isJumping;
a_state.lastWeaponDrawn = desired.weaponDrawn;
a_state.lastGraphSpeed = desired.graphSpeed;
a_state.lastDirection = desired.direction;
a_state.hasAppliedGraphState = true;
a_state.lastIsMoving = desired.isMoving;
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,20 +694,28 @@ namespace F4T::ProxyAnimationSync
snapshot.floats[descriptor.GetFloatVariableIndex("direction")] = desired.direction;
}
// Prepare bool variables (isSprinting, isSneaking, isMoving)
snapshot.bools.resize(descriptor.GetBoolVariableCount(), false);
// Prepare bool variables (isSprinting, isSneaking, isMoving, isJumping, isCrouching)
snapshot.bools.resize(descriptor.GetBoolVariableCount(), false);
if (descriptor.GetBoolVariableIndex("isSprinting") != static_cast<std::size_t>(-1)) {
snapshot.bools[descriptor.GetBoolVariableIndex("isSprinting")] = desired.isSprinting;
}
if (descriptor.GetBoolVariableIndex("isSprinting") != static_cast<std::size_t>(-1)) {
snapshot.bools[descriptor.GetBoolVariableIndex("isSprinting")] = desired.isSprinting;
}
if (descriptor.GetBoolVariableIndex("isSneaking") != static_cast<std::size_t>(-1)) {
snapshot.bools[descriptor.GetBoolVariableIndex("isSneaking")] = desired.isSneaking;
}
if (descriptor.GetBoolVariableIndex("isSneaking") != static_cast<std::size_t>(-1)) {
snapshot.bools[descriptor.GetBoolVariableIndex("isSneaking")] = desired.isSneaking;
}
if (descriptor.GetBoolVariableIndex("isMoving") != static_cast<std::size_t>(-1)) {
snapshot.bools[descriptor.GetBoolVariableIndex("isMoving")] = desired.isMoving;
}
if (descriptor.GetBoolVariableIndex("isMoving") != static_cast<std::size_t>(-1)) {
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);
+24 -11
View File
@@ -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;
}
@@ -538,16 +550,17 @@ namespace
currentTransform.angleZ,
movementType,
currentLocation.hasCellId,
currentLocation.cellId,
currentLocation.hasWorldspaceId,
currentLocation.worldspaceId,
networkMovementState.isMoving,
networkMovementState.isSprinting,
networkMovementState.isSneaking,
networkMovementState.isJumping,
networkMovementState.weaponDrawn,
networkMovementState.movementSpeed,
animationGraphSpeed);
currentLocation.cellId,
currentLocation.hasWorldspaceId,
currentLocation.worldspaceId,
networkMovementState.isMoving,
networkMovementState.isSprinting,
networkMovementState.isSneaking,
networkMovementState.isJumping,
networkMovementState.isCrouching,
networkMovementState.weaponDrawn,
networkMovementState.movementSpeed,
animationGraphSpeed);
if (sentTransform) {
lastSentTransform = currentTransform;
lastKnownLocation = currentLocation;