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.
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.
4. The plugin receives its `welcome` packet and stores its assigned `playerId`.
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
player tables.
10. On the game-thread update path, the plugin reads a copied remote-player
snapshot and moves the single placed proxy actor in `F4TTestCell01`,
smoothing normal movement and snapping cell changes, worldspace changes, and
teleports.
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,
worldspace changes, and teleports snap directly.
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
- Reading local player state
- Sending local player data to the server
- Throttling normal transform sends separately from readable local movement logs
- Receiving server packets on a background thread
- Storing assigned and remote `playerId` state internally
- 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:
```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 stores remote player state
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
Use this format for future updates:
```markdown
## YYYY-MM-DD - Milestone Title
### What Changed
- ...
### What Worked
- ...
### What Broke
- ...
### Notes
- ...
### Next Steps
- ...
```
## 2026-05-31
## 2026-05-31 - Placed Proxy Actor Control
### What Changed
@@ -608,7 +580,9 @@ Use this format for future updates:
- Snap movement first.
- Add smoothing/interpolation later.
## 2026-05-31
---
## 2026-05-31 - Remote-State Proxy Movement
### What Changed
@@ -644,7 +618,9 @@ Use this format for future updates:
- Build the plugin with `xmake build`.
- Run the single-client, two-client, and disconnect tests in `F4TTestCell01`.
## 2026-06-02
---
## 2026-06-02 - Remote Proxy Smoothing
### What Changed
@@ -682,3 +658,89 @@ Use this format for future updates:
- Run the single-client, two-client, special-movement, disconnect, and cell-leave
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 RE::TESFormID kProxyFallbackLocalFormId = 0x0020A2;
constexpr auto kProxyOffsetX = 150.0F;
constexpr float kProxyPositionLerpAlpha = 0.25F;
constexpr auto kMovementInterval = 200ms;
constexpr float kProxyPositionLerpAlpha = 0.15F;
constexpr auto kSafetyOffsetMovementInterval = 200ms;
constexpr auto kWarningLogInterval = 5s;
constexpr auto kNotInTestCellLogInterval = 30s;
@@ -50,7 +50,7 @@ namespace
bool g_remoteStateMovedLogged = false;
bool g_proxySmoothingEnabledLogged = 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::unordered_map<std::string, std::chrono::steady_clock::time_point> g_lastWarningLogTimes;
@@ -393,14 +393,15 @@ namespace
return proxy;
}
bool ShouldMoveProxy()
bool ShouldMoveSafetyOffsetProxy()
{
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;
}
g_lastMovementTime = now;
g_lastSafetyOffsetMovementTime = now;
return true;
}
@@ -563,7 +564,7 @@ namespace F4T::ProxyActorController
if constexpr (kProxyMovementMode == ProxyMovementMode::kSafetyOffsetTest) {
auto* proxy = ResolveProxy(*parentCell, *player);
if (!proxy || !ShouldMoveProxy()) {
if (!proxy || !ShouldMoveSafetyOffsetProxy()) {
return;
}
@@ -577,7 +578,7 @@ namespace F4T::ProxyActorController
}
auto* proxy = ResolveProxy(*parentCell, *player);
if (!proxy || !ShouldMoveProxy()) {
if (!proxy) {
return;
}
+60 -32
View File
@@ -24,10 +24,10 @@ namespace
std::uint32_t worldspaceId;
};
constexpr auto kPositionLogThreshold = 10.0F;
constexpr auto kRotationLogThresholdDegrees = 1.0F;
constexpr auto kRotationLogThresholdRadians = kRotationLogThresholdDegrees * 3.14159265358979323846F / 180.0F;
constexpr auto kMinimumLogInterval = 1s;
constexpr auto kTransformSendInterval = 100ms;
constexpr auto kTransformLogInterval = 1s;
constexpr auto kTransformSendPositionThreshold = 3.0F;
constexpr auto kTransformSendRotationThreshold = 0.02F;
constexpr auto kTeleportDistanceThreshold = 5000.0F;
std::string GetLocalPlayerLogMessage(std::string_view a_message)
@@ -99,7 +99,7 @@ namespace
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;
@@ -113,10 +113,10 @@ namespace
}
const auto squaredPositionDelta = GetSquaredPositionDistance(a_previous, a_current);
constexpr auto squaredPositionThreshold = kPositionLogThreshold * kPositionLogThreshold;
constexpr auto squaredPositionThreshold = kTransformSendPositionThreshold * kTransformSendPositionThreshold;
return squaredPositionDelta > squaredPositionThreshold ||
deltaAngleZ > kRotationLogThresholdRadians || deltaAngleZ < -kRotationLogThresholdRadians;
deltaAngleZ > kTransformSendRotationThreshold || deltaAngleZ < -kTransformSendRotationThreshold;
}
bool HasCellChanged(const PlayerLocation& a_previous, const PlayerLocation& a_current)
@@ -146,7 +146,37 @@ namespace
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();
if (!player) {
@@ -161,42 +191,33 @@ namespace
static bool hasLastTransform = false;
static PlayerTransform lastSentTransform{};
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 currentLocation = GetPlayerLocation(*player);
const auto currentTime = std::chrono::steady_clock::now();
if (!hasLastTransform) {
lastSentTransform = currentTransform;
lastKnownLocation = currentLocation;
lastSendTime = currentTime;
lastLogTime = currentTime;
hasLastTransform = true;
return;
}
const char* movementType = "normal";
if (HasWorldspaceChanged(lastKnownLocation, currentLocation)) {
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* movementType =
GetSpecialMovementType(lastKnownLocation, currentLocation, lastSentTransform, currentTransform);
const auto isSpecialMovement = movementType != std::string_view{ "normal" };
if (!isSpecialMovement && !HasTransformChanged(lastSentTransform, currentTransform)) {
if (!isSpecialMovement && !HasMeaningfulTransformDelta(lastSentTransform, currentTransform)) {
return;
}
const auto currentTime = std::chrono::steady_clock::now();
if (!isSpecialMovement && currentTime - lastLogTime < kMinimumLogInterval) {
if (!isSpecialMovement && currentTime - lastSendTime < kTransformSendInterval) {
return;
}
// Player transforms can change by tiny amounts every frame. Throttling keeps the
// readout useful for debugging movement without flooding Fallout4Together.log or
// turning this first local networking test into a packet every frame.
// Normal movement sends more often than logs, but still avoids packet-per-frame noise.
const auto sentTransform = F4T::Networking::SendTransformPacket(
currentTransform.x,
currentTransform.y,
@@ -208,11 +229,18 @@ namespace
currentLocation.hasWorldspaceId,
currentLocation.worldspaceId);
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;
}
}
}
void StartPlayerPositionPolling()
@@ -224,14 +252,14 @@ namespace
const auto* taskInterface = F4SE::GetTaskInterface();
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;
}
// 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([]() {
CheckAndLogPlayerPositionChange();
PollLocalPlayerTransform();
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
to the local Python server. The server assigns a `playerId`, adds `serverTime`,
and broadcasts transform packets to other connected clients.
to the local Python server. Normal movement sends are throttled separately from
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
thread. It stores its assigned `playerId`, stores remote transform state by
@@ -58,6 +61,8 @@ player exists.
## Implemented
- 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 broadcast transform packets.
- 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 plugin moves the single placed test proxy actor from a thread-safe
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
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.
## Planned Later