Add basic movement state to transform packets

Add optional movement-state fields (isMoving, isSprinting, isSneaking, isJumping, weaponDrawn, movementSpeed) to transform packets and wire them end-to-end. Plugin changes: extend F4TNetworking API and RemotePlayerState, derive movement speed/jump state on the game-thread, validate values, include fields when formatting transform JSON, and add throttled remote movement-state logging. main.cpp adds sampling, speed/vertical calculations, jump hold logic, and change-detection to avoid extra sends. Networking parsing (F4TNetworking.cpp) reads optional booleans/floats safely, preserves backwards compatibility, and clears movement-state logs on disconnect. Proxy controller includes a TODO note for future animation use. Server and docs: update protocol and packet docs, dev-log, server README, and fake_client.py to parse/display optional fields safely. Also add several .cursor rule files for coding, documentation, protocol, project overview, and testing guidance. This milestone prepares the data model for later animation/behavior work while keeping existing relay behavior unchanged.
This commit is contained in:
2026-06-02 13:27:40 +12:00
parent 4ebc4a2814
commit 8c09e91534
16 changed files with 553 additions and 17 deletions
+7 -1
View File
@@ -18,5 +18,11 @@ namespace F4T::Networking
bool a_hasCellId = false,
std::uint32_t a_cellId = 0,
bool a_hasWorldspaceId = false,
std::uint32_t a_worldspaceId = 0);
std::uint32_t a_worldspaceId = 0,
bool a_isMoving = false,
bool a_isSprinting = false,
bool a_isSneaking = false,
bool a_isJumping = false,
bool a_weaponDrawn = false,
float a_movementSpeed = 0.0F);
}
+6
View File
@@ -20,6 +20,12 @@ namespace F4T::RemotePlayerState
std::string worldspaceId;
std::optional<double> clientTime;
std::optional<double> serverTime;
bool isMoving{ false };
bool isSprinting{ false };
bool isSneaking{ false };
bool isJumping{ false };
bool weaponDrawn{ false };
float movementSpeed{ 0.0F };
std::chrono::steady_clock::time_point lastReceivedLocalTime{};
};
+70 -4
View File
@@ -10,6 +10,7 @@
#include <atomic>
#include <charconv>
#include <chrono>
#include <cmath>
#include <cstddef>
#include <cstdio>
#include <format>
@@ -42,6 +43,7 @@ namespace
std::atomic_bool g_receiveThreadRunning{ false };
std::thread g_receiveThread;
std::unordered_map<std::uint32_t, std::chrono::steady_clock::time_point> g_lastRemoteUpdateLogTimes;
std::unordered_map<std::uint32_t, std::chrono::steady_clock::time_point> g_lastRemoteMovementStateLogTimes;
std::unordered_map<std::string, std::chrono::steady_clock::time_point> g_lastWarningLogTimes;
using Json = nlohmann::json;
@@ -226,6 +228,20 @@ namespace
return std::nullopt;
}
std::optional<bool> ReadBool(const Json& a_packet, const char* a_fieldName)
{
const auto field = a_packet.find(a_fieldName);
if (field == a_packet.end()) {
return std::nullopt;
}
if (field->is_boolean()) {
return field->get<bool>();
}
return std::nullopt;
}
std::optional<std::string> ReadString(const Json& a_packet, const char* a_fieldName)
{
const auto field = a_packet.find(a_fieldName);
@@ -248,6 +264,18 @@ namespace
return true;
}
bool ShouldLogRemoteMovementState(std::uint32_t a_playerId)
{
const auto now = std::chrono::steady_clock::now();
const auto lastLog = g_lastRemoteMovementStateLogTimes.find(a_playerId);
if (lastLog != g_lastRemoteMovementStateLogTimes.end() && now - lastLog->second < kRemoteUpdateLogInterval) {
return false;
}
g_lastRemoteMovementStateLogTimes[a_playerId] = now;
return true;
}
std::optional<std::string_view> GetMissingRequiredTransformField(const Json& a_packet)
{
constexpr std::array<std::string_view, 7> requiredFields{
@@ -328,6 +356,10 @@ namespace
const auto movementType = ReadString(a_packet, "movementType").value_or("normal");
const auto worldspaceId = ReadString(a_packet, "worldspaceId").value_or("");
auto movementSpeed = ReadDouble(a_packet, "movementSpeed").value_or(0.0);
if (!std::isfinite(movementSpeed) || movementSpeed < 0.0) {
movementSpeed = 0.0;
}
F4T::RemotePlayerState::RemotePlayerState remoteState{
*playerId,
@@ -340,6 +372,12 @@ namespace
worldspaceId,
ReadDouble(a_packet, "clientTime").value_or(0.0),
ReadDouble(a_packet, "serverTime").value_or(0.0),
ReadBool(a_packet, "isMoving").value_or(false),
ReadBool(a_packet, "isSprinting").value_or(false),
ReadBool(a_packet, "isSneaking").value_or(false),
ReadBool(a_packet, "isJumping").value_or(false),
ReadBool(a_packet, "weaponDrawn").value_or(false),
static_cast<float>(movementSpeed),
std::chrono::steady_clock::now()
};
@@ -358,6 +396,18 @@ namespace
remoteState.cellId,
remoteState.worldspaceId));
}
if (ShouldLogRemoteMovementState(*playerId)) {
LogInfoWithLocalPlayerPrefix(std::format(
"Remote player {} movement state: moving={}, sprinting={}, sneaking={}, jumping={}, weaponDrawn={}, speed={:.1f}",
remoteState.playerId,
remoteState.isMoving,
remoteState.isSprinting,
remoteState.isSneaking,
remoteState.isJumping,
remoteState.weaponDrawn,
remoteState.movementSpeed));
}
}
void HandleDisconnectPacket(const Json& a_packet)
@@ -369,6 +419,7 @@ namespace
}
g_lastRemoteUpdateLogTimes.erase(*playerId);
g_lastRemoteMovementStateLogTimes.erase(*playerId);
if (F4T::RemotePlayerState::RemoveRemotePlayer(*playerId)) {
LogInfoWithLocalPlayerPrefix(std::format("Remote player {} disconnected and was removed.", *playerId));
@@ -580,6 +631,7 @@ namespace F4T::Networking
F4T::RemotePlayerState::ClearAssignedPlayerId();
F4T::RemotePlayerState::ClearRemotePlayers();
g_lastRemoteUpdateLogTimes.clear();
g_lastRemoteMovementStateLogTimes.clear();
StartReceiveThread();
LogInfoWithLocalPlayerPrefix(
@@ -593,6 +645,7 @@ namespace F4T::Networking
F4T::RemotePlayerState::ClearAssignedPlayerId();
F4T::RemotePlayerState::ClearRemotePlayers();
g_lastRemoteUpdateLogTimes.clear();
g_lastRemoteMovementStateLogTimes.clear();
if (g_winsockInitialized) {
WSACleanup();
@@ -614,7 +667,13 @@ namespace F4T::Networking
bool a_hasCellId,
std::uint32_t a_cellId,
bool a_hasWorldspaceId,
std::uint32_t a_worldspaceId)
std::uint32_t a_worldspaceId,
bool a_isMoving,
bool a_isSprinting,
bool a_isSneaking,
bool a_isJumping,
bool a_weaponDrawn,
float a_movementSpeed)
{
if (!IsConnectedToServer() && !ConnectToLocalServer()) {
return false;
@@ -623,17 +682,24 @@ namespace F4T::Networking
const auto clientTime =
std::chrono::duration<double>(std::chrono::system_clock::now().time_since_epoch()).count();
std::array<char, 320> packet{};
std::array<char, 512> packet{};
auto packetSize = std::snprintf(
packet.data(),
packet.size(),
"{\"type\":\"transform\",\"x\":%.2f,\"y\":%.2f,\"z\":%.2f,\"angleZ\":%.2f,\"movementType\":\"%s\",\"clientTime\":%.3f",
"{\"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",
a_x,
a_y,
a_z,
a_angleZ,
a_movementType ? a_movementType : "normal",
clientTime);
clientTime,
a_isMoving ? "true" : "false",
a_isSprinting ? "true" : "false",
a_isSneaking ? "true" : "false",
a_isJumping ? "true" : "false",
a_weaponDrawn ? "true" : "false",
a_movementSpeed);
if (packetSize <= 0 || static_cast<std::size_t>(packetSize) >= packet.size()) {
LogWarningWithLocalPlayerPrefix("Could not format Fallout 4 Together transform packet.");
+2
View File
@@ -489,6 +489,8 @@ namespace
void MoveProxyToRemotePlayer(RE::Actor& a_proxy, const RemotePlayer& a_remotePlayer)
{
// TODO: Later animation/behavior milestones can read movement state from
// RemotePlayer here. This milestone keeps the proxy visual movement unchanged.
RE::NiPoint3 targetPosition = a_proxy.GetPosition();
targetPosition.x = a_remotePlayer.x;
targetPosition.y = a_remotePlayer.y;
+156 -3
View File
@@ -1,6 +1,7 @@
#include "F4TNetworking.h"
#include "F4TProxyActorController.h"
#include <cmath>
#include <cstdlib>
#include <cstdint>
#include <format>
@@ -24,11 +25,25 @@ namespace
std::uint32_t worldspaceId;
};
struct PlayerMovementState
{
bool isMoving;
bool isSprinting;
bool isSneaking;
bool isJumping;
bool weaponDrawn;
float movementSpeed;
};
constexpr auto kTransformSendInterval = 100ms;
constexpr auto kTransformLogInterval = 1s;
constexpr auto kTransformSendPositionThreshold = 3.0F;
constexpr auto kTransformSendRotationThreshold = 0.02F;
constexpr auto kTeleportDistanceThreshold = 5000.0F;
constexpr auto kMovingSpeedThreshold = 1.0F;
constexpr auto kJumpVerticalSpeedThreshold = 80.0F;
constexpr auto kJumpStateHoldDuration = 400ms;
constexpr auto kJumpDiagnosticLogInterval = 1s;
std::string GetLocalPlayerLogMessage(std::string_view a_message)
{
@@ -99,6 +114,120 @@ namespace
return (deltaX * deltaX) + (deltaY * deltaY) + (deltaZ * deltaZ);
}
float GetSampleElapsedSeconds(
const PlayerTransform* a_previousSample,
std::chrono::steady_clock::time_point a_previousSampleTime,
std::chrono::steady_clock::time_point a_currentTime)
{
if (!a_previousSample || a_previousSampleTime.time_since_epoch().count() == 0) {
return 0.0F;
}
const auto elapsedSeconds = std::chrono::duration<float>(a_currentTime - a_previousSampleTime).count();
if (!std::isfinite(elapsedSeconds) || elapsedSeconds <= 0.0F) {
return 0.0F;
}
return elapsedSeconds;
}
float GetMovementSpeed(
const PlayerTransform* a_previousSample,
const PlayerTransform& a_currentTransform,
float a_elapsedSeconds)
{
if (!a_previousSample || a_elapsedSeconds <= 0.0F) {
return 0.0F;
}
auto movementSpeed = std::sqrt(GetSquaredPositionDistance(*a_previousSample, a_currentTransform)) / a_elapsedSeconds;
if (!std::isfinite(movementSpeed) || movementSpeed < 0.0F) {
return 0.0F;
}
return movementSpeed;
}
float GetVerticalSpeed(
const PlayerTransform* a_previousSample,
const PlayerTransform& a_currentTransform,
float a_elapsedSeconds)
{
if (!a_previousSample || a_elapsedSeconds <= 0.0F) {
return 0.0F;
}
const auto verticalSpeed = (a_currentTransform.z - a_previousSample->z) / a_elapsedSeconds;
if (!std::isfinite(verticalSpeed)) {
return 0.0F;
}
return verticalSpeed;
}
bool ShouldLogJumpDetection(bool a_isJumping, std::chrono::steady_clock::time_point a_currentTime)
{
static bool lastLoggedJumping = false;
static auto lastLogTime = std::chrono::steady_clock::time_point{};
const auto jumpStateChanged = a_isJumping != lastLoggedJumping;
const auto logThrottledJumpState =
a_isJumping && (lastLogTime.time_since_epoch().count() == 0 || a_currentTime - lastLogTime >= kJumpDiagnosticLogInterval);
if (!jumpStateChanged && !logThrottledJumpState) {
return false;
}
lastLoggedJumping = a_isJumping;
lastLogTime = a_currentTime;
return true;
}
PlayerMovementState GetPlayerMovementState(
RE::PlayerCharacter& a_player,
const PlayerTransform* a_previousSample,
std::chrono::steady_clock::time_point a_previousSampleTime,
const PlayerTransform& a_currentTransform,
std::chrono::steady_clock::time_point a_currentTime)
{
static auto jumpHoldUntil = std::chrono::steady_clock::time_point{};
const auto elapsedSeconds = GetSampleElapsedSeconds(a_previousSample, a_previousSampleTime, a_currentTime);
const auto movementSpeed = GetMovementSpeed(a_previousSample, a_currentTransform, elapsedSeconds);
const auto verticalSpeed = GetVerticalSpeed(a_previousSample, a_currentTransform, elapsedSeconds);
const auto apiJumping = a_player.IsJumping();
const auto derivedJumping = verticalSpeed >= kJumpVerticalSpeedThreshold;
if (apiJumping || derivedJumping) {
jumpHoldUntil = a_currentTime + kJumpStateHoldDuration;
}
const auto heldJumping = a_currentTime < jumpHoldUntil;
if (ShouldLogJumpDetection(heldJumping, a_currentTime)) {
LogInfoWithLocalPlayerPrefix(std::format(
"Local jump detection: apiJumping={}, verticalSpeed={:.1f}, heldJumping={}",
apiJumping,
verticalSpeed,
heldJumping));
}
return {
movementSpeed >= kMovingSpeedThreshold,
a_player.DoGetSprinting(),
a_player.IsSneaking(),
heldJumping,
a_player.GetWeaponMagicDrawn(),
movementSpeed
};
}
bool HasMovementStateChanged(const PlayerMovementState& a_previous, const PlayerMovementState& a_current)
{
return a_previous.isMoving != a_current.isMoving ||
a_previous.isSprinting != a_current.isSprinting ||
a_previous.isSneaking != a_current.isSneaking ||
a_previous.isJumping != a_current.isJumping ||
a_previous.weaponDrawn != a_current.weaponDrawn;
}
bool HasMeaningfulTransformDelta(const PlayerTransform& a_previous, const PlayerTransform& a_current)
{
auto deltaAngleZ = a_current.angleZ - a_previous.angleZ;
@@ -178,7 +307,7 @@ namespace
void PollLocalPlayerTransform()
{
const auto* player = TryGetLocalPlayer();
auto* player = TryGetLocalPlayer();
if (!player) {
static bool playerUnavailableWarningLogged = false;
if (!playerUnavailableWarningLogged) {
@@ -190,18 +319,26 @@ namespace
static bool hasLastTransform = false;
static PlayerTransform lastSentTransform{};
static PlayerTransform lastSampleTransform{};
static PlayerLocation lastKnownLocation{};
static PlayerMovementState lastSentMovementState{};
static auto lastSendTime = std::chrono::steady_clock::time_point{};
static auto lastLogTime = std::chrono::steady_clock::time_point{};
static auto lastSampleTime = std::chrono::steady_clock::time_point{};
const auto currentTransform = GetPlayerTransform(*player);
const auto currentLocation = GetPlayerLocation(*player);
const auto currentTime = std::chrono::steady_clock::now();
const auto currentMovementState =
GetPlayerMovementState(*player, hasLastTransform ? &lastSampleTransform : nullptr, lastSampleTime, currentTransform, currentTime);
if (!hasLastTransform) {
lastSentTransform = currentTransform;
lastSampleTransform = currentTransform;
lastKnownLocation = currentLocation;
lastSentMovementState = currentMovementState;
lastSendTime = currentTime;
lastLogTime = currentTime;
lastSampleTime = currentTime;
hasLastTransform = true;
return;
}
@@ -209,11 +346,17 @@ namespace
const auto* movementType =
GetSpecialMovementType(lastKnownLocation, currentLocation, lastSentTransform, currentTransform);
const auto isSpecialMovement = movementType != std::string_view{ "normal" };
if (!isSpecialMovement && !HasMeaningfulTransformDelta(lastSentTransform, currentTransform)) {
const auto hasMeaningfulTransformDelta = HasMeaningfulTransformDelta(lastSentTransform, currentTransform);
const auto hasMovementStateChanged = HasMovementStateChanged(lastSentMovementState, currentMovementState);
if (!isSpecialMovement && !hasMeaningfulTransformDelta && !hasMovementStateChanged) {
lastSampleTransform = currentTransform;
lastSampleTime = currentTime;
return;
}
if (!isSpecialMovement && currentTime - lastSendTime < kTransformSendInterval) {
lastSampleTransform = currentTransform;
lastSampleTime = currentTime;
return;
}
@@ -227,10 +370,17 @@ namespace
currentLocation.hasCellId,
currentLocation.cellId,
currentLocation.hasWorldspaceId,
currentLocation.worldspaceId);
currentLocation.worldspaceId,
currentMovementState.isMoving,
currentMovementState.isSprinting,
currentMovementState.isSneaking,
currentMovementState.isJumping,
currentMovementState.weaponDrawn,
currentMovementState.movementSpeed);
if (sentTransform) {
lastSentTransform = currentTransform;
lastKnownLocation = currentLocation;
lastSentMovementState = currentMovementState;
lastSendTime = currentTime;
if (isSpecialMovement) {
@@ -241,6 +391,9 @@ namespace
lastLogTime = currentTime;
}
}
lastSampleTransform = currentTransform;
lastSampleTime = currentTime;
}
void StartPlayerPositionPolling()