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:
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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{};
|
||||
};
|
||||
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user