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:
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user