Move placed proxy from remote-player snapshot

Enable game-thread proxy actor movement driven by a copied remote-player snapshot and update related networking/validation, docs, and test client.

- Plugin: add F4T::ProxyActorController::Update() (alias UpdateSafetyTest preserved) and implement remote-state-driven movement for the single placed proxy (F4TProxyRemotePlayer01REF) in F4TTestCell01. Selects lowest available playerId, validates same-cell/worldspace for movement, and caches represented playerId. Adds helper functions (ParseHexFormId, IsRemotePlayerInSameLocation, SelectRemotePlayerToRepresent, MoveProxyToRemotePlayer) and improved throttled logging.
- Networking: tighten transform packet required-field checking (requires type, playerId, x, y, z, angleZ, cellId), add GetMissingRequiredTransformField, make movementType/worldspaceId/clientTime/serverTime optional with sensible defaults, and switch LogThrottledWarning to accept string_view.
- main.cpp: call ProxyActorController::Update on game-thread tasks.
- fake_client.py: mirror new packet validation and provide defaults for optional fields.
- Docs/dev-log/protocol/setup: update architecture, dev log, protocol, and setup notes to describe the single-proxy remote-state milestone, required packet fields, and testing checklist.

This change keeps networking thread limited to parsing/updating plain remote-player state and moves all actor access to the game-thread controller. Dynamic spawning, multiple proxies, and interpolation remain out of scope.
This commit is contained in:
2026-05-31 22:21:39 +12:00
parent 49819813da
commit 46b2fa8b9b
9 changed files with 376 additions and 48 deletions
+11 -5
View File
@@ -22,8 +22,10 @@ both send and receive paths:
8. The server broadcasts `disconnect` packets when clients disconnect.
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`.
The Fallout 4 plugin still does not spawn or move remote actors.
The Fallout 4 plugin still does not dynamically spawn remote actors.
## Main Components
@@ -36,10 +38,11 @@ The native plugin is responsible for:
- Sending local player data to the server
- 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
thread
Planned later:
- Applying remote player updates safely
- Spawning or moving remote actors
### External Server
@@ -86,17 +89,20 @@ Current approach:
```text
Networking thread receives packets
Networking thread stores remote player state
Game update reads remote player state
Future game update applies actor movement
Game update reads a copied remote player snapshot
Game update moves the placed test proxy actor
```
The networking receive thread does not directly modify Fallout 4 actors or game
objects. It parses newline-separated JSON, updates plain C++ remote-player
state, and leaves actor spawning or movement for a later game-thread milestone.
state, and leaves actor access to `F4TProxyActorController` on the game-thread
update path.
## Out Of Scope For Current Prototype
- Remote actor spawning
- Multiple proxy actors
- Interpolation or smoothing
- Combat sync
- Quest sync
- Inventory sync
+37
View File
@@ -607,3 +607,40 @@ Use this format for future updates:
- Only move the proxy when the remote player is in the same cell/worldspace.
- Snap movement first.
- Add smoothing/interpolation later.
## 2026-05-31
### What Changed
- Changed the proxy actor controller plan from local-player-offset movement to
remote-state-driven movement.
- The placed proxy actor `F4TProxyRemotePlayer01REF` now represents the lowest
available remote `playerId` from a copied remote-player snapshot.
- Proxy movement remains game-thread-only and throttled to roughly 5 Hz.
- The networking receive thread still only parses packets and updates plain
remote-player state.
- Relaxed receive-side transform validation so interior/test-cell packets only
require `cellId`; missing `worldspaceId` is stored as an empty string.
- Added a same-test-cell proxy movement log for `F4TTestCell01`.
### What Worked
- Existing proxy actor lookup and safe handle caching were preserved.
- The local-player-offset safety movement remains available as a debug fallback
mode in code.
### What Broke
- Not tested in-game yet.
### Notes
- The proxy is left wherever it last moved after a remote disconnect for now.
- Dynamic spawning, multiple proxy actors, interpolation, and gameplay sync are
still intentionally out of scope.
### Next Steps
- Build the plugin with `xmake build`.
- Run the single-client, two-client, and disconnect tests in `F4TTestCell01`.
+5 -2
View File
@@ -2,7 +2,10 @@
namespace F4T::ProxyActorController
{
// Temporary game-thread-only actor-control safety test. This validates that the
// plugin can resolve and move the placed proxy before remote-player visual sync.
// Game-thread-only actor controller for the single placed proxy test actor.
// Networking threads must only update plain remote-player state.
void Update();
// Backward-compatible alias for the earlier local-player-offset safety test entry point.
void UpdateSafetyTest();
}
+14 -6
View File
@@ -35,14 +35,17 @@ This means the plugin should be able to:
* Connect to the local server on `127.0.0.1:7777`
* Send local transform packets
* Receive `welcome`, `transform`, and `disconnect` packets
* Store remote player state internally without spawning actors
* Store remote player state internally
* Move the single placed test proxy actor from remote player state on the game
thread without dynamically spawning actors
## Not Included Yet
The plugin does not currently include:
* Actor sync
* Creation Kit test cell integration
* Dynamic actor spawning
* Multiple proxy actors
* Interpolation or smoothing
* Remote player spawning
* Combat sync
* Quest sync
@@ -111,9 +114,14 @@ Use this checklist for the first plugin test:
* [ ] Plugin log shows `[LocalPlayerId=N] Assigned server playerId: N`
* [ ] Plugin logs prefixed remote player updates from another connected client
* [ ] Plugin logs prefixed remote player removal after a disconnect
* [ ] `Fallout4Together_Test.esp` is enabled
* [ ] `coc F4TTestCell01` places the local player in the test cell
* [ ] `F4TProxyRemotePlayer01REF` resolves in the test cell
* [ ] With two Fallout 4 instances in `F4TTestCell01`, each instance moves its
local proxy to the other player's networked position
## Notes
Remote actor spawning is intentionally not part of the current networking
prototype. The receive loop stores plain remote-player state first so later
actor work can be added from a stable data source.
Remote actor spawning is intentionally not part of the current prototype. The
receive loop stores plain remote-player state, and the game-thread proxy actor
controller consumes a copied snapshot to move the one placed test proxy actor.
+58 -9
View File
@@ -153,7 +153,7 @@ namespace
return true;
}
void LogThrottledWarning(const std::string& a_key, const char* a_message)
void LogThrottledWarning(const std::string& a_key, std::string_view a_message)
{
if (ShouldLogWarning(a_key)) {
LogWarningWithLocalPlayerPrefix(a_message);
@@ -248,6 +248,27 @@ namespace
return true;
}
std::optional<std::string_view> GetMissingRequiredTransformField(const Json& a_packet)
{
constexpr std::array<std::string_view, 7> requiredFields{
"type",
"playerId",
"x",
"y",
"z",
"angleZ",
"cellId"
};
for (const auto fieldName : requiredFields) {
if (a_packet.find(fieldName) == a_packet.end()) {
return fieldName;
}
}
return std::nullopt;
}
void HandleWelcomePacket(const Json& a_packet)
{
const auto playerId = ReadPlayerId(a_packet);
@@ -262,17 +283,42 @@ namespace
void HandleTransformPacket(const Json& a_packet)
{
if (const auto missingField = GetMissingRequiredTransformField(a_packet)) {
LogThrottledWarning(
"transform_missing_" + std::string(*missingField),
std::format("Ignoring transform packet missing required field '{}'.", *missingField));
return;
}
const auto playerId = ReadPlayerId(a_packet);
const auto x = ReadDouble(a_packet, "x");
const auto y = ReadDouble(a_packet, "y");
const auto z = ReadDouble(a_packet, "z");
const auto angleZ = ReadDouble(a_packet, "angleZ");
const auto movementType = ReadString(a_packet, "movementType");
const auto cellId = ReadString(a_packet, "cellId");
const auto worldspaceId = ReadString(a_packet, "worldspaceId");
if (!playerId || !x || !y || !z || !angleZ || !movementType || !cellId || !worldspaceId) {
LogThrottledWarning("transform_invalid", "Ignoring transform packet with missing or invalid required fields.");
if (!playerId) {
LogThrottledWarning("transform_invalid_playerId", "Ignoring transform packet with invalid required field 'playerId'.");
return;
}
if (!x) {
LogThrottledWarning("transform_invalid_x", "Ignoring transform packet with invalid required field 'x'.");
return;
}
if (!y) {
LogThrottledWarning("transform_invalid_y", "Ignoring transform packet with invalid required field 'y'.");
return;
}
if (!z) {
LogThrottledWarning("transform_invalid_z", "Ignoring transform packet with invalid required field 'z'.");
return;
}
if (!angleZ) {
LogThrottledWarning("transform_invalid_angleZ", "Ignoring transform packet with invalid required field 'angleZ'.");
return;
}
if (!cellId) {
LogThrottledWarning("transform_invalid_cellId", "Ignoring transform packet with invalid required field 'cellId'.");
return;
}
@@ -280,17 +326,20 @@ namespace
return;
}
const auto movementType = ReadString(a_packet, "movementType").value_or("normal");
const auto worldspaceId = ReadString(a_packet, "worldspaceId").value_or("");
F4T::RemotePlayerState::RemotePlayerState remoteState{
*playerId,
static_cast<float>(*x),
static_cast<float>(*y),
static_cast<float>(*z),
static_cast<float>(*angleZ),
*movementType,
movementType,
*cellId,
*worldspaceId,
ReadDouble(a_packet, "clientTime"),
ReadDouble(a_packet, "serverTime"),
worldspaceId,
ReadDouble(a_packet, "clientTime").value_or(0.0),
ReadDouble(a_packet, "serverTime").value_or(0.0),
std::chrono::steady_clock::now()
};
+225 -14
View File
@@ -3,11 +3,17 @@
#include "F4TProxyActorController.h"
#include "F4TNetworking.h"
#include "F4TRemotePlayerState.h"
#include <algorithm>
#include <charconv>
#include <cstdint>
#include <format>
#include <optional>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
namespace
{
@@ -21,6 +27,12 @@ namespace
constexpr auto kWarningLogInterval = 5s;
constexpr auto kNotInTestCellLogInterval = 30s;
enum class ProxyMovementMode
{
kSafetyOffsetTest,
kRemotePlayerStateTest
};
enum class ProxyLookupResult
{
kReferenceEditorId,
@@ -28,12 +40,19 @@ namespace
kFallbackFormId
};
constexpr auto kProxyMovementMode = ProxyMovementMode::kRemotePlayerStateTest;
RE::ObjectRefHandle g_proxyHandle;
bool g_proxyResolvedLogged = false;
bool g_proxyMovedLogged = false;
bool g_safetyOffsetMovedLogged = false;
bool g_remoteStateMovementStartedLogged = false;
bool g_remoteStateMovedLogged = false;
auto g_lastMovementTime = 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;
using RemotePlayer = F4T::RemotePlayerState::RemotePlayerState;
std::string GetLocalPlayerLogMessage(std::string_view a_message)
{
return F4T::Networking::GetLocalPlayerLogPrefix() + " " + std::string(a_message);
@@ -102,10 +121,101 @@ namespace
LogThrottledWarning(
"test_cell_unresolved",
"Proxy actor safety test could not resolve F4TTestCell01; actor movement is disabled.");
"Proxy actor controller could not resolve F4TTestCell01; actor movement is disabled.");
return false;
}
std::optional<std::uint32_t> ParseHexFormId(const std::string& a_formId)
{
if (a_formId.empty()) {
return std::nullopt;
}
std::uint32_t formId = 0;
const auto* begin = a_formId.data();
const auto* end = begin + a_formId.size();
const auto result = std::from_chars(begin, end, formId, 16);
if (result.ec != std::errc{} || result.ptr != end) {
return std::nullopt;
}
return formId;
}
std::optional<std::uint32_t> GetLocalWorldspaceId(
const RE::PlayerCharacter& a_player,
const RE::TESObjectCELL& a_parentCell)
{
if (a_player.cachedWorldspace) {
return a_player.cachedWorldspace->GetFormID();
}
if (a_parentCell.IsExterior() && a_parentCell.worldSpace) {
return a_parentCell.worldSpace->GetFormID();
}
return std::nullopt;
}
bool IsRemotePlayerInSameLocation(
const RemotePlayer& a_remotePlayer,
const RE::PlayerCharacter& a_player,
const RE::TESObjectCELL& a_parentCell)
{
const auto remoteCellId = ParseHexFormId(a_remotePlayer.cellId);
if (!remoteCellId) {
LogThrottledWarning(
"remote_cell_invalid_" + std::to_string(a_remotePlayer.playerId),
std::format(
"Remote player {} has an invalid cellId '{}'; proxy movement is skipped.",
a_remotePlayer.playerId,
a_remotePlayer.cellId));
return false;
}
const auto localCellId = a_parentCell.GetFormID();
if (localCellId != *remoteCellId) {
LogThrottledInfo(
"remote_cell_mismatch_" + std::to_string(a_remotePlayer.playerId),
std::format(
"Remote player {} is in a different cell: local={:08X}, remote={}. Proxy movement is skipped.",
a_remotePlayer.playerId,
localCellId,
a_remotePlayer.cellId));
return false;
}
// The current visual test is same-cell only. For the custom interior test cell,
// a matching cell ID is enough even when worldspace data is absent or stale.
if (IsTestCell(a_parentCell)) {
LogThrottledInfo(
"remote_same_test_cell_" + std::to_string(a_remotePlayer.playerId),
std::format(
"Remote player {} is in same test cell; moving proxy from remote state.",
a_remotePlayer.playerId));
return true;
}
const auto remoteWorldspaceId = ParseHexFormId(a_remotePlayer.worldspaceId);
const auto localWorldspaceId = GetLocalWorldspaceId(a_player, a_parentCell);
if (!localWorldspaceId && !remoteWorldspaceId) {
return true;
}
if (!localWorldspaceId || !remoteWorldspaceId || *localWorldspaceId != *remoteWorldspaceId) {
LogThrottledInfo(
"remote_worldspace_mismatch_" + std::to_string(a_remotePlayer.playerId),
std::format(
"Remote player {} is in a different worldspace: local={}, remote={}. Proxy movement is skipped.",
a_remotePlayer.playerId,
localWorldspaceId ? std::format("{:08X}", *localWorldspaceId) : "unavailable",
a_remotePlayer.worldspaceId.empty() ? "unavailable" : a_remotePlayer.worldspaceId));
return false;
}
return true;
}
bool IsValidProxyActor(const RE::Actor& a_proxy, const RE::TESObjectCELL& a_expectedCell, const RE::PlayerCharacter& a_player)
{
return std::addressof(a_proxy) != std::addressof(a_player) && a_proxy.GetParentCell() == std::addressof(a_expectedCell);
@@ -244,14 +354,14 @@ namespace
if (!proxy) {
LogThrottledWarning(
"proxy_missing",
"Proxy actor safety test could not find F4TProxyRemotePlayer01REF in F4TTestCell01.");
"Proxy actor controller could not find F4TProxyRemotePlayer01REF in F4TTestCell01.");
return nullptr;
}
g_proxyHandle = proxy->GetHandle();
if (!g_proxyResolvedLogged) {
LogInfoWithLocalPlayerPrefix(std::format(
"Proxy actor safety test resolved {} by {} as form {:08X}.",
"Proxy actor controller resolved {} by {} as form {:08X}.",
kProxyEditorId,
GetLookupResultDescription(lookupResult),
proxy->GetFormID()));
@@ -272,6 +382,63 @@ namespace
return true;
}
std::optional<RemotePlayer> SelectRemotePlayerToRepresent(std::vector<RemotePlayer> a_remotePlayers)
{
if (g_representedPlayerId) {
const auto representedPlayerStillExists = std::ranges::any_of(
a_remotePlayers,
[](const RemotePlayer& a_remotePlayer) {
return a_remotePlayer.playerId == *g_representedPlayerId;
});
if (!representedPlayerStillExists) {
LogInfoWithLocalPlayerPrefix(std::format(
"Represented remote player {} disconnected or disappeared from state; stopping proxy movement. TODO: hide or disable the proxy actor after disconnect.",
*g_representedPlayerId));
g_representedPlayerId.reset();
g_remoteStateMovedLogged = false;
}
}
if (a_remotePlayers.empty()) {
LogThrottledInfo(
"no_remote_players",
"No suitable remote player is available for proxy movement; leaving proxy actor idle.");
return std::nullopt;
}
auto selectedPlayer = std::ranges::min_element(
a_remotePlayers,
[](const RemotePlayer& a_left, const RemotePlayer& a_right) {
return a_left.playerId < a_right.playerId;
});
if (selectedPlayer == a_remotePlayers.end()) {
return std::nullopt;
}
if (a_remotePlayers.size() > 1) {
LogThrottledInfo(
"multiple_remote_players",
std::format(
"Multiple remote players are available; representing lowest playerId {} for now. TODO: add one proxy actor per remote player later.",
selectedPlayer->playerId));
}
if (g_representedPlayerId != selectedPlayer->playerId) {
g_representedPlayerId = selectedPlayer->playerId;
g_remoteStateMovedLogged = false;
if (!g_remoteStateMovementStartedLogged) {
LogInfoWithLocalPlayerPrefix("Remote-state-driven proxy movement started.");
g_remoteStateMovementStartedLogged = true;
}
LogInfoWithLocalPlayerPrefix(std::format(
"Proxy actor {} is representing remote player {}.",
kProxyEditorId,
selectedPlayer->playerId));
}
return *selectedPlayer;
}
void MoveProxyToPlayerOffset(RE::Actor& a_proxy, const RE::PlayerCharacter& a_player)
{
auto targetPosition = a_player.GetPosition();
@@ -280,48 +447,92 @@ namespace
a_proxy.SetPosition(targetPosition, true);
a_proxy.SetHeading(a_player.data.angle.z);
if (!g_proxyMovedLogged) {
if (!g_safetyOffsetMovedLogged) {
LogInfoWithLocalPlayerPrefix(std::format(
"Proxy actor safety test moved {} to player position plus X offset {:.2f}.",
kProxyEditorId,
kProxyOffsetX));
g_proxyMovedLogged = true;
g_safetyOffsetMovedLogged = true;
}
}
void MoveProxyToRemotePlayer(RE::Actor& a_proxy, const RemotePlayer& a_remotePlayer)
{
auto targetPosition = a_proxy.GetPosition();
targetPosition.x = a_remotePlayer.x;
targetPosition.y = a_remotePlayer.y;
targetPosition.z = a_remotePlayer.z;
a_proxy.SetPosition(targetPosition, true);
a_proxy.SetHeading(a_remotePlayer.angleZ);
if (!g_remoteStateMovedLogged) {
LogInfoWithLocalPlayerPrefix(std::format(
"Moved proxy actor {} from remote player {} state for the first time: X={:.2f}, Y={:.2f}, Z={:.2f}, AngleZ={:.2f}.",
kProxyEditorId,
a_remotePlayer.playerId,
a_remotePlayer.x,
a_remotePlayer.y,
a_remotePlayer.z,
a_remotePlayer.angleZ));
g_remoteStateMovedLogged = true;
}
}
}
namespace F4T::ProxyActorController
{
void UpdateSafetyTest()
void Update()
{
// This temporary safety test intentionally ignores remote-player network state.
// Later visual-sync milestones can replace the local-player offset with a
// game-thread snapshot of remote transforms.
auto* player = RE::PlayerCharacter::GetSingleton();
if (!player) {
LogThrottledWarning("player_missing", "Proxy actor safety test skipped: local player is not available yet.");
LogThrottledWarning("player_missing", "Proxy actor controller skipped: local player is not available yet.");
return;
}
auto* parentCell = player->GetParentCell();
if (!parentCell) {
LogThrottledWarning("cell_missing", "Proxy actor safety test skipped: local player parent cell is unavailable.");
LogThrottledWarning("cell_missing", "Proxy actor controller skipped: local player parent cell is unavailable.");
return;
}
if (!IsTestCell(*parentCell)) {
LogThrottledWarning(
"not_in_test_cell",
"Proxy actor safety test skipped: player is not in F4TTestCell01.",
"Proxy actor controller skipped: player is not in F4TTestCell01.",
kNotInTestCellLogInterval);
return;
}
if constexpr (kProxyMovementMode == ProxyMovementMode::kSafetyOffsetTest) {
auto* proxy = ResolveProxy(*parentCell, *player);
if (!proxy || !ShouldMoveProxy()) {
return;
}
MoveProxyToPlayerOffset(*proxy, *player);
return;
}
auto selectedRemotePlayer = SelectRemotePlayerToRepresent(F4T::RemotePlayerState::GetRemotePlayerSnapshot());
if (!selectedRemotePlayer) {
return;
}
auto* proxy = ResolveProxy(*parentCell, *player);
if (!proxy || !ShouldMoveProxy()) {
return;
}
MoveProxyToPlayerOffset(*proxy, *player);
if (!IsRemotePlayerInSameLocation(*selectedRemotePlayer, *player, *parentCell)) {
return;
}
MoveProxyToRemotePlayer(*proxy, *selectedRemotePlayer);
}
void UpdateSafetyTest()
{
Update();
}
}
+1 -1
View File
@@ -232,7 +232,7 @@ namespace
// check on the game thread after load, while the logging gate keeps updates readable.
taskInterface->AddTaskPermanent([]() {
CheckAndLogPlayerPositionChange();
F4T::ProxyActorController::UpdateSafetyTest();
F4T::ProxyActorController::Update();
});
pollingStarted = true;
+18 -3
View File
@@ -21,6 +21,11 @@ remote `playerId`, and removes remote state when disconnect packets arrive.
`server/fake_client.py` remains a lightweight test receiver for the same packet
lifecycle.
The plugin's game-thread proxy actor controller can now read a copied snapshot
of this remote-player state and move the single placed test proxy actor
`F4TProxyRemotePlayer01REF` in `F4TTestCell01`. The networking thread still does
not touch Fallout 4 actors or references.
## Current Remote Player State Model
The plugin and fake client store remote players by `playerId`.
@@ -41,7 +46,14 @@ serverTime
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`.
This model proves the data shape and lifecycle before spawning remote actors.
For the current visual milestone, the controller represents only one remote
player and chooses the lowest available `playerId` if more than one remote
player exists.
## Implemented
@@ -58,13 +70,16 @@ This model proves the data shape and lifecycle before spawning remote actors.
- The fake client stores remote player state by `playerId`.
- The server broadcasts `disconnect` packets.
- 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.
No Fallout 4 remote actor is spawned yet.
No Fallout 4 remote actor is dynamically spawned yet.
## Planned Later
- Add interpolation or smoothing after plugin-side state exists.
- Spawn and move remote actors only after the receive loop is stable.
- Add interpolation or smoothing after snap movement is proven stable.
- Add one proxy actor per remote player after the single-proxy test is stable.
- Dynamically spawn remote actors only after placed-proxy movement is stable.
## Not Required Yet
+6 -7
View File
@@ -22,18 +22,17 @@ def warn(message: str) -> None:
def update_remote_player_state(packet: dict[str, Any]) -> None:
required_fields = (
"type",
"playerId",
"x",
"y",
"z",
"angleZ",
"movementType",
"cellId",
"worldspaceId",
)
missing_fields = [field_name for field_name in required_fields if field_name not in packet]
if missing_fields:
warn(f"Ignoring transform packet missing {', '.join(missing_fields)}: {packet}")
warn(f"Ignoring transform packet missing required field {missing_fields[0]}: {packet}")
return
try:
@@ -52,11 +51,11 @@ def update_remote_player_state(packet: dict[str, Any]) -> None:
"y": y,
"z": z,
"angleZ": angle_z,
"movementType": packet["movementType"],
"movementType": packet.get("movementType", "normal"),
"cellId": packet["cellId"],
"worldspaceId": packet["worldspaceId"],
"clientTime": packet.get("clientTime"),
"serverTime": packet.get("serverTime"),
"worldspaceId": packet.get("worldspaceId", ""),
"clientTime": packet.get("clientTime", 0.0),
"serverTime": packet.get("serverTime", 0.0),
"lastReceivedLocalTime": time.time(),
}