Log local sneak detection and proxy sneak observer

Add diagnostics and a log-only proxy observation for sneak state changes.

- docs/dev-log.md: Add entries documenting proxy sneak observation and local sneak detection diagnostics, scope notes, and next steps.
- plugin/src/main.cpp: Introduce SneakDetectionState, GetSneakDetectionState, and ShouldLogSneakDetection to combine API and actor-state signals; throttle diagnostic logs; use the combined finalIsSneaking in player movement state decisions; add include for ACTOR_STANCE.
- plugin/src/F4TProxyActorController.cpp: Add controller-local tracking for the represented remote player sneak state, reset helpers, and ApplyRemoteMovementStateToProxy which logs sneak transitions for the represented remote player (no visual crouch applied yet).

Notes: This change is log-only — it does not force animations or ActorState writes. Networking protocol and receive-thread behavior remain unchanged. A TODO is left to safely apply visual crouch once a supported API is confirmed.
This commit is contained in:
2026-06-02 13:44:24 +12:00
parent 8c09e91534
commit d36140c76e
3 changed files with 186 additions and 1 deletions
+76
View File
@@ -761,6 +761,82 @@ Player position: X=2048.00, Y=2048.00, Z=0.00, AngleZ=0.00
--- ---
## 2026-06-02 - Proxy Sneak State Observation
### What Changed
- Added a log-only movement-state behaviour hook in `F4TProxyActorController`.
- The single represented proxy now observes the remote player's `isSneaking`
value after the normal same-cell validation.
- Added controller-local tracking for the represented remote `playerId` and its
last observed sneak state so logs happen only on transitions.
- Reset sneak-state tracking when the represented remote player changes or
disappears.
### What Worked
- Existing smooth proxy transform movement remains unchanged.
- Special movement types still snap through the existing movement path.
- The networking receive thread remains actor-free and continues to update only
plain remote-player state.
### What Broke
- Nothing recorded.
### Notes
- This milestone intentionally does not force the proxy to crouch visually.
- Animation graph variables/events, direct `ActorState` writes, and
`PerformAction(kActionSneak)` remain out of scope until a safe actor crouch API
is confirmed.
- Dynamic spawning, multiple proxy actors, combat, inventory, quest, settlement,
weapon, and interaction sync remain out of scope.
### Next Steps
- Test with two Fallout 4 clients in `F4TTestCell01` and confirm sneak toggles
produce one log line per transition.
- Research a safe visual crouch/sneak application path for the placed proxy
actor.
---
## 2026-06-02 - Local Sneak Detection Diagnostics
### What Changed
- Improved local `isSneaking` detection to combine safe read-only signals from
`PlayerCharacter::IsSneaking()`, actor-state stance, and actor-state
`forceSneak`.
- Added throttled local sneak diagnostics showing each read-only signal and the
final `isSneaking` value sent in transform packets.
- Kept movement-state change detection unchanged, so `isSneaking` transitions
can trigger transform sends even without a meaningful position delta.
### What Worked
- The networking protocol remains unchanged and backwards compatible.
- The receive thread remains actor-free.
- Proxy sneak behaviour remains log-only; no visual crouch is applied.
### What Broke
- Nothing recorded.
### Notes
- No animation graph variables/events, direct actor-state writes, or
`PerformAction(kActionSneak)` were added.
- Server files and fake-client compatibility were left unchanged.
### Next Steps
- Test crouch/sneak in-game and compare `apiSneaking`, `actorStateSneaking`,
`actorStateForceSneak`, and `finalIsSneaking` in `Fallout4Together.log`.
---
## Entry Template ## Entry Template
Use this format for future updates: Use this format for future updates:
+41
View File
@@ -52,6 +52,8 @@ namespace
bool g_firstSmoothedMovementLogged = false; bool g_firstSmoothedMovementLogged = false;
auto g_lastSafetyOffsetMovementTime = std::chrono::steady_clock::time_point{}; auto g_lastSafetyOffsetMovementTime = std::chrono::steady_clock::time_point{};
std::optional<std::uint32_t> g_representedPlayerId; std::optional<std::uint32_t> g_representedPlayerId;
std::optional<std::uint32_t> g_lastObservedSneakPlayerId;
std::optional<bool> g_lastObservedSneakState;
std::unordered_map<std::string, std::chrono::steady_clock::time_point> g_lastWarningLogTimes; std::unordered_map<std::string, std::chrono::steady_clock::time_point> g_lastWarningLogTimes;
using RemotePlayer = F4T::RemotePlayerState::RemotePlayerState; using RemotePlayer = F4T::RemotePlayerState::RemotePlayerState;
@@ -124,6 +126,12 @@ namespace
return a_movementType == "cell_change" || a_movementType == "worldspace_change" || a_movementType == "teleport"; return a_movementType == "cell_change" || a_movementType == "worldspace_change" || a_movementType == "teleport";
} }
void ResetRemoteMovementStateTracking()
{
g_lastObservedSneakPlayerId.reset();
g_lastObservedSneakState.reset();
}
bool HasEditorId(const RE::TESForm& a_form, std::string_view a_editorId) bool HasEditorId(const RE::TESForm& a_form, std::string_view a_editorId)
{ {
const auto* editorId = a_form.GetFormEditorID(); const auto* editorId = a_form.GetFormEditorID();
@@ -420,6 +428,7 @@ namespace
g_representedPlayerId.reset(); g_representedPlayerId.reset();
g_remoteStateMovedLogged = false; g_remoteStateMovedLogged = false;
g_firstSmoothedMovementLogged = false; g_firstSmoothedMovementLogged = false;
ResetRemoteMovementStateTracking();
} }
} }
@@ -451,6 +460,7 @@ namespace
g_representedPlayerId = selectedPlayer->playerId; g_representedPlayerId = selectedPlayer->playerId;
g_remoteStateMovedLogged = false; g_remoteStateMovedLogged = false;
g_firstSmoothedMovementLogged = false; g_firstSmoothedMovementLogged = false;
ResetRemoteMovementStateTracking();
if (!g_remoteStateMovementStartedLogged) { if (!g_remoteStateMovementStartedLogged) {
LogInfoWithLocalPlayerPrefix("Remote-state-driven proxy movement started."); LogInfoWithLocalPlayerPrefix("Remote-state-driven proxy movement started.");
g_remoteStateMovementStartedLogged = true; g_remoteStateMovementStartedLogged = true;
@@ -470,6 +480,36 @@ namespace
return *selectedPlayer; return *selectedPlayer;
} }
void ApplyRemoteMovementStateToProxy(RE::Actor& a_proxy, const RemotePlayer& a_remotePlayer)
{
(void)a_proxy;
if (g_lastObservedSneakPlayerId != a_remotePlayer.playerId) {
g_lastObservedSneakPlayerId = a_remotePlayer.playerId;
g_lastObservedSneakState = a_remotePlayer.isSneaking;
return;
}
if (!g_lastObservedSneakState) {
g_lastObservedSneakState = a_remotePlayer.isSneaking;
return;
}
if (*g_lastObservedSneakState == a_remotePlayer.isSneaking) {
return;
}
g_lastObservedSneakState = a_remotePlayer.isSneaking;
LogInfoWithLocalPlayerPrefix(std::format(
"Remote player {} sneak state changed: isSneaking={}",
a_remotePlayer.playerId,
a_remotePlayer.isSneaking));
// TODO: Once a safe Fallout 4/CommonLibF4 API is confirmed, apply a visual
// crouch/sneak state here. Do not force animation graph variables/events,
// ActorState bits, or PerformAction(kActionSneak) without that proof.
}
void MoveProxyToPlayerOffset(RE::Actor& a_proxy, const RE::PlayerCharacter& a_player) void MoveProxyToPlayerOffset(RE::Actor& a_proxy, const RE::PlayerCharacter& a_player)
{ {
auto targetPosition = a_player.GetPosition(); auto targetPosition = a_player.GetPosition();
@@ -594,6 +634,7 @@ namespace F4T::ProxyActorController
return; return;
} }
ApplyRemoteMovementStateToProxy(*proxy, *selectedRemotePlayer);
MoveProxyToRemotePlayer(*proxy, *selectedRemotePlayer); MoveProxyToRemotePlayer(*proxy, *selectedRemotePlayer);
} }
+69 -1
View File
@@ -1,6 +1,8 @@
#include "F4TNetworking.h" #include "F4TNetworking.h"
#include "F4TProxyActorController.h" #include "F4TProxyActorController.h"
#include "RE/A/ACTOR_STANCE.h"
#include <cmath> #include <cmath>
#include <cstdlib> #include <cstdlib>
#include <cstdint> #include <cstdint>
@@ -35,6 +37,15 @@ namespace
float movementSpeed; float movementSpeed;
}; };
struct SneakDetectionState
{
bool apiSneaking;
bool actorStateSneaking;
bool actorStateForceSneak;
std::uint32_t actorStateStance;
bool finalIsSneaking;
};
constexpr auto kTransformSendInterval = 100ms; constexpr auto kTransformSendInterval = 100ms;
constexpr auto kTransformLogInterval = 1s; constexpr auto kTransformLogInterval = 1s;
constexpr auto kTransformSendPositionThreshold = 3.0F; constexpr auto kTransformSendPositionThreshold = 3.0F;
@@ -44,6 +55,7 @@ namespace
constexpr auto kJumpVerticalSpeedThreshold = 80.0F; constexpr auto kJumpVerticalSpeedThreshold = 80.0F;
constexpr auto kJumpStateHoldDuration = 400ms; constexpr auto kJumpStateHoldDuration = 400ms;
constexpr auto kJumpDiagnosticLogInterval = 1s; constexpr auto kJumpDiagnosticLogInterval = 1s;
constexpr auto kSneakDiagnosticLogInterval = 2s;
std::string GetLocalPlayerLogMessage(std::string_view a_message) std::string GetLocalPlayerLogMessage(std::string_view a_message)
{ {
@@ -182,6 +194,51 @@ namespace
return true; return true;
} }
SneakDetectionState GetSneakDetectionState(RE::PlayerCharacter& a_player)
{
const auto apiSneaking = a_player.IsSneaking();
const auto actorStateStance = a_player.stance;
const auto actorStateSneaking =
actorStateStance == static_cast<std::uint32_t>(RE::ACTOR_STANCE::kSneaking);
const auto actorStateForceSneak = a_player.forceSneak != 0;
return {
apiSneaking,
actorStateSneaking,
actorStateForceSneak,
actorStateStance,
apiSneaking || actorStateSneaking || actorStateForceSneak
};
}
bool ShouldLogSneakDetection(
const SneakDetectionState& a_sneakDetection,
std::chrono::steady_clock::time_point a_currentTime)
{
static bool hasLastLoggedDetection = false;
static SneakDetectionState lastLoggedDetection{};
static auto lastLogTime = std::chrono::steady_clock::time_point{};
const auto detectionChanged = !hasLastLoggedDetection ||
lastLoggedDetection.apiSneaking != a_sneakDetection.apiSneaking ||
lastLoggedDetection.actorStateSneaking != a_sneakDetection.actorStateSneaking ||
lastLoggedDetection.actorStateForceSneak != a_sneakDetection.actorStateForceSneak ||
lastLoggedDetection.actorStateStance != a_sneakDetection.actorStateStance ||
lastLoggedDetection.finalIsSneaking != a_sneakDetection.finalIsSneaking;
const auto diagnosticThrottled =
lastLogTime.time_since_epoch().count() == 0 ||
a_currentTime - lastLogTime >= kSneakDiagnosticLogInterval;
if (!detectionChanged && !diagnosticThrottled) {
return false;
}
hasLastLoggedDetection = true;
lastLoggedDetection = a_sneakDetection;
lastLogTime = a_currentTime;
return true;
}
PlayerMovementState GetPlayerMovementState( PlayerMovementState GetPlayerMovementState(
RE::PlayerCharacter& a_player, RE::PlayerCharacter& a_player,
const PlayerTransform* a_previousSample, const PlayerTransform* a_previousSample,
@@ -194,6 +251,7 @@ namespace
const auto elapsedSeconds = GetSampleElapsedSeconds(a_previousSample, a_previousSampleTime, a_currentTime); const auto elapsedSeconds = GetSampleElapsedSeconds(a_previousSample, a_previousSampleTime, a_currentTime);
const auto movementSpeed = GetMovementSpeed(a_previousSample, a_currentTransform, elapsedSeconds); const auto movementSpeed = GetMovementSpeed(a_previousSample, a_currentTransform, elapsedSeconds);
const auto verticalSpeed = GetVerticalSpeed(a_previousSample, a_currentTransform, elapsedSeconds); const auto verticalSpeed = GetVerticalSpeed(a_previousSample, a_currentTransform, elapsedSeconds);
const auto sneakDetection = GetSneakDetectionState(a_player);
const auto apiJumping = a_player.IsJumping(); const auto apiJumping = a_player.IsJumping();
const auto derivedJumping = verticalSpeed >= kJumpVerticalSpeedThreshold; const auto derivedJumping = verticalSpeed >= kJumpVerticalSpeedThreshold;
if (apiJumping || derivedJumping) { if (apiJumping || derivedJumping) {
@@ -209,10 +267,20 @@ namespace
heldJumping)); heldJumping));
} }
if (ShouldLogSneakDetection(sneakDetection, a_currentTime)) {
LogInfoWithLocalPlayerPrefix(std::format(
"Local sneak detection: apiSneaking={}, actorStateSneaking={}, actorStateForceSneak={}, actorStateStance={}, finalIsSneaking={}",
sneakDetection.apiSneaking,
sneakDetection.actorStateSneaking,
sneakDetection.actorStateForceSneak,
sneakDetection.actorStateStance,
sneakDetection.finalIsSneaking));
}
return { return {
movementSpeed >= kMovingSpeedThreshold, movementSpeed >= kMovingSpeedThreshold,
a_player.DoGetSprinting(), a_player.DoGetSprinting(),
a_player.IsSneaking(), sneakDetection.finalIsSneaking,
heldJumping, heldJumping,
a_player.GetWeaponMagicDrawn(), a_player.GetWeaponMagicDrawn(),
movementSpeed movementSpeed