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:
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
movement logs, targeting roughly 10 Hz while the player is moving.
3. The server assigns `playerId` values and sends `welcome` packets.
4. The plugin receives its `welcome` packet and stores its assigned `playerId`.
5. The server adds `playerId` and `serverTime` to transform packets.
6. 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 server broadcasts `disconnect` packets when clients disconnect.
9. The plugin and fake client remove disconnected players from their remote
4. The server assigns `playerId` values and sends `welcome` packets.
5. The plugin receives its `welcome` packet and stores its assigned `playerId`.
6. The server adds `playerId` and `serverTime` to transform packets.
7. The server broadcasts transform packets to other connected clients.
8. The plugin and `server/fake_client.py` store remote player state by `playerId`.
9. The server broadcasts `disconnect` packets when clients disconnect.
10. The plugin and fake client remove disconnected players from their remote
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`.
Proxy visual updates are independent from local movement log throttling:
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
- Reading local player state
- 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
- Receiving server packets on a background thread
- Storing assigned and remote `playerId` state internally
@@ -93,6 +96,7 @@ Current approach:
```text
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 logs local movement around 1 Hz
Networking thread receives packets
@@ -110,6 +114,7 @@ update path.
- Remote actor spawning
- Multiple proxy actors
- Animation graph sync or animation application
- Combat sync
- Quest 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
Use this format for future updates:
+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()
+15
View File
@@ -63,6 +63,12 @@ Example after server processing:
"cellId": "0000DD60",
"worldspaceId": "0000003C",
"clientTime": 1780212128.301,
"isMoving": true,
"isSprinting": false,
"isSneaking": false,
"isJumping": false,
"weaponDrawn": false,
"movementSpeed": 186.4,
"serverTime": 1780212128.3011043
}
```
@@ -80,6 +86,12 @@ movementType Movement classification for this transform update.
cellId Current cell form ID as a string.
worldspaceId Current worldspace form ID as a string.
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.
```
@@ -95,6 +107,9 @@ teleport
`cell_change` and `worldspace_change` are currently represented through
`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
Sent by the server to remaining connected clients when a client disconnects.
+12
View File
@@ -46,12 +46,21 @@ cellId
worldspaceId
clientTime
serverTime
isMoving
isSprinting
isSneaking
isJumping
weaponDrawn
movementSpeed
lastReceivedLocalTime
```
Required transform packet fields are `type`, `playerId`, `x`, `y`, `z`,
`angleZ`, and `cellId`. `worldspaceId`, `movementType`, `clientTime`, and
`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.
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
`cell_change`, `worldspace_change`, and `teleport` transforms snap directly to
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
@@ -97,6 +108,7 @@ remote transform state on the networking thread and moves actors only from the
game-thread proxy controller.
No Fallout 4 remote actor is dynamically spawned yet.
No animation graph sync or animation application is implemented yet.
## 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
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
playable yet.
@@ -51,6 +56,7 @@ Expected behavior:
- The server receives transforms from the Fallout 4 plugin.
- The server adds `playerId` and `serverTime` to each transform packet before
broadcast.
- The server preserves optional movement-state fields on transform packets.
- The server broadcasts those transform packets to connected clients except the
sender.
- `fake_client.py` receives broadcast transform packets.
@@ -72,6 +78,7 @@ does not spawn remote actors or send movement.
- Send welcome packets
- Receive transform packets
- Add server timestamps
- Preserve optional transform fields
- Broadcast transforms to other connected clients
- Broadcast disconnect packets
- Handle disconnect cleanup
+35
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import json
import math
import socket
import time
from typing import Any
@@ -20,6 +21,25 @@ def warn(message: str) -> None:
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:
required_fields = (
"type",
@@ -56,6 +76,12 @@ def update_remote_player_state(packet: dict[str, Any]) -> None:
"worldspaceId": packet.get("worldspaceId", ""),
"clientTime": packet.get("clientTime", 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(),
}
@@ -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"AngleZ: {player['angleZ']:.2f}",
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"Worldspace: {player['worldspaceId']}",
f"Client Time: {client_time}",
@@ -95,6 +127,9 @@ def print_remote_players() -> None:
f"- Player {player_id}: "
f"pos=({player['x']:.2f}, {player['y']:.2f}, {player['z']:.2f}), "
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"serverTime={player['serverTime']}"
)