Throttle transform sends and smooth proxy movement

Separate normal transform send cadence from movement logging and improve proxy smoothing. Key changes:

- Polling/send logic (plugin/src/main.cpp): introduced distinct send vs log intervals (100ms send, 1s log), lowered send thresholds (≈3.0 units position, 0.02 rad rotation), renamed/clarified functions, and added special movement types (worldspace_change, cell_change, teleport) that bypass the send gate and are logged immediately. Normal movement targets roughly 10 Hz while moving to avoid packet-per-frame traffic.
- Proxy controller (plugin/src/F4TProxyActorController.cpp): tuned proxy position lerp alpha to 0.15 for smoother motion, renamed movement-interval variables for clarity, and removed the generic per-update movement gate so normal proxy visual updates are applied each game update while only the safety-offset path is throttled.
- Documentation updates (docs/architecture.md, docs/dev-log.md, protocol/player-sync.md): describe the new send/log separation, cadence and thresholds, smoothing behavior, and recorded dev-log about smoothing tests and results.

These changes reduce network noise, produce smoother remote visuals, and keep readable local movement logs while preserving immediate sends for large/categorical movement changes.
This commit is contained in:
2026-06-02 13:01:02 +12:00
parent 8dbe3117b0
commit 4ebc4a2814
5 changed files with 197 additions and 80 deletions
+10 -4
View File
@@ -13,7 +13,8 @@ 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 sends transform packets to the local Python server on
`127.0.0.1:7777`. `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. 3. The server assigns `playerId` values and sends `welcome` packets.
4. The plugin receives its `welcome` packet and stores its assigned `playerId`. 4. The plugin receives its `welcome` packet and stores its assigned `playerId`.
5. The server adds `playerId` and `serverTime` to transform packets. 5. The server adds `playerId` and `serverTime` to transform packets.
@@ -23,9 +24,10 @@ both send and receive paths:
9. The plugin and fake client remove disconnected players from their remote 9. 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 10. 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`.
smoothing normal movement and snapping cell changes, worldspace changes, and Proxy visual updates are independent from local movement log throttling:
teleports. normal movement is smoothed toward the latest target, while cell changes,
worldspace changes, and teleports snap directly.
The Fallout 4 plugin still does not dynamically spawn remote actors. The Fallout 4 plugin still does not dynamically spawn remote actors.
@@ -38,6 +40,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
- 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
- Moving the single placed test proxy actor from remote-player state on the game - Moving the single placed test proxy actor from remote-player state on the game
@@ -89,6 +92,9 @@ Networking should run separately from game update logic.
Current approach: Current approach:
```text ```text
Game update reads local player transform
Game update sends thresholded transform packets around 10 Hz while moving
Game update logs local movement around 1 Hz
Networking thread receives packets Networking thread receives packets
Networking thread stores remote player state Networking thread stores remote player state
Game update reads a copied remote player snapshot Game update reads a copied remote player snapshot
+93 -31
View File
@@ -544,35 +544,7 @@ Player position: X=2048.00, Y=2048.00, Z=0.00, AngleZ=0.00
--- ---
## Entry Template ## 2026-05-31 - Placed Proxy Actor Control
Use this format for future updates:
```markdown
## YYYY-MM-DD - Milestone Title
### What Changed
- ...
### What Worked
- ...
### What Broke
- ...
### Notes
- ...
### Next Steps
- ...
```
## 2026-05-31
### What Changed ### What Changed
@@ -608,7 +580,9 @@ Use this format for future updates:
- Snap movement first. - Snap movement first.
- Add smoothing/interpolation later. - Add smoothing/interpolation later.
## 2026-05-31 ---
## 2026-05-31 - Remote-State Proxy Movement
### What Changed ### What Changed
@@ -644,7 +618,9 @@ Use this format for future updates:
- Build the plugin with `xmake build`. - Build the plugin with `xmake build`.
- Run the single-client, two-client, and disconnect tests in `F4TTestCell01`. - Run the single-client, two-client, and disconnect tests in `F4TTestCell01`.
## 2026-06-02 ---
## 2026-06-02 - Remote Proxy Smoothing
### What Changed ### What Changed
@@ -682,3 +658,89 @@ Use this format for future updates:
- Run the single-client, two-client, special-movement, disconnect, and cell-leave - Run the single-client, two-client, special-movement, disconnect, and cell-leave
tests in `F4TTestCell01`. tests in `F4TTestCell01`.
---
## 2026-06-02 - Smooth Remote Proxy Movement Tested
### What Changed
- Separated normal local transform send cadence from local movement log cadence.
- Lowered normal transform send thresholds to support smoother remote motion:
about 3 game units of position change or 0.02 radians of rotation change.
- Targeted normal transform sends at roughly 10 Hz while moving, without sending
every frame.
- Kept local movement logs readable at the slower debug interval.
- Kept `cell_change`, `worldspace_change`, and `teleport` sends immediate and
baseline-updating.
- Removed the 200 ms throttle from remote-player proxy visual movement on the
game-thread update path.
- Tuned normal proxy position smoothing to a per-update lerp alpha near 0.15.
- Preserved direct snapping for special remote movement types and kept rotation
snapping through the existing safe heading path.
### What Worked
- The networking receive thread remains actor-free and still only updates plain
remote-player state.
- The single placed proxy actor remains the only remote visual representation;
dynamic spawning and multiple proxy actors are still out of scope.
- Two Fallout 4 clients can still connect to the local server.
- Server transform relay still works.
- Remote player state still updates correctly.
- The proxy actor now moves smoothly compared to the previous snapping/jittery
version.
- The current visual result is close to feeling correct, with animation sync now
being the obvious missing piece.
### What Broke
- Nothing recorded.
### Notes
- `server/server.py` and `server/fake_client.py` were intentionally left
unchanged because the transform packet protocol did not need to change.
- This is the first smooth visible multiplayer prototype for Fallout 4
Together.
- The proxy actor still lacks synced animations.
- Only one proxy actor is supported.
- Dynamic spawning is not implemented.
- Combat, inventory, quest, settlement, interaction, and animation sync remain
out of scope for this milestone.
### Next Steps
- Add basic movement state sync.
- Start with simple animation-related states like idle, walking, running,
sprinting, crouching, jumping, and weapon drawn.
- Keep animation sync separate from combat and inventory sync.
---
## Entry Template
Use this format for future updates:
```markdown
## YYYY-MM-DD - Milestone Title
### What Changed
- ...
### What Worked
- ...
### What Broke
- ...
### Notes
- ...
### Next Steps
- ...
```
+9 -8
View File
@@ -23,8 +23,8 @@ namespace
constexpr auto kTestPluginName = "Fallout4Together_Test.esp"; constexpr auto kTestPluginName = "Fallout4Together_Test.esp";
constexpr RE::TESFormID kProxyFallbackLocalFormId = 0x0020A2; constexpr RE::TESFormID kProxyFallbackLocalFormId = 0x0020A2;
constexpr auto kProxyOffsetX = 150.0F; constexpr auto kProxyOffsetX = 150.0F;
constexpr float kProxyPositionLerpAlpha = 0.25F; constexpr float kProxyPositionLerpAlpha = 0.15F;
constexpr auto kMovementInterval = 200ms; constexpr auto kSafetyOffsetMovementInterval = 200ms;
constexpr auto kWarningLogInterval = 5s; constexpr auto kWarningLogInterval = 5s;
constexpr auto kNotInTestCellLogInterval = 30s; constexpr auto kNotInTestCellLogInterval = 30s;
@@ -50,7 +50,7 @@ namespace
bool g_remoteStateMovedLogged = false; bool g_remoteStateMovedLogged = false;
bool g_proxySmoothingEnabledLogged = false; bool g_proxySmoothingEnabledLogged = false;
bool g_firstSmoothedMovementLogged = false; bool g_firstSmoothedMovementLogged = false;
auto g_lastMovementTime = std::chrono::steady_clock::time_point{}; auto g_lastSafetyOffsetMovementTime = std::chrono::steady_clock::time_point{};
std::optional<std::uint32_t> g_representedPlayerId; std::optional<std::uint32_t> g_representedPlayerId;
std::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;
@@ -393,14 +393,15 @@ namespace
return proxy; return proxy;
} }
bool ShouldMoveProxy() bool ShouldMoveSafetyOffsetProxy()
{ {
const auto now = std::chrono::steady_clock::now(); const auto now = std::chrono::steady_clock::now();
if (g_lastMovementTime.time_since_epoch().count() != 0 && now - g_lastMovementTime < kMovementInterval) { if (g_lastSafetyOffsetMovementTime.time_since_epoch().count() != 0 &&
now - g_lastSafetyOffsetMovementTime < kSafetyOffsetMovementInterval) {
return false; return false;
} }
g_lastMovementTime = now; g_lastSafetyOffsetMovementTime = now;
return true; return true;
} }
@@ -563,7 +564,7 @@ namespace F4T::ProxyActorController
if constexpr (kProxyMovementMode == ProxyMovementMode::kSafetyOffsetTest) { if constexpr (kProxyMovementMode == ProxyMovementMode::kSafetyOffsetTest) {
auto* proxy = ResolveProxy(*parentCell, *player); auto* proxy = ResolveProxy(*parentCell, *player);
if (!proxy || !ShouldMoveProxy()) { if (!proxy || !ShouldMoveSafetyOffsetProxy()) {
return; return;
} }
@@ -577,7 +578,7 @@ namespace F4T::ProxyActorController
} }
auto* proxy = ResolveProxy(*parentCell, *player); auto* proxy = ResolveProxy(*parentCell, *player);
if (!proxy || !ShouldMoveProxy()) { if (!proxy) {
return; return;
} }
+62 -34
View File
@@ -24,10 +24,10 @@ namespace
std::uint32_t worldspaceId; std::uint32_t worldspaceId;
}; };
constexpr auto kPositionLogThreshold = 10.0F; constexpr auto kTransformSendInterval = 100ms;
constexpr auto kRotationLogThresholdDegrees = 1.0F; constexpr auto kTransformLogInterval = 1s;
constexpr auto kRotationLogThresholdRadians = kRotationLogThresholdDegrees * 3.14159265358979323846F / 180.0F; constexpr auto kTransformSendPositionThreshold = 3.0F;
constexpr auto kMinimumLogInterval = 1s; constexpr auto kTransformSendRotationThreshold = 0.02F;
constexpr auto kTeleportDistanceThreshold = 5000.0F; constexpr auto kTeleportDistanceThreshold = 5000.0F;
std::string GetLocalPlayerLogMessage(std::string_view a_message) std::string GetLocalPlayerLogMessage(std::string_view a_message)
@@ -99,7 +99,7 @@ namespace
return (deltaX * deltaX) + (deltaY * deltaY) + (deltaZ * deltaZ); return (deltaX * deltaX) + (deltaY * deltaY) + (deltaZ * deltaZ);
} }
bool HasTransformChanged(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;
@@ -113,10 +113,10 @@ namespace
} }
const auto squaredPositionDelta = GetSquaredPositionDistance(a_previous, a_current); const auto squaredPositionDelta = GetSquaredPositionDistance(a_previous, a_current);
constexpr auto squaredPositionThreshold = kPositionLogThreshold * kPositionLogThreshold; constexpr auto squaredPositionThreshold = kTransformSendPositionThreshold * kTransformSendPositionThreshold;
return squaredPositionDelta > squaredPositionThreshold || return squaredPositionDelta > squaredPositionThreshold ||
deltaAngleZ > kRotationLogThresholdRadians || deltaAngleZ < -kRotationLogThresholdRadians; deltaAngleZ > kTransformSendRotationThreshold || deltaAngleZ < -kTransformSendRotationThreshold;
} }
bool HasCellChanged(const PlayerLocation& a_previous, const PlayerLocation& a_current) bool HasCellChanged(const PlayerLocation& a_previous, const PlayerLocation& a_current)
@@ -146,7 +146,37 @@ namespace
a_transform.angleZ)); a_transform.angleZ));
} }
void CheckAndLogPlayerPositionChange() const char* GetSpecialMovementType(
const PlayerLocation& a_previousLocation,
const PlayerLocation& a_currentLocation,
const PlayerTransform& a_previousTransform,
const PlayerTransform& a_currentTransform)
{
if (HasWorldspaceChanged(a_previousLocation, a_currentLocation)) {
return "worldspace_change";
}
if (HasCellChanged(a_previousLocation, a_currentLocation)) {
return "cell_change";
}
if (HasTeleportDistance(a_previousTransform, a_currentTransform)) {
return "teleport";
}
return "normal";
}
void LogSpecialMovement(std::string_view a_movementType)
{
if (a_movementType == "worldspace_change") {
LogInfoWithLocalPlayerPrefix("Detected worldspace change. Sending immediate transform update.");
} else if (a_movementType == "cell_change") {
LogInfoWithLocalPlayerPrefix("Detected cell change. Sending immediate transform update.");
} else if (a_movementType == "teleport") {
LogInfoWithLocalPlayerPrefix("Detected teleport/large position jump. Sending immediate transform update.");
}
}
void PollLocalPlayerTransform()
{ {
const auto* player = TryGetLocalPlayer(); const auto* player = TryGetLocalPlayer();
if (!player) { if (!player) {
@@ -161,42 +191,33 @@ namespace
static bool hasLastTransform = false; static bool hasLastTransform = false;
static PlayerTransform lastSentTransform{}; static PlayerTransform lastSentTransform{};
static PlayerLocation lastKnownLocation{}; static PlayerLocation lastKnownLocation{};
static auto lastLogTime = std::chrono::steady_clock::now(); static auto lastSendTime = std::chrono::steady_clock::time_point{};
static auto lastLogTime = 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();
if (!hasLastTransform) { if (!hasLastTransform) {
lastSentTransform = currentTransform; lastSentTransform = currentTransform;
lastKnownLocation = currentLocation; lastKnownLocation = currentLocation;
lastSendTime = currentTime;
lastLogTime = currentTime;
hasLastTransform = true; hasLastTransform = true;
return; return;
} }
const char* movementType = "normal"; const auto* movementType =
if (HasWorldspaceChanged(lastKnownLocation, currentLocation)) { GetSpecialMovementType(lastKnownLocation, currentLocation, lastSentTransform, currentTransform);
LogInfoWithLocalPlayerPrefix("Detected worldspace change. Sending immediate transform update.");
movementType = "worldspace_change";
} else if (HasCellChanged(lastKnownLocation, currentLocation)) {
LogInfoWithLocalPlayerPrefix("Detected cell change. Sending immediate transform update.");
movementType = "cell_change";
} else if (HasTeleportDistance(lastSentTransform, currentTransform)) {
LogInfoWithLocalPlayerPrefix("Detected teleport/large position jump. Sending immediate transform update.");
movementType = "teleport";
}
const auto isSpecialMovement = movementType != std::string_view{ "normal" }; const auto isSpecialMovement = movementType != std::string_view{ "normal" };
if (!isSpecialMovement && !HasTransformChanged(lastSentTransform, currentTransform)) { if (!isSpecialMovement && !HasMeaningfulTransformDelta(lastSentTransform, currentTransform)) {
return; return;
} }
const auto currentTime = std::chrono::steady_clock::now(); if (!isSpecialMovement && currentTime - lastSendTime < kTransformSendInterval) {
if (!isSpecialMovement && currentTime - lastLogTime < kMinimumLogInterval) {
return; return;
} }
// Player transforms can change by tiny amounts every frame. Throttling keeps the // Normal movement sends more often than logs, but still avoids packet-per-frame noise.
// readout useful for debugging movement without flooding Fallout4Together.log or
// turning this first local networking test into a packet every frame.
const auto sentTransform = F4T::Networking::SendTransformPacket( const auto sentTransform = F4T::Networking::SendTransformPacket(
currentTransform.x, currentTransform.x,
currentTransform.y, currentTransform.y,
@@ -208,11 +229,18 @@ namespace
currentLocation.hasWorldspaceId, currentLocation.hasWorldspaceId,
currentLocation.worldspaceId); currentLocation.worldspaceId);
if (sentTransform) { if (sentTransform) {
LogPlayerTransform(currentTransform); lastSentTransform = currentTransform;
lastKnownLocation = currentLocation;
lastSendTime = currentTime;
if (isSpecialMovement) {
LogSpecialMovement(movementType);
lastLogTime = currentTime;
} else if (currentTime - lastLogTime >= kTransformLogInterval) {
LogPlayerTransform(currentTransform);
lastLogTime = currentTime;
}
} }
lastSentTransform = currentTransform;
lastKnownLocation = currentLocation;
lastLogTime = currentTime;
} }
void StartPlayerPositionPolling() void StartPlayerPositionPolling()
@@ -224,14 +252,14 @@ namespace
const auto* taskInterface = F4SE::GetTaskInterface(); const auto* taskInterface = F4SE::GetTaskInterface();
if (!taskInterface) { if (!taskInterface) {
LogWarningWithLocalPlayerPrefix("Failed to get F4SE task interface; player position changes will not be logged."); LogWarningWithLocalPlayerPrefix("Failed to get F4SE task interface; player position changes will not be sent or logged.");
return; return;
} }
// The player reference may not exist at plugin load time. A permanent task lets us // The player reference may not exist at plugin load time. A permanent task lets us
// check on the game thread after load, while the logging gate keeps updates readable. // check on the game thread after load, while separate send and log gates keep updates useful.
taskInterface->AddTaskPermanent([]() { taskInterface->AddTaskPermanent([]() {
CheckAndLogPlayerPositionChange(); PollLocalPlayerTransform();
F4T::ProxyActorController::Update(); F4T::ProxyActorController::Update();
}); });
+23 -3
View File
@@ -12,8 +12,11 @@ Plugin Remote Player State
``` ```
The Fallout 4 plugin currently reads the local player's transform and sends it The Fallout 4 plugin currently reads the local player's transform and sends it
to the local Python server. The server assigns a `playerId`, adds `serverTime`, to the local Python server. Normal movement sends are throttled separately from
and broadcasts transform packets to other connected clients. local movement logs: while the player is moving, the plugin targets roughly
10 Hz and skips sends until position or rotation changes meaningfully. The
server assigns a `playerId`, adds `serverTime`, and broadcasts transform packets
to other connected clients.
The Fallout 4 plugin also receives server packets on a background networking The Fallout 4 plugin also receives server packets on a background networking
thread. It stores its assigned `playerId`, stores remote transform state by thread. It stores its assigned `playerId`, stores remote transform state by
@@ -58,6 +61,8 @@ player exists.
## Implemented ## Implemented
- The plugin sends local player transform packets. - The plugin sends local player transform packets.
- Normal transform sends are independent from movement logging and are
thresholded to avoid packet-per-frame traffic.
- The plugin receives `welcome` packets and stores its assigned `playerId`. - The plugin receives `welcome` packets and stores its assigned `playerId`.
- The plugin receives broadcast transform packets. - The plugin receives broadcast transform packets.
- The plugin ignores transform packets for its own assigned `playerId`. - The plugin ignores transform packets for its own assigned `playerId`.
@@ -72,10 +77,25 @@ player exists.
- The fake client removes disconnected remote players from its state table. - The fake client removes disconnected remote players from its state table.
- The plugin moves the single placed test proxy actor from a thread-safe - The plugin moves the single placed test proxy actor from a thread-safe
remote-player snapshot on the game-thread update path. remote-player snapshot on the game-thread update path.
- Normal proxy movement is smoothed with a simple position lerp, 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 Cadence
Normal local movement currently uses three separate rates:
- Network sends target roughly 10 Hz while the player is moving.
- Sends are skipped until position changes by about 3 game units or rotation
changes by about 0.02 radians.
- Local movement logs stay much slower, around once per second, so
`Fallout4Together.log` remains readable.
`cell_change`, `worldspace_change`, and `teleport` movement types bypass the
normal send interval and are sent immediately. The receiving plugin still stores
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 Fallout 4 remote actor is dynamically spawned yet.
## Planned Later ## Planned Later