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
+33
View File
@@ -0,0 +1,33 @@
---
description: C++ plugin and Python server coding standards
alwaysApply: true
---
# Coding Standards
For C++ plugin work:
- Prioritise stability and safety over cleverness.
- Avoid unsafe game-thread access.
- Interact with Fallout 4 runtime objects on the game thread unless clearly safe otherwise.
- Use defensive null checks for pointers, actors, cells, worldspaces, forms, and lookup results.
- Do not crash the game if a pointer, actor, cell, worldspace, or form lookup fails.
- Use throttled logging for repeated runtime events.
- Keep logs useful for debugging, but do not spam the log every frame.
- Keep networking code separated from game-world manipulation where possible.
- Preserve existing behaviour unless the requested change requires modifying it.
- Do not make large architecture rewrites unless explicitly requested.
- Prefer small, testable milestones.
For Python server work:
- Keep the server simple and readable.
- Preserve unknown JSON fields where possible so the protocol can evolve without breaking older code.
- Treat malformed packets as client input errors, not server-fatal errors.
- Do not crash the server because of one bad client message.
- Preserve backwards compatibility unless specifically requested.
- Keep `server/fake_client.py` useful for testing protocol changes.
General implementation guidance:
- Prefer clear naming over overly abstract systems.
- Add TODO comments only where they are genuinely useful.
- Do not remove useful debugging logs unless they are too spammy.
- Do not remove fallback behaviour or safe defaults.
+54
View File
@@ -0,0 +1,54 @@
---
description: Documentation and development log update rules
alwaysApply: true
---
# Documentation Rules
After every meaningful code change, update the relevant documentation. Do not let code and documentation drift apart.
Always consider whether these files need updates:
- `docs/dev-log.md`
- `docs/protocol.md`
- `docs/architecture.md`
- `docs/setup.md`
- `docs/testing.md`
At minimum, after every milestone or feature change, update `docs/dev-log.md` with:
- Date
- Summary of what changed
- Files modified
- What was tested
- What worked
- Any known issues
- Suggested next steps
Use this dev-log format:
```md
## YYYY-MM-DD - Feature or Milestone Name
### Summary
Briefly explain what changed.
### Files Changed
- `path/to/file`
- `path/to/file`
### Details
- Important technical detail
- Important technical detail
### Testing
- What was tested
- What the expected result was
- What actually happened
### Known Issues
- Issue or `None currently known`
### Next Steps
- Suggested next task
```
If the protocol changes, update `docs/protocol.md`. If the architecture changes, update `docs/architecture.md`. If setup changes, update `docs/setup.md`. If testing workflow changes, update `docs/testing.md`.
+29
View File
@@ -0,0 +1,29 @@
---
description: Fallout 4 Together project architecture and goals
alwaysApply: true
---
# Project Overview
Fallout 4 Together is an experimental Fallout 4 multiplayer mod. It uses a C++ F4SE plugin, a Python relay server, fake clients for protocol testing, and project documentation in `docs/`.
The goal is to sync players across separate Fallout 4 instances by sending player transform and state data through a server, then representing remote players in-game with proxy actors.
The current sync model includes player transforms, cell/worldspace IDs, heading, and movement state such as moving, speed, sprinting, sneaking, jumping, and weapon drawn.
Preserve the existing server-authoritative relay architecture unless specifically told otherwise. The project is not trying to fully merge game worlds: each player runs their own local Fallout 4 instance, and the server distributes state updates between clients.
Important project areas:
- `plugin/src/main.cpp`: C++ F4SE plugin entry point and game integration.
- `server/server.py`: Python relay server.
- `server/fake_client.py`: fake testing client for protocol and relay checks.
- `docs/`: architecture, setup, protocol, testing, and milestone documentation.
- `docs/dev-log.md`: running project history and development log.
Cursor workflow:
- Read relevant files before editing.
- Make minimal focused changes.
- Avoid breaking already-working transform sync.
- Avoid speculative rewrites or unrelated feature work.
- Keep changes understandable for an iterative prototype.
- Explain changes after applying them and mention assumptions made.
+39
View File
@@ -0,0 +1,39 @@
---
description: Networking protocol compatibility and documentation rules
alwaysApply: true
---
# Protocol Rules
When modifying the networking protocol:
- Update protocol documentation whenever packet structure changes.
- Treat new fields as optional on receive unless the change is intentionally breaking.
- Use safe defaults when a field is missing.
- Do not rename protocol fields unless specifically requested.
- Preserve unknown JSON fields where possible.
Preserve existing field names, including:
- `type`
- `playerId`
- `x`, `y`, `z`
- `angleZ`
- `cellId`
- `worldspaceId`
- `movementType`
- `clientTime`
- `serverTime`
- `isMoving`
- `movementSpeed`
- `isSprinting`
- `isSneaking`
- `isJumping`
- `weaponDrawn`
When adding a new protocol field, make sure:
- The plugin sends it when appropriate.
- Receivers parse it safely and tolerate it being absent.
- The server preserves or relays it.
- `server/fake_client.py` is updated if useful for testing.
- Relevant protocol documentation is updated.
Protocol changes should avoid breaking already-working transform sync unless the requested change explicitly requires it.
+30
View File
@@ -0,0 +1,30 @@
---
description: Practical manual testing guidance for Fallout 4 Together changes
alwaysApply: true
---
# Testing Rules
When making changes, suggest practical tests the developer can run. Match the testing depth to the risk and scope of the change.
Useful project tests include:
- Running the Python server.
- Connecting one Fallout 4 instance.
- Connecting two Fallout 4 instances.
- Running `server/fake_client.py`.
- Checking `Fallout4Together.log`.
- Verifying transform packets are sent.
- Verifying remote player state is received.
- Verifying proxy actor movement in `F4TTestCell01`.
- Checking that movement state fields appear in logs.
- Checking that server output receives and relays packet fields correctly.
When a change touches multiplayer sync, include a short manual testing checklist covering:
- Server startup and client connection.
- Outgoing plugin packet fields.
- Server receive and relay behaviour.
- Remote state parsing.
- Proxy actor movement or visible in-game result.
- Relevant log output.
If a test cannot be run locally, say so clearly and explain the remaining risk.
+14 -9
View File
@@ -12,18 +12,20 @@ The current system is still a local prototype, but the Fallout 4 plugin now has
both send and receive paths: both send and receive paths:
1. The Fallout 4 plugin reads local player transform data. 1. The Fallout 4 plugin reads local player transform data.
2. The plugin sends transform packets to the local Python server on 2. The plugin derives basic movement state on the game-thread polling path and
sends it as optional data on transform packets.
3. The plugin sends transform packets to the local Python server on
`127.0.0.1:7777`. Normal movement sends are throttled separately from `127.0.0.1:7777`. Normal movement sends are throttled separately from
movement logs, targeting roughly 10 Hz while the player is moving. movement logs, targeting roughly 10 Hz while the player is moving.
3. The server assigns `playerId` values and sends `welcome` packets. 4. The server assigns `playerId` values and sends `welcome` packets.
4. The plugin receives its `welcome` packet and stores its assigned `playerId`. 5. The plugin receives its `welcome` packet and stores its assigned `playerId`.
5. The server adds `playerId` and `serverTime` to transform packets. 6. The server adds `playerId` and `serverTime` to transform packets.
6. The server broadcasts transform packets to other connected clients. 7. The server broadcasts transform packets to other connected clients.
7. The plugin and `server/fake_client.py` store remote player state by `playerId`. 8. The plugin and `server/fake_client.py` store remote player state by `playerId`.
8. The server broadcasts `disconnect` packets when clients disconnect. 9. The server broadcasts `disconnect` packets when clients disconnect.
9. The plugin and fake client remove disconnected players from their remote 10. The plugin and fake client remove disconnected players from their remote
player tables. player tables.
10. On the game-thread update path, the plugin reads a copied remote-player 11. On the game-thread update path, the plugin reads a copied remote-player
snapshot and moves the single placed proxy actor in `F4TTestCell01`. snapshot and moves the single placed proxy actor in `F4TTestCell01`.
Proxy visual updates are independent from local movement log throttling: Proxy visual updates are independent from local movement log throttling:
normal movement is smoothed toward the latest target, while cell changes, normal movement is smoothed toward the latest target, while cell changes,
@@ -40,6 +42,7 @@ The native plugin is responsible for:
- Loading into Fallout 4 - Loading into Fallout 4
- Reading local player state - Reading local player state
- Sending local player data to the server - Sending local player data to the server
- Adding basic data-only movement state to transform packets
- Throttling normal transform sends separately from readable local movement logs - Throttling normal transform sends separately from readable local movement logs
- Receiving server packets on a background thread - Receiving server packets on a background thread
- Storing assigned and remote `playerId` state internally - Storing assigned and remote `playerId` state internally
@@ -93,6 +96,7 @@ Current approach:
```text ```text
Game update reads local player transform Game update reads local player transform
Game update derives basic movement state
Game update sends thresholded transform packets around 10 Hz while moving Game update sends thresholded transform packets around 10 Hz while moving
Game update logs local movement around 1 Hz Game update logs local movement around 1 Hz
Networking thread receives packets Networking thread receives packets
@@ -110,6 +114,7 @@ update path.
- Remote actor spawning - Remote actor spawning
- Multiple proxy actors - Multiple proxy actors
- Animation graph sync or animation application
- Combat sync - Combat sync
- Quest sync - Quest sync
- Inventory sync - Inventory sync
+44
View File
@@ -717,6 +717,50 @@ Player position: X=2048.00, Y=2048.00, Z=0.00, AngleZ=0.00
--- ---
## 2026-06-02 - Basic Movement State Sync
### What Changed
- Added data-only movement state fields to transform packets:
`isMoving`, `isSprinting`, `isSneaking`, `isJumping`, `weaponDrawn`, and
`movementSpeed`.
- Derived local movement speed from sampled position delta over elapsed time on
the game-thread polling path.
- Stored optional movement state fields in remote-player state with safe
defaults for older packets.
- Added throttled remote movement-state logs so packet data can be inspected
without logging every transform.
- Updated `server/fake_client.py` to display movement state while keeping
missing fields backwards compatible.
### What Worked
- The server relay remains unchanged because it preserves unknown transform
fields when it adds `playerId` and `serverTime`.
- The networking receive thread remains actor-free and only updates plain
remote-player state.
- Proxy actor movement remains unchanged; movement state is not applied to
animations yet.
### What Broke
- Nothing recorded.
### Notes
- This milestone prepares the remote-player data model for later proxy
animation or behavior work.
- Dynamic spawning, multiple proxy actors, animation graph sync, combat,
inventory, quest, settlement, and interaction sync remain out of scope.
### Next Steps
- Test with two Fallout 4 clients in `F4TTestCell01`.
- Use the synced state in a later animation milestone without expanding
gameplay sync scope.
---
## Entry Template ## Entry Template
Use this format for future updates: Use this format for future updates:
+7 -1
View File
@@ -18,5 +18,11 @@ namespace F4T::Networking
bool a_hasCellId = false, bool a_hasCellId = false,
std::uint32_t a_cellId = 0, std::uint32_t a_cellId = 0,
bool a_hasWorldspaceId = false, 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::string worldspaceId;
std::optional<double> clientTime; std::optional<double> clientTime;
std::optional<double> serverTime; 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{}; std::chrono::steady_clock::time_point lastReceivedLocalTime{};
}; };
+70 -4
View File
@@ -10,6 +10,7 @@
#include <atomic> #include <atomic>
#include <charconv> #include <charconv>
#include <chrono> #include <chrono>
#include <cmath>
#include <cstddef> #include <cstddef>
#include <cstdio> #include <cstdio>
#include <format> #include <format>
@@ -42,6 +43,7 @@ namespace
std::atomic_bool g_receiveThreadRunning{ false }; std::atomic_bool g_receiveThreadRunning{ false };
std::thread g_receiveThread; 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_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; std::unordered_map<std::string, std::chrono::steady_clock::time_point> g_lastWarningLogTimes;
using Json = nlohmann::json; using Json = nlohmann::json;
@@ -226,6 +228,20 @@ namespace
return std::nullopt; 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) std::optional<std::string> ReadString(const Json& a_packet, const char* a_fieldName)
{ {
const auto field = a_packet.find(a_fieldName); const auto field = a_packet.find(a_fieldName);
@@ -248,6 +264,18 @@ namespace
return true; 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) std::optional<std::string_view> GetMissingRequiredTransformField(const Json& a_packet)
{ {
constexpr std::array<std::string_view, 7> requiredFields{ 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 movementType = ReadString(a_packet, "movementType").value_or("normal");
const auto worldspaceId = ReadString(a_packet, "worldspaceId").value_or(""); 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{ F4T::RemotePlayerState::RemotePlayerState remoteState{
*playerId, *playerId,
@@ -340,6 +372,12 @@ namespace
worldspaceId, worldspaceId,
ReadDouble(a_packet, "clientTime").value_or(0.0), ReadDouble(a_packet, "clientTime").value_or(0.0),
ReadDouble(a_packet, "serverTime").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() std::chrono::steady_clock::now()
}; };
@@ -358,6 +396,18 @@ namespace
remoteState.cellId, remoteState.cellId,
remoteState.worldspaceId)); 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) void HandleDisconnectPacket(const Json& a_packet)
@@ -369,6 +419,7 @@ namespace
} }
g_lastRemoteUpdateLogTimes.erase(*playerId); g_lastRemoteUpdateLogTimes.erase(*playerId);
g_lastRemoteMovementStateLogTimes.erase(*playerId);
if (F4T::RemotePlayerState::RemoveRemotePlayer(*playerId)) { if (F4T::RemotePlayerState::RemoveRemotePlayer(*playerId)) {
LogInfoWithLocalPlayerPrefix(std::format("Remote player {} disconnected and was removed.", *playerId)); LogInfoWithLocalPlayerPrefix(std::format("Remote player {} disconnected and was removed.", *playerId));
@@ -580,6 +631,7 @@ namespace F4T::Networking
F4T::RemotePlayerState::ClearAssignedPlayerId(); F4T::RemotePlayerState::ClearAssignedPlayerId();
F4T::RemotePlayerState::ClearRemotePlayers(); F4T::RemotePlayerState::ClearRemotePlayers();
g_lastRemoteUpdateLogTimes.clear(); g_lastRemoteUpdateLogTimes.clear();
g_lastRemoteMovementStateLogTimes.clear();
StartReceiveThread(); StartReceiveThread();
LogInfoWithLocalPlayerPrefix( LogInfoWithLocalPlayerPrefix(
@@ -593,6 +645,7 @@ namespace F4T::Networking
F4T::RemotePlayerState::ClearAssignedPlayerId(); F4T::RemotePlayerState::ClearAssignedPlayerId();
F4T::RemotePlayerState::ClearRemotePlayers(); F4T::RemotePlayerState::ClearRemotePlayers();
g_lastRemoteUpdateLogTimes.clear(); g_lastRemoteUpdateLogTimes.clear();
g_lastRemoteMovementStateLogTimes.clear();
if (g_winsockInitialized) { if (g_winsockInitialized) {
WSACleanup(); WSACleanup();
@@ -614,7 +667,13 @@ namespace F4T::Networking
bool a_hasCellId, bool a_hasCellId,
std::uint32_t a_cellId, std::uint32_t a_cellId,
bool a_hasWorldspaceId, 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()) { if (!IsConnectedToServer() && !ConnectToLocalServer()) {
return false; return false;
@@ -623,17 +682,24 @@ namespace F4T::Networking
const auto clientTime = const auto clientTime =
std::chrono::duration<double>(std::chrono::system_clock::now().time_since_epoch()).count(); 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( auto packetSize = std::snprintf(
packet.data(), packet.data(),
packet.size(), 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_x,
a_y, a_y,
a_z, a_z,
a_angleZ, a_angleZ,
a_movementType ? a_movementType : "normal", 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()) { if (packetSize <= 0 || static_cast<std::size_t>(packetSize) >= packet.size()) {
LogWarningWithLocalPlayerPrefix("Could not format Fallout 4 Together transform packet."); 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) 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(); RE::NiPoint3 targetPosition = a_proxy.GetPosition();
targetPosition.x = a_remotePlayer.x; targetPosition.x = a_remotePlayer.x;
targetPosition.y = a_remotePlayer.y; targetPosition.y = a_remotePlayer.y;
+156 -3
View File
@@ -1,6 +1,7 @@
#include "F4TNetworking.h" #include "F4TNetworking.h"
#include "F4TProxyActorController.h" #include "F4TProxyActorController.h"
#include <cmath>
#include <cstdlib> #include <cstdlib>
#include <cstdint> #include <cstdint>
#include <format> #include <format>
@@ -24,11 +25,25 @@ namespace
std::uint32_t worldspaceId; std::uint32_t worldspaceId;
}; };
struct PlayerMovementState
{
bool isMoving;
bool isSprinting;
bool isSneaking;
bool isJumping;
bool weaponDrawn;
float movementSpeed;
};
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;
constexpr auto kTransformSendRotationThreshold = 0.02F; constexpr auto kTransformSendRotationThreshold = 0.02F;
constexpr auto kTeleportDistanceThreshold = 5000.0F; 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) std::string GetLocalPlayerLogMessage(std::string_view a_message)
{ {
@@ -99,6 +114,120 @@ namespace
return (deltaX * deltaX) + (deltaY * deltaY) + (deltaZ * deltaZ); 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) bool HasMeaningfulTransformDelta(const PlayerTransform& a_previous, const PlayerTransform& a_current)
{ {
auto deltaAngleZ = a_current.angleZ - a_previous.angleZ; auto deltaAngleZ = a_current.angleZ - a_previous.angleZ;
@@ -178,7 +307,7 @@ namespace
void PollLocalPlayerTransform() void PollLocalPlayerTransform()
{ {
const auto* player = TryGetLocalPlayer(); auto* player = TryGetLocalPlayer();
if (!player) { if (!player) {
static bool playerUnavailableWarningLogged = false; static bool playerUnavailableWarningLogged = false;
if (!playerUnavailableWarningLogged) { if (!playerUnavailableWarningLogged) {
@@ -190,18 +319,26 @@ namespace
static bool hasLastTransform = false; static bool hasLastTransform = false;
static PlayerTransform lastSentTransform{}; static PlayerTransform lastSentTransform{};
static PlayerTransform lastSampleTransform{};
static PlayerLocation lastKnownLocation{}; static PlayerLocation lastKnownLocation{};
static PlayerMovementState lastSentMovementState{};
static auto lastSendTime = std::chrono::steady_clock::time_point{}; static auto lastSendTime = std::chrono::steady_clock::time_point{};
static auto lastLogTime = 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 currentTransform = GetPlayerTransform(*player);
const auto currentLocation = GetPlayerLocation(*player); const auto currentLocation = GetPlayerLocation(*player);
const auto currentTime = std::chrono::steady_clock::now(); const auto currentTime = std::chrono::steady_clock::now();
const auto currentMovementState =
GetPlayerMovementState(*player, hasLastTransform ? &lastSampleTransform : nullptr, lastSampleTime, currentTransform, currentTime);
if (!hasLastTransform) { if (!hasLastTransform) {
lastSentTransform = currentTransform; lastSentTransform = currentTransform;
lastSampleTransform = currentTransform;
lastKnownLocation = currentLocation; lastKnownLocation = currentLocation;
lastSentMovementState = currentMovementState;
lastSendTime = currentTime; lastSendTime = currentTime;
lastLogTime = currentTime; lastLogTime = currentTime;
lastSampleTime = currentTime;
hasLastTransform = true; hasLastTransform = true;
return; return;
} }
@@ -209,11 +346,17 @@ namespace
const auto* movementType = const auto* movementType =
GetSpecialMovementType(lastKnownLocation, currentLocation, lastSentTransform, currentTransform); GetSpecialMovementType(lastKnownLocation, currentLocation, lastSentTransform, currentTransform);
const auto isSpecialMovement = movementType != std::string_view{ "normal" }; 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; return;
} }
if (!isSpecialMovement && currentTime - lastSendTime < kTransformSendInterval) { if (!isSpecialMovement && currentTime - lastSendTime < kTransformSendInterval) {
lastSampleTransform = currentTransform;
lastSampleTime = currentTime;
return; return;
} }
@@ -227,10 +370,17 @@ namespace
currentLocation.hasCellId, currentLocation.hasCellId,
currentLocation.cellId, currentLocation.cellId,
currentLocation.hasWorldspaceId, currentLocation.hasWorldspaceId,
currentLocation.worldspaceId); currentLocation.worldspaceId,
currentMovementState.isMoving,
currentMovementState.isSprinting,
currentMovementState.isSneaking,
currentMovementState.isJumping,
currentMovementState.weaponDrawn,
currentMovementState.movementSpeed);
if (sentTransform) { if (sentTransform) {
lastSentTransform = currentTransform; lastSentTransform = currentTransform;
lastKnownLocation = currentLocation; lastKnownLocation = currentLocation;
lastSentMovementState = currentMovementState;
lastSendTime = currentTime; lastSendTime = currentTime;
if (isSpecialMovement) { if (isSpecialMovement) {
@@ -241,6 +391,9 @@ namespace
lastLogTime = currentTime; lastLogTime = currentTime;
} }
} }
lastSampleTransform = currentTransform;
lastSampleTime = currentTime;
} }
void StartPlayerPositionPolling() void StartPlayerPositionPolling()
+15
View File
@@ -63,6 +63,12 @@ Example after server processing:
"cellId": "0000DD60", "cellId": "0000DD60",
"worldspaceId": "0000003C", "worldspaceId": "0000003C",
"clientTime": 1780212128.301, "clientTime": 1780212128.301,
"isMoving": true,
"isSprinting": false,
"isSneaking": false,
"isJumping": false,
"weaponDrawn": false,
"movementSpeed": 186.4,
"serverTime": 1780212128.3011043 "serverTime": 1780212128.3011043
} }
``` ```
@@ -80,6 +86,12 @@ movementType Movement classification for this transform update.
cellId Current cell form ID as a string. cellId Current cell form ID as a string.
worldspaceId Current worldspace form ID as a string. worldspaceId Current worldspace form ID as a string.
clientTime Timestamp generated by the sending plugin. clientTime Timestamp generated by the sending plugin.
isMoving Optional movement-state flag; defaults to false when missing.
isSprinting Optional movement-state flag; defaults to false when missing.
isSneaking Optional movement-state flag; defaults to false when missing.
isJumping Optional movement-state flag; defaults to false when missing.
weaponDrawn Optional weapon drawn state; defaults to false when missing.
movementSpeed Optional derived movement speed in game units per second; defaults to 0.0 when missing.
serverTime Timestamp added by the server before broadcast. serverTime Timestamp added by the server before broadcast.
``` ```
@@ -95,6 +107,9 @@ teleport
`cell_change` and `worldspace_change` are currently represented through `cell_change` and `worldspace_change` are currently represented through
`movementType` inside transform packets. They are not separate packet types yet. `movementType` inside transform packets. They are not separate packet types yet.
Movement state fields are data-only for now. Receivers must treat them as
optional and must not reject older transform packets when they are absent.
### Disconnect Packet ### Disconnect Packet
Sent by the server to remaining connected clients when a client disconnects. Sent by the server to remaining connected clients when a client disconnects.
+12
View File
@@ -46,12 +46,21 @@ cellId
worldspaceId worldspaceId
clientTime clientTime
serverTime serverTime
isMoving
isSprinting
isSneaking
isJumping
weaponDrawn
movementSpeed
lastReceivedLocalTime lastReceivedLocalTime
``` ```
Required transform packet fields are `type`, `playerId`, `x`, `y`, `z`, Required transform packet fields are `type`, `playerId`, `x`, `y`, `z`,
`angleZ`, and `cellId`. `worldspaceId`, `movementType`, `clientTime`, and `angleZ`, and `cellId`. `worldspaceId`, `movementType`, `clientTime`, and
`serverTime` are optional; interior test cells may omit `worldspaceId`. `serverTime` are optional; interior test cells may omit `worldspaceId`.
Movement state fields are also optional and default to not moving, not
sprinting, not sneaking, not jumping, weapon holstered, and speed `0.0` when
missing.
This model proves the data shape and lifecycle before spawning remote actors. This model proves the data shape and lifecycle before spawning remote actors.
For the current visual milestone, the controller represents only one remote For the current visual milestone, the controller represents only one remote
@@ -80,6 +89,8 @@ player exists.
- Normal proxy movement is smoothed toward the latest received target, while - Normal proxy movement is smoothed toward the latest received target, while
`cell_change`, `worldspace_change`, and `teleport` transforms snap directly to `cell_change`, `worldspace_change`, and `teleport` transforms snap directly to
avoid slow movement across large discontinuities. avoid slow movement across large discontinuities.
- Transform packets now include basic movement state data for future animation
work, but the proxy actor does not apply animations from that state yet.
## Transform Cadence ## Transform Cadence
@@ -97,6 +108,7 @@ remote transform state on the networking thread and moves actors only from the
game-thread proxy controller. game-thread proxy controller.
No Fallout 4 remote actor is dynamically spawned yet. No Fallout 4 remote actor is dynamically spawned yet.
No animation graph sync or animation application is implemented yet.
## Planned Later ## Planned Later
+7
View File
@@ -9,6 +9,11 @@ packets, assigns incrementing `playerId` values, sends `welcome` packets, adds
to every other connected client, and broadcasts disconnect packets when clients to every other connected client, and broadcasts disconnect packets when clients
disconnect. disconnect.
Transform packets may include optional movement-state fields such as
`isMoving`, `isSprinting`, `isSneaking`, `isJumping`, `weaponDrawn`, and
`movementSpeed`. The server preserves these fields automatically because it
broadcasts the original transform packet after adding server-owned fields.
This is still a local prototype. It does not make Fallout 4 multiplayer This is still a local prototype. It does not make Fallout 4 multiplayer
playable yet. playable yet.
@@ -51,6 +56,7 @@ Expected behavior:
- The server receives transforms from the Fallout 4 plugin. - The server receives transforms from the Fallout 4 plugin.
- The server adds `playerId` and `serverTime` to each transform packet before - The server adds `playerId` and `serverTime` to each transform packet before
broadcast. broadcast.
- The server preserves optional movement-state fields on transform packets.
- The server broadcasts those transform packets to connected clients except the - The server broadcasts those transform packets to connected clients except the
sender. sender.
- `fake_client.py` receives broadcast transform packets. - `fake_client.py` receives broadcast transform packets.
@@ -72,6 +78,7 @@ does not spawn remote actors or send movement.
- Send welcome packets - Send welcome packets
- Receive transform packets - Receive transform packets
- Add server timestamps - Add server timestamps
- Preserve optional transform fields
- Broadcast transforms to other connected clients - Broadcast transforms to other connected clients
- Broadcast disconnect packets - Broadcast disconnect packets
- Handle disconnect cleanup - Handle disconnect cleanup
+35
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import math
import socket import socket
import time import time
from typing import Any from typing import Any
@@ -20,6 +21,25 @@ def warn(message: str) -> None:
log(f"Warning: {message}") log(f"Warning: {message}")
def get_optional_bool(packet: dict[str, Any], field_name: str, default: bool = False) -> bool:
value = packet.get(field_name, default)
if isinstance(value, bool):
return value
return default
def get_optional_float(packet: dict[str, Any], field_name: str, default: float = 0.0) -> float:
value = packet.get(field_name, default)
try:
parsed_value = float(value)
except (TypeError, ValueError):
return default
if not math.isfinite(parsed_value) or parsed_value < 0.0:
return default
return parsed_value
def update_remote_player_state(packet: dict[str, Any]) -> None: def update_remote_player_state(packet: dict[str, Any]) -> None:
required_fields = ( required_fields = (
"type", "type",
@@ -56,6 +76,12 @@ def update_remote_player_state(packet: dict[str, Any]) -> None:
"worldspaceId": packet.get("worldspaceId", ""), "worldspaceId": packet.get("worldspaceId", ""),
"clientTime": packet.get("clientTime", 0.0), "clientTime": packet.get("clientTime", 0.0),
"serverTime": packet.get("serverTime", 0.0), "serverTime": packet.get("serverTime", 0.0),
"isMoving": get_optional_bool(packet, "isMoving"),
"isSprinting": get_optional_bool(packet, "isSprinting"),
"isSneaking": get_optional_bool(packet, "isSneaking"),
"isJumping": get_optional_bool(packet, "isJumping"),
"weaponDrawn": get_optional_bool(packet, "weaponDrawn"),
"movementSpeed": get_optional_float(packet, "movementSpeed"),
"lastReceivedLocalTime": time.time(), "lastReceivedLocalTime": time.time(),
} }
@@ -73,6 +99,12 @@ def print_remote_player(player: dict[str, Any]) -> None:
f"Position: X={player['x']:.2f}, Y={player['y']:.2f}, Z={player['z']:.2f}", f"Position: X={player['x']:.2f}, Y={player['y']:.2f}, Z={player['z']:.2f}",
f"AngleZ: {player['angleZ']:.2f}", f"AngleZ: {player['angleZ']:.2f}",
f"Movement Type: {player['movementType']}", f"Movement Type: {player['movementType']}",
(
"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"Cell: {player['cellId']}", f"Cell: {player['cellId']}",
f"Worldspace: {player['worldspaceId']}", f"Worldspace: {player['worldspaceId']}",
f"Client Time: {client_time}", f"Client Time: {client_time}",
@@ -95,6 +127,9 @@ def print_remote_players() -> None:
f"- Player {player_id}: " f"- Player {player_id}: "
f"pos=({player['x']:.2f}, {player['y']:.2f}, {player['z']:.2f}), " f"pos=({player['x']:.2f}, {player['y']:.2f}, {player['z']:.2f}), "
f"angleZ={player['angleZ']:.2f}, movementType={player['movementType']}, " 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"cell={player['cellId']}, worldspace={player['worldspaceId']}, " f"cell={player['cellId']}, worldspace={player['worldspaceId']}, "
f"serverTime={player['serverTime']}" f"serverTime={player['serverTime']}"
) )