From 23901108931fe3bc20c9e1f75ef987a702e2f08b Mon Sep 17 00:00:00 2001 From: Daniil Zakharov Date: Sat, 10 Oct 2026 00:20:30 +0300 Subject: [PATCH 1/4] Replace fixed 300 ms remote delay with adaptive playback Remote actors were shown a fixed 300 ms behind. Each now has a `PlaybackClock` that plays the owner's timeline back by the snapshot interval plus measured jitter, using network receive times so local stutters don't inflate it. Late snapshots are briefly extrapolated and corrected smoothly; teleports snap. Snapshots carry the owner's capture tick and are relayed by the server immediately and unreliably, at most 8 actors per message: ~30 Hz for players, mounts and actors in combat, ~10 Hz for other NPCs --- .../Components/InterpolationComponent.h | 12 +- .../Components/LocalAnimationComponent.h | 8 - Code/client/Services/CharacterService.h | 3 + .../Services/Debug/Views/ComponentView.cpp | 13 + .../Services/Debug/Views/NetworkView.cpp | 34 +++ .../Services/Generic/CharacterService.cpp | 97 +++++-- .../Services/Generic/TransportService.cpp | 13 +- Code/client/Services/TransportService.h | 11 +- Code/client/Systems/AnimationSystem.cpp | 57 ++-- Code/client/Systems/AnimationSystem.h | 22 +- Code/client/Systems/InterpolationSystem.cpp | 105 +++++-- Code/client/Systems/InterpolationSystem.h | 7 +- Code/client/Systems/PlaybackClock.cpp | 103 +++++++ Code/client/Systems/PlaybackClock.h | 70 +++++ Code/encoding/Messages/ClientMessageFactory.h | 3 +- .../Messages/ClientReferencesMoveRequest.h | 10 +- Code/encoding/Messages/NotifyActionEvents.cpp | 34 +++ Code/encoding/Messages/NotifyActionEvents.h | 25 ++ .../encoding/Messages/RequestActionEvents.cpp | 34 +++ Code/encoding/Messages/RequestActionEvents.h | 27 ++ Code/encoding/Messages/ServerMessageFactory.h | 3 +- .../Messages/ServerReferencesMoveRequest.h | 8 +- Code/encoding/Opcodes.h | 2 + Code/encoding/Structs/ReferenceUpdate.cpp | 41 --- Code/encoding/Structs/ReferenceUpdate.h | 22 -- Code/server/Components/AnimationComponent.h | 2 - Code/server/Components/MovementComponent.h | 2 - Code/server/Game/Player.cpp | 4 +- Code/server/Game/Player.h | 2 +- Code/server/GameServer.cpp | 4 +- Code/server/GameServer.h | 2 +- Code/server/Scripting/Component_Bindings.cpp | 1 - Code/server/Services/CharacterService.cpp | 144 ++++------ Code/server/Services/CharacterService.h | 4 +- Code/tests/encoding.cpp | 66 ++++- Code/tests/playback_clock.cpp | 260 ++++++++++++++++++ Code/tests/xmake.lua | 2 + 37 files changed, 982 insertions(+), 275 deletions(-) create mode 100644 Code/client/Systems/PlaybackClock.cpp create mode 100644 Code/client/Systems/PlaybackClock.h create mode 100644 Code/encoding/Messages/NotifyActionEvents.cpp create mode 100644 Code/encoding/Messages/NotifyActionEvents.h create mode 100644 Code/encoding/Messages/RequestActionEvents.cpp create mode 100644 Code/encoding/Messages/RequestActionEvents.h delete mode 100644 Code/encoding/Structs/ReferenceUpdate.cpp delete mode 100644 Code/encoding/Structs/ReferenceUpdate.h create mode 100644 Code/tests/playback_clock.cpp diff --git a/Code/client/Components/InterpolationComponent.h b/Code/client/Components/InterpolationComponent.h index a934714e5..12bef56ae 100644 --- a/Code/client/Components/InterpolationComponent.h +++ b/Code/client/Components/InterpolationComponent.h @@ -5,11 +5,13 @@ #endif #include +#include struct InterpolationComponent { struct TimePoint { + // The owner's capture tick uint64_t Tick{}; glm::vec3 Position{}; glm::vec3 Rotation{}; @@ -17,10 +19,16 @@ struct InterpolationComponent float Direction{}; TimePoint() = default; - TimePoint(const TimePoint&) = default; - TimePoint& operator=(const TimePoint&) = default; }; List TimePoints; + // Last displayed position glm::vec3 Position; + + PlaybackClock Clock; + + // Left over from correcting an extrapolation, decays over a few frames instead of popping the actor into place + glm::vec3 PositionError{}; + bool IsExtrapolating{false}; + bool HasNewTimePoint{false}; }; diff --git a/Code/client/Components/LocalAnimationComponent.h b/Code/client/Components/LocalAnimationComponent.h index 143aced46..3a47cf459 100644 --- a/Code/client/Components/LocalAnimationComponent.h +++ b/Code/client/Components/LocalAnimationComponent.h @@ -11,13 +11,5 @@ struct LocalAnimationComponent Vector Actions; ActionEvent LastProcessedAction; - [[nodiscard]] Outcome GetLatestAction() const noexcept - { - if (Actions.empty()) - return false; - - return Actions[Actions.size() - 1]; - } - void Append(const ActionEvent& acEvent) noexcept { Actions.push_back(acEvent); } }; diff --git a/Code/client/Services/CharacterService.h b/Code/client/Services/CharacterService.h index d3063d14d..031d4e24c 100644 --- a/Code/client/Services/CharacterService.h +++ b/Code/client/Services/CharacterService.h @@ -13,6 +13,7 @@ struct ActionEvent; struct AssignCharacterResponse; struct CharacterSpawnRequest; struct ServerReferencesMoveRequest; +struct NotifyActionEvents; struct NotifyInventoryChanges; struct NotifyFactionsChanges; struct NotifyRemoveCharacter; @@ -66,6 +67,7 @@ struct CharacterService void OnAssignCharacter(const AssignCharacterResponse& acMessage) noexcept; void OnCharacterSpawn(const CharacterSpawnRequest& acMessage) const noexcept; void OnReferencesMoveRequest(const ServerReferencesMoveRequest& acMessage) const noexcept; + void OnNotifyActionEvents(const NotifyActionEvents& acMessage) const noexcept; void OnActionEvent(const ActionEvent& acActionEvent) const noexcept; void OnFactionsChanges(const NotifyFactionsChanges& acEvent) const noexcept; void OnOwnershipTransfer(const NotifyOwnershipTransfer& acMessage) noexcept; @@ -146,6 +148,7 @@ struct CharacterService entt::scoped_connection m_assignCharacterConnection; entt::scoped_connection m_characterSpawnConnection; entt::scoped_connection m_referenceMovementSnapshotConnection; + entt::scoped_connection m_actionEventsConnection; entt::scoped_connection m_mountConnection; entt::scoped_connection m_notifyMountConnection; entt::scoped_connection m_initPackageConnection; diff --git a/Code/client/Services/Debug/Views/ComponentView.cpp b/Code/client/Services/Debug/Views/ComponentView.cpp index b26af0b0b..cd10cacab 100644 --- a/Code/client/Services/Debug/Views/ComponentView.cpp +++ b/Code/client/Services/Debug/Views/ComponentView.cpp @@ -141,6 +141,19 @@ void DebugService::DrawComponentDebugView() if (ImGui::CollapsingHeader("InterpolationComponent")) { ImGui::Text("%f,%f,%f\n", pComponent->Position.x, pComponent->Position.y, pComponent->Position.z); + + const auto& clock = pComponent->Clock; + if (!pComponent->TimePoints.empty()) + { + // How far playback runs behind the newest snapshot right now, negative while extrapolating + const double bufferedTime = static_cast(pComponent->TimePoints.back().Tick) - clock.GetRenderTick(); + + ImGui::Text( + "Buffered: %.1f ms (target %.1f ms)\nSnapshot interval: %.1f ms\nJitter: %.1f ms\nPlayback rate: %.3f\n" + "Extrapolating: %s\nCorrection: %.1f", + bufferedTime, clock.GetTargetDelay(), clock.GetSnapshotInterval(), clock.GetJitter(), clock.GetPlaybackRate(), + pComponent->IsExtrapolating ? "yes" : "no", glm::length(pComponent->PositionError)); + } } } diff --git a/Code/client/Services/Debug/Views/NetworkView.cpp b/Code/client/Services/Debug/Views/NetworkView.cpp index 8f2829124..bdd0a590a 100644 --- a/Code/client/Services/Debug/Views/NetworkView.cpp +++ b/Code/client/Services/Debug/Views/NetworkView.cpp @@ -2,6 +2,7 @@ #include #include +#include void DebugService::DrawNetworkView() { @@ -40,6 +41,39 @@ void DebugService::DrawNetworkView() ImGui::PlotLines("Lines", values, IM_ARRAYSIZE(values), values_offset, nullptr, 0.f, FLT_MAX, ImVec2(0, 160.0f)); } + // Applies to everything this client sends and receives, to see how remote actors hold up on a bad connection + if (ImGui::CollapsingHeader("Simulate bad connection")) + { + static int s_lag = 0; + static float s_loss = 0.f; + static float s_delayedPackets = 0.f; + static int s_extraDelay = 0; + + auto* pUtils = SteamNetworkingUtils(); + + if (ImGui::SliderInt("Lag each way (ms)", &s_lag, 0, 500)) + { + pUtils->SetGlobalConfigValueInt32(k_ESteamNetworkingConfig_FakePacketLag_Send, s_lag); + pUtils->SetGlobalConfigValueInt32(k_ESteamNetworkingConfig_FakePacketLag_Recv, s_lag); + } + + if (ImGui::SliderFloat("Packet loss (%)", &s_loss, 0.f, 50.f)) + { + pUtils->SetGlobalConfigValueFloat(k_ESteamNetworkingConfig_FakePacketLoss_Send, s_loss); + pUtils->SetGlobalConfigValueFloat(k_ESteamNetworkingConfig_FakePacketLoss_Recv, s_loss); + } + + // GameNetworkingSockets has no jitter setting, delaying a share of the packets is the closest to it + if (ImGui::SliderFloat("Delayed packets (%)", &s_delayedPackets, 0.f, 50.f)) + { + pUtils->SetGlobalConfigValueFloat(k_ESteamNetworkingConfig_FakePacketReorder_Send, s_delayedPackets); + pUtils->SetGlobalConfigValueFloat(k_ESteamNetworkingConfig_FakePacketReorder_Recv, s_delayedPackets); + } + + if (ImGui::SliderInt("Delayed packets extra lag (ms)", &s_extraDelay, 0, 300)) + pUtils->SetGlobalConfigValueInt32(k_ESteamNetworkingConfig_FakePacketReorder_Time, s_extraDelay); + } + /* float width = -ImGui::CalcItemWidth(); ImGui::PushItemWidth(width); diff --git a/Code/client/Services/Generic/CharacterService.cpp b/Code/client/Services/Generic/CharacterService.cpp index 52b0a0955..d22173d57 100644 --- a/Code/client/Services/Generic/CharacterService.cpp +++ b/Code/client/Services/Generic/CharacterService.cpp @@ -42,6 +42,8 @@ #include #include #include +#include +#include #include #include #include @@ -83,6 +85,7 @@ CharacterService::CharacterService(World& aWorld, entt::dispatcher& aDispatcher, m_assignCharacterConnection = m_dispatcher.sink().connect<&CharacterService::OnAssignCharacter>(this); m_characterSpawnConnection = m_dispatcher.sink().connect<&CharacterService::OnCharacterSpawn>(this); m_referenceMovementSnapshotConnection = m_dispatcher.sink().connect<&CharacterService::OnReferencesMoveRequest>(this); + m_actionEventsConnection = m_dispatcher.sink().connect<&CharacterService::OnNotifyActionEvents>(this); m_factionsConnection = m_dispatcher.sink().connect<&CharacterService::OnFactionsChanges>(this); m_ownershipTransferConnection = m_dispatcher.sink().connect<&CharacterService::OnOwnershipTransfer>(this); m_removeCharacterConnection = m_dispatcher.sink().connect<&CharacterService::OnRemoveCharacter>(this); @@ -640,9 +643,13 @@ void CharacterService::OnCharacterSpawn(const CharacterSpawnRequest& acMessage) void CharacterService::OnReferencesMoveRequest(const ServerReferencesMoveRequest& acMessage) const noexcept { - auto view = m_world.view(); + // When it came off the network, not when this frame got to it. Otherwise a long frame on our side would look like + // network jitter and make every remote actor play further behind for seconds. + const double cArrivalTime = m_transport.GetMessageReceiveTime(); - for (const auto& [serverId, update] : acMessage.Updates) + auto view = m_world.view(); + + for (const auto& [serverId, movement] : acMessage.Updates) { auto itor = std::find_if(std::begin(view), std::end(view), [serverId = serverId, view](entt::entity entity) { return view.get(entity).Id == serverId; }); @@ -650,8 +657,6 @@ void CharacterService::OnReferencesMoveRequest(const ServerReferencesMoveRequest continue; auto& interpolationComponent = view.get(*itor); - auto& animationComponent = view.get(*itor); - const auto& movement = update.UpdatedMovement; InterpolationComponent::TimePoint point; point.Tick = acMessage.Tick; @@ -660,9 +665,25 @@ void CharacterService::OnReferencesMoveRequest(const ServerReferencesMoveRequest point.Variables = movement.Variables; point.Direction = movement.Direction; - InterpolationSystem::AddPoint(interpolationComponent, point); + InterpolationSystem::AddPoint(interpolationComponent, std::move(point), cArrivalTime); + } +} + +void CharacterService::OnNotifyActionEvents(const NotifyActionEvents& acMessage) const noexcept +{ + auto view = m_world.view(); + + for (const auto& [serverId, actions] : acMessage.Actions) + { + auto itor = std::find_if(std::begin(view), std::end(view), [serverId = serverId, view](entt::entity entity) { return view.get(entity).Id == serverId; }); + + if (itor == std::end(view)) + continue; + + auto& animationComponent = view.get(*itor); - for (const auto& action : update.ActionEvents) + // Played once the actor's playback clock reaches their tick, in step with its movement + for (const auto& action : actions) { animationComponent.TimePoints.push_back(action); } @@ -1724,18 +1745,30 @@ void CharacterService::ProcessLeveledConforms() noexcept void CharacterService::RunLocalUpdates() const noexcept { - static std::chrono::steady_clock::time_point lastSendTimePoint; - constexpr auto cDelayBetweenSnapshots = 100ms; + // Remote clients show actors about one snapshot interval late, so players, mounts and actors in combat get every snapshot + // and the rest every few. Assumes 60+ fps: 30ms keeps 60 fps gaps at two frames, with 33ms most were three (~25 Hz). + constexpr auto cDelayBetweenSnapshots = 30ms; + constexpr uint32_t cLowPrioritySnapshotDivider = 3; + // Movement is unreliable, keeping each message around a single packet (~120 bytes per humanoid) means a lost packet + // only drops a few actors + constexpr size_t cMaxActorsPerMovementMessage = 8; + + static std::chrono::steady_clock::time_point lastSnapshotTimePoint; + static uint32_t snapshotCount = 0; const auto now = std::chrono::steady_clock::now(); - if (now - lastSendTimePoint < cDelayBetweenSnapshots) + if (now - lastSnapshotTimePoint < cDelayBetweenSnapshots) return; - lastSendTimePoint = now; + lastSnapshotTimePoint = now; + const bool cIsFullSnapshot = ++snapshotCount % cLowPrioritySnapshotDivider == 0; - ClientReferencesMoveRequest message; - message.Tick = m_transport.GetClock().GetCurrentTick(); + RequestActionEvents actionsMessage; + ClientReferencesMoveRequest movementMessage; + movementMessage.Tick = m_transport.GetClock().GetCurrentTick(); + + const auto* pPlayer = PlayerCharacter::Get(); auto animatedLocalView = m_world.view(); for (auto entity : animatedLocalView) @@ -1744,16 +1777,39 @@ void CharacterService::RunLocalUpdates() const noexcept auto& animationComponent = animatedLocalView.get(entity); auto& formIdComponent = animatedLocalView.get(entity); - AnimationSystem::Serialize(m_world, message, localComponent, animationComponent, formIdComponent); + auto* pActor = Cast(TESForm::GetById(formIdComponent.Id)); + if (!pActor) + continue; + + // Remote clients play actions on the same timeline as movement, by the time playback reaches an action it waits + // for the snapshot after it anyway, so sending actions along with that snapshot doesn't delay them + AnimationSystem::SerializeActions(actionsMessage, localComponent, animationComponent); + + if (!cIsFullSnapshot && pActor != pPlayer && !pActor->IsMount() && !pActor->IsInCombat()) + continue; + + if (movementMessage.Updates.size() == cMaxActorsPerMovementMessage) + { + m_transport.Send(movementMessage, TiltedPhoques::kUnreliable); + movementMessage.Updates.clear(); + } + + AnimationSystem::SerializeMovement(m_world, pActor, movementMessage.Updates[localComponent.Id]); } - m_transport.Send(message); + if (!actionsMessage.Actions.empty()) + m_transport.Send(actionsMessage); + + // NoNagle on the last message sends everything queued this frame right away, in as few packets as possible + if (!movementMessage.Updates.empty()) + m_transport.Send(movementMessage, TiltedPhoques::kUnreliableNoNagle); } void CharacterService::RunRemoteUpdates() noexcept { - // Delay by 300ms to let the interpolation system accumulate interpolation points - const auto tick = m_transport.GetClock().GetCurrentTick() - 300; + // Each remote actor is played back on its owner's timeline, only as far behind as its snapshots need, see PlaybackClock. + // Playback can only rely on snapshots that have been handled, so it runs on the time they were last picked up at. + const double cNow = m_transport.GetLastPollTime(); // Interpolation has to keep running even if the actor is not in view, otherwise we will never know if we need to spawn it auto interpolatedEntities = m_world.view(); @@ -1770,10 +1826,10 @@ void CharacterService::RunRemoteUpdates() noexcept pActor = Cast(pForm); } - InterpolationSystem::Update(pActor, interpolationComponent, tick); + InterpolationSystem::Update(pActor, interpolationComponent, cNow); } - auto animatedView = m_world.view(); + auto animatedView = m_world.view(); for (auto entity : animatedView) { @@ -1785,7 +1841,10 @@ void CharacterService::RunRemoteUpdates() noexcept if (!pActor) continue; - AnimationSystem::Update(m_world, pActor, animationComponent, tick); + // Actions run on the clock the interpolation pass above just advanced, so the body and its animations stay in step + const auto cTick = InterpolationSystem::GetPlaybackTick(animatedView.get(entity)); + + AnimationSystem::Update(m_world, pActor, animationComponent, cTick); } auto facegenView = m_world.view(); diff --git a/Code/client/Services/Generic/TransportService.cpp b/Code/client/Services/Generic/TransportService.cpp index 0f3f684dc..ed490d150 100644 --- a/Code/client/Services/Generic/TransportService.cpp +++ b/Code/client/Services/Generic/TransportService.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include @@ -65,7 +66,7 @@ TransportService::TransportService(World& aWorld, entt::dispatcher& aDispatcher) }; } -bool TransportService::Send(const ClientMessage& acMessage) const noexcept +bool TransportService::Send(const ClientMessage& acMessage, const TiltedPhoques::EPacketFlags aPacketFlags) const noexcept { static thread_local ScratchAllocator s_allocator(1 << 18); @@ -85,7 +86,7 @@ bool TransportService::Send(const ClientMessage& acMessage) const noexcept acMessage.Serialize(writer); TiltedPhoques::PacketView packet(reinterpret_cast(buffer.GetWriteData()), writer.Size()); - Client::Send(&packet); + Client::Send(&packet, aPacketFlags); return true; } @@ -178,8 +179,16 @@ void TransportService::OnUpdate() { } +double TransportService::GetMessageReceiveTime() const noexcept +{ + return static_cast(GetCurrentMessageReceiveTime()) / 1000.0; +} + void TransportService::HandleUpdate(const UpdateEvent& acEvent) noexcept { + // Taken before polling, so that every message that arrived before this time gets handled by this update + m_lastPollTime = static_cast(SteamNetworkingUtils()->GetLocalTimestamp()) / 1000.0; + Update(); } diff --git a/Code/client/Services/TransportService.h b/Code/client/Services/TransportService.h index 8f2c3503e..2ef91acbf 100644 --- a/Code/client/Services/TransportService.h +++ b/Code/client/Services/TransportService.h @@ -26,7 +26,7 @@ struct TransportService : Client TP_NOCOPYMOVE(TransportService); - bool Send(const ClientMessage& acMessage) const noexcept; + bool Send(const ClientMessage& acMessage, TiltedPhoques::EPacketFlags aPacketFlags = TiltedPhoques::kReliable) const noexcept; void OnConsume(const void* apData, uint32_t aSize) override; void OnConnected() override; @@ -37,6 +37,14 @@ struct TransportService : Client void SetServerPassword(const std::string& acPassword) noexcept { m_serverPassword = acPassword; } const uint32_t& GetLocalPlayerId() const noexcept { return m_localPlayerId; } + // Times below are in ms on the networking library's local clock, which keeps running while the game thread is stalled + + // When the message being handled arrived from the network, it may have waited out a long frame since. + // Only meaningful while a received message is being handled. + [[nodiscard]] double GetMessageReceiveTime() const noexcept; + // When received messages were last picked up, everything that arrived before then has been handled + [[nodiscard]] double GetLastPollTime() const noexcept { return m_lastPollTime; } + protected: // Event handlers void HandleUpdate(const UpdateEvent& acEvent) noexcept; @@ -53,6 +61,7 @@ struct TransportService : Client bool m_connected; String m_serverPassword{}; uint32_t m_localPlayerId; + double m_lastPollTime{}; entt::scoped_connection m_updateConnection; entt::scoped_connection m_sendServerMessageConnection; diff --git a/Code/client/Systems/AnimationSystem.cpp b/Code/client/Systems/AnimationSystem.cpp index 31227b1dc..5d2a12b1d 100644 --- a/Code/client/Systems/AnimationSystem.cpp +++ b/Code/client/Systems/AnimationSystem.cpp @@ -11,7 +11,8 @@ #include #include -#include +#include +#include #include #include @@ -82,8 +83,13 @@ void AnimationSystem::Clean(World& aWorld, const entt::entity aEntity) noexcept void AnimationSystem::AddActionsForReplay(RemoteAnimationComponent& aAnimationComponent, const ActionReplayChain& acReplay) noexcept { - aAnimationComponent.TimePoints.insert(aAnimationComponent.TimePoints.end(), acReplay.Actions.begin(), - acReplay.Actions.end()); + // A replay rebuilds past state, so it is due right away rather than when playback reaches the original ticks + for (auto action : acReplay.Actions) + { + action.Tick = 0; + aAnimationComponent.TimePoints.push_back(std::move(action)); + } + aAnimationComponent.ReplayCount = acReplay.Actions.size(); aAnimationComponent.ResetAnimationGraphForReplay = acReplay.ResetAnimationGraph; } @@ -103,44 +109,35 @@ void AnimationSystem::AddAction(RemoteAnimationComponent& aAnimationComponent, c aAnimationComponent.TimePoints.push_back(lastProcessedAction); } -void AnimationSystem::Serialize(World& aWorld, ClientReferencesMoveRequest& aMovementSnapshot, LocalComponent& localComponent, LocalAnimationComponent& animationComponent, FormIdComponent& formIdComponent) +void AnimationSystem::SerializeMovement(World& aWorld, Actor* apActor, Movement& aMovement) noexcept { - const auto pForm = TESForm::GetById(formIdComponent.Id); - const auto pActor = Cast(pForm); - if (!pActor) - return; - - auto& update = aMovementSnapshot.Updates[localComponent.Id]; - auto& movement = update.UpdatedMovement; - - if (const auto pCell = pActor->parentCell) - World::Get().GetModSystem().GetServerModId(pCell->formID, movement.CellId.ModId, movement.CellId.BaseId); + if (const auto pCell = apActor->parentCell) + aWorld.GetModSystem().GetServerModId(pCell->formID, aMovement.CellId.ModId, aMovement.CellId.BaseId); - if (const auto pWorldSpace = pActor->GetWorldSpace()) - World::Get().GetModSystem().GetServerModId(pWorldSpace->formID, movement.WorldSpaceId.ModId, movement.WorldSpaceId.BaseId); + if (const auto pWorldSpace = apActor->GetWorldSpace()) + aWorld.GetModSystem().GetServerModId(pWorldSpace->formID, aMovement.WorldSpaceId.ModId, aMovement.WorldSpaceId.BaseId); - movement.Position = pActor->position; + aMovement.Position = apActor->position; - movement.Rotation.x = pActor->rotation.x; - movement.Rotation.y = pActor->rotation.z; + aMovement.Rotation.x = apActor->rotation.x; + aMovement.Rotation.y = apActor->rotation.z; - pActor->SaveAnimationVariables(movement.Variables); + apActor->SaveAnimationVariables(aMovement.Variables); - if (pActor->currentProcess && pActor->currentProcess->middleProcess) + if (apActor->currentProcess && apActor->currentProcess->middleProcess) { - movement.Direction = pActor->currentProcess->middleProcess->direction; - } - - for (auto& entry : animationComponent.Actions) - { - update.ActionEvents.push_back(entry); + aMovement.Direction = apActor->currentProcess->middleProcess->direction; } +} - auto latestAction = animationComponent.GetLatestAction(); +void AnimationSystem::SerializeActions(RequestActionEvents& aMessage, LocalComponent& localComponent, LocalAnimationComponent& animationComponent) noexcept +{ + if (animationComponent.Actions.empty()) + return; - if (latestAction) - localComponent.CurrentAction = latestAction.MoveResult(); + localComponent.CurrentAction = animationComponent.Actions.back(); + aMessage.Actions[localComponent.Id] = std::move(animationComponent.Actions); animationComponent.Actions.clear(); } diff --git a/Code/client/Systems/AnimationSystem.h b/Code/client/Systems/AnimationSystem.h index 78fb1e69a..94b920d41 100644 --- a/Code/client/Systems/AnimationSystem.h +++ b/Code/client/Systems/AnimationSystem.h @@ -4,7 +4,8 @@ struct World; struct Actor; -struct ClientReferencesMoveRequest; +struct Movement; +struct RequestActionEvents; /** * @brief Applies animations coming from remote actors. @@ -16,7 +17,7 @@ struct AnimationSystem * @param aWorld The registry where the actor in question lives. * @param apActor The actor to-be-updated. * @param aAnimationComponent The animation component attached to the actor. - * @param aTick The current tick. + * @param aTick The owner tick currently being played back, actions up to it are run. */ static void Update(World& aWorld, Actor* apActor, RemoteAnimationComponent& aAnimationComponent, uint64_t aTick) noexcept; /** @@ -47,14 +48,19 @@ struct AnimationSystem */ static void AddAction(RemoteAnimationComponent& aAnimationComponent, const std::string& acActionDiff) noexcept; /** - * @brief Serializes the actions to-be-sent. + * @brief Captures the movement snapshot of a local actor. * @param aWorld The registry where the actor in question lives. - * @param aMovementSnapshot The output of the animation data. - * @param localComponent The local component of the actor whose data is to be serialized. - * @param animationComponent The local animation component of the actor, used to give the output the server id of the actor. - * @param formIdComponent The form id component of the actor, used to fetch the actor pointer. + * @param apActor The local actor. + * @param aMovement The output movement snapshot. + */ + static void SerializeMovement(World& aWorld, Actor* apActor, Movement& aMovement) noexcept; + /** + * @brief Moves the actions a local actor performed since the last call into the message to-be-sent. + * @param aMessage The output message. + * @param localComponent The local component of the actor, gives the server id and tracks the latest action. + * @param animationComponent The local animation component of the actor holding the pending actions. */ - static void Serialize(World& aWorld, ClientReferencesMoveRequest& aMovementSnapshot, LocalComponent& localComponent, LocalAnimationComponent& animationComponent, FormIdComponent& formIdComponent); + static void SerializeActions(RequestActionEvents& aMessage, LocalComponent& localComponent, LocalAnimationComponent& animationComponent) noexcept; /** * @brief Serializes the actions to-be-sent. * diff --git a/Code/client/Systems/InterpolationSystem.cpp b/Code/client/Systems/InterpolationSystem.cpp index 8b0870ba6..e5fdb7e30 100644 --- a/Code/client/Systems/InterpolationSystem.cpp +++ b/Code/client/Systems/InterpolationSystem.cpp @@ -9,17 +9,35 @@ #include #include -void InterpolationSystem::Update(Actor* apActor, InterpolationComponent& aInterpolationComponent, const uint64_t aTick) noexcept +namespace { +// Past the newest snapshot the actor keeps moving for at most this long, then holds still until data arrives +constexpr double cMaxExtrapolation = 150.0; +// Consecutive snapshots further apart than this are a teleport, not movement to interpolate across +constexpr float cTeleportDistance = 1024.f; +// Larger corrections snap instead of sliding the actor into place +constexpr float cMaxPositionCorrection = 256.f; +// Time constant of the correction fading out, ~95% of it is gone after three times this +constexpr double cPositionCorrectionTime = 50.0; +constexpr size_t cMaxTimePoints = 64; +} // namespace + +void InterpolationSystem::Update(Actor* apActor, InterpolationComponent& aInterpolationComponent, const double aNow) noexcept +{ + auto& clock = aInterpolationComponent.Clock; + const double cDeltaTime = clock.Advance(aNow); + auto& movements = aInterpolationComponent.TimePoints; if (movements.size() < 2) return; + const double cRenderTick = clock.GetRenderTick(); + while (movements.size() > 2) { - const auto second = *(++movements.begin()); - if (aTick > second.Tick) + const auto& second = *(++movements.begin()); + if (cRenderTick >= static_cast(second.Tick)) movements.pop_front(); else break; @@ -28,25 +46,57 @@ void InterpolationSystem::Update(Actor* apActor, InterpolationComponent& aInterp const auto& first = *(movements.begin()); const auto& second = *(++movements.begin()); - // Calculate delta movement since last update - auto delta = 0.0001f; - const auto tickDelta = static_cast(second.Tick - first.Tick); - if (tickDelta > 0.f) + const auto tickDelta = static_cast(second.Tick - first.Tick); + const bool cIsTeleport = glm::distance(first.Position, second.Position) > cTeleportDistance; + + glm::vec3 position{}; + auto delta = 1.f; + bool isExtrapolating = false; + + if (cRenderTick <= static_cast(second.Tick)) + { + if (tickDelta > 0.0) + delta = static_cast(std::clamp((cRenderTick - static_cast(first.Tick)) / tickDelta, 0.0, 1.0)); + + // Stay at the old position until the teleport happens + if (cIsTeleport && delta < 1.f) + delta = 0.f; + + position = TiltedPhoques::Lerp(first.Position, second.Position, delta); + } + else { - delta = 1.f / tickDelta * static_cast(aTick - first.Tick); + // The next snapshot is late, keep going the way the actor was moving rather than freezing it + position = second.Position; + + if (!cIsTeleport && tickDelta > 0.0) + { + const double cExtrapolationTime = std::min(cRenderTick - static_cast(second.Tick), cMaxExtrapolation); + position = TiltedPhoques::Lerp(first.Position, second.Position, static_cast(1.0 + cExtrapolationTime / tickDelta)); + isExtrapolating = true; + } } - delta = TiltedPhoques::Min(delta, 1.0f); + auto& positionError = aInterpolationComponent.PositionError; - const NiPoint3 position{TiltedPhoques::Lerp(first.Position, second.Position, delta)}; + // The late snapshot arrived, so the actor is no longer where extrapolation put it + if (aInterpolationComponent.HasNewTimePoint && aInterpolationComponent.IsExtrapolating) + positionError = aInterpolationComponent.Position - position; - aInterpolationComponent.Position = position; + if (cIsTeleport || glm::length(positionError) > cMaxPositionCorrection) + positionError = {}; + else + positionError *= static_cast(std::exp(-cDeltaTime / cPositionCorrectionTime)); + + aInterpolationComponent.HasNewTimePoint = false; + aInterpolationComponent.IsExtrapolating = isExtrapolating; + aInterpolationComponent.Position = position + positionError; // Don't try to move a null actor if (!apActor) return; - apActor->ForcePosition(position); + apActor->ForcePosition(aInterpolationComponent.Position); apActor->LoadAnimationVariables(second.Variables); if (apActor->currentProcess && apActor->currentProcess->middleProcess) @@ -71,24 +121,29 @@ void InterpolationSystem::Update(Actor* apActor, InterpolationComponent& aInterp apActor->SetRotation(finalX, finalY, finalZ); } -void InterpolationSystem::AddPoint(InterpolationComponent& aInterpolationComponent, const InterpolationComponent::TimePoint& acPoint) noexcept +void InterpolationSystem::AddPoint(InterpolationComponent& aInterpolationComponent, InterpolationComponent::TimePoint aPoint, const double aArrivalTime) noexcept { - auto itor = std::begin(aInterpolationComponent.TimePoints); - const auto end = std::cend(aInterpolationComponent.TimePoints); - - while (itor != end) - { - if (itor->Tick > acPoint.Tick) - { - aInterpolationComponent.TimePoints.insert(itor, acPoint); + if (!aInterpolationComponent.Clock.AddSample(aArrivalTime, aPoint.Tick)) + return; - return; - } + auto& timePoints = aInterpolationComponent.TimePoints; + // Snapshots are unreliable and can arrive out of order + auto itor = std::begin(timePoints); + while (itor != std::end(timePoints) && itor->Tick < aPoint.Tick) ++itor; - } - aInterpolationComponent.TimePoints.push_back(acPoint); + timePoints.insert(itor, std::move(aPoint)); + aInterpolationComponent.HasNewTimePoint = true; + + while (timePoints.size() > cMaxTimePoints) + timePoints.pop_front(); +} + +uint64_t InterpolationSystem::GetPlaybackTick(const InterpolationComponent& acInterpolationComponent) noexcept +{ + // The render tick stays at 0 until the first snapshot arrives + return static_cast(std::max(acInterpolationComponent.Clock.GetRenderTick(), 0.0)); } InterpolationComponent& InterpolationSystem::Setup(World& aWorld, const entt::entity aEntity) noexcept diff --git a/Code/client/Systems/InterpolationSystem.h b/Code/client/Systems/InterpolationSystem.h index d08cc11d4..6a4476410 100644 --- a/Code/client/Systems/InterpolationSystem.h +++ b/Code/client/Systems/InterpolationSystem.h @@ -8,8 +8,11 @@ struct Actor; */ struct InterpolationSystem { - static void Update(Actor* apActor, InterpolationComponent& aInterpolationComponent, uint64_t aTick) noexcept; - static void AddPoint(InterpolationComponent& aInterpolationComponent, const InterpolationComponent::TimePoint& acPoint) noexcept; + // aNow and aArrivalTime are on the same local clock, see PlaybackClock + static void Update(Actor* apActor, InterpolationComponent& aInterpolationComponent, double aNow) noexcept; + static void AddPoint(InterpolationComponent& aInterpolationComponent, InterpolationComponent::TimePoint aPoint, double aArrivalTime) noexcept; + // The owner tick that remote actions should be played up to, 0 until the first snapshot has arrived + static uint64_t GetPlaybackTick(const InterpolationComponent& acInterpolationComponent) noexcept; static InterpolationComponent& Setup(World& aWorld, entt::entity aEntity) noexcept; static void Clean(World& aWorld, entt::entity aEntity) noexcept; }; diff --git a/Code/client/Systems/PlaybackClock.cpp b/Code/client/Systems/PlaybackClock.cpp new file mode 100644 index 000000000..b7a59d15b --- /dev/null +++ b/Code/client/Systems/PlaybackClock.cpp @@ -0,0 +1,103 @@ +#include "PlaybackClock.h" + +#include +#include +#include + +bool PlaybackClock::AddSample(const double aArrivalTime, const uint64_t aCaptureTick) noexcept +{ + // A duplicate carries no new timing information and would skew the interval estimate + for (size_t i = 0; i < m_sampleCount; ++i) + { + if (m_samples[i].CaptureTick == aCaptureTick) + return false; + } + + const bool cIsFirstSample = !IsRunning(); + + m_samples[m_nextSample] = {aArrivalTime, aCaptureTick}; + m_nextSample = (m_nextSample + 1) % cMaxSamples; + m_sampleCount = std::min(m_sampleCount + 1, cMaxSamples); + + UpdateEstimates(aArrivalTime); + + if (cIsFirstSample) + { + m_lastAdvanceTime = aArrivalTime; + m_renderTick = GetDesiredRenderTick(aArrivalTime); + } + + return true; +} + +double PlaybackClock::Advance(const double aNow) noexcept +{ + if (!IsRunning()) + return 0.0; + + const double delta = std::max(aNow - m_lastAdvanceTime, 0.0); + m_lastAdvanceTime = aNow; + + // The playback rate was picked to close the last error. A long frame must not carry the correction past it, or playback + // would end up off by as much the other way, e.g. ahead of every snapshot that has arrived. + const double cCorrection = std::clamp(delta * (m_playbackRate - 1.0), std::min(m_error, 0.0), std::max(m_error, 0.0)); + m_renderTick += delta + cCorrection; + + const double desiredRenderTick = GetDesiredRenderTick(aNow); + m_error = desiredRenderTick - m_renderTick; + + if (std::abs(m_error) > cSnapThreshold) + { + m_renderTick = desiredRenderTick; + m_playbackRate = 1.0; + m_error = 0.0; + } + else + { + // Small enough changes in playback speed are not noticeable, unlike jumps in time + m_playbackRate = 1.0 + std::clamp(m_error / cFullRateAdjustmentError * cMaxRateAdjustment, -cMaxRateAdjustment, cMaxRateAdjustment); + } + + return delta; +} + +void PlaybackClock::UpdateEstimates(const double aNewestArrivalTime) noexcept +{ + std::array transits; + size_t count = 0; + uint64_t oldestCaptureTick = std::numeric_limits::max(); + uint64_t newestCaptureTick = 0; + + for (size_t i = 0; i < m_sampleCount; ++i) + { + const auto& sample = m_samples[i]; + if (sample.ArrivalTime < aNewestArrivalTime - cSampleWindow) + continue; + + // Includes the network delay and the unknown offset between both clocks, only differences between transits are meaningful + transits[count++] = sample.ArrivalTime - static_cast(sample.CaptureTick); + oldestCaptureTick = std::min(oldestCaptureTick, sample.CaptureTick); + newestCaptureTick = std::max(newestCaptureTick, sample.CaptureTick); + } + + // The sample that triggered the update is always within the window + const auto end = transits.begin() + count; + m_minTransit = *std::min_element(transits.begin(), end); + + // Rounded up so that few samples give a conservative estimate + const auto percentile = transits.begin() + static_cast(std::ceil(cJitterPercentile * static_cast(count - 1))); + std::nth_element(transits.begin(), percentile, end); + m_jitter = *percentile - m_minTransit; + + // An average is enough because owners send at an even pace, see RunLocalUpdates + if (count >= 2) + m_snapshotInterval = static_cast(newestCaptureTick - oldestCaptureTick) / static_cast(count - 1); + + m_targetDelay = std::clamp(m_snapshotInterval + m_jitter + cSafetyMargin, cMinTargetDelay, cMaxTargetDelay); +} + +double PlaybackClock::GetDesiredRenderTick(const double aNow) const noexcept +{ + // aNow - m_minTransit is the newest owner tick that could have arrived by now + return aNow - m_minTransit - m_targetDelay; +} diff --git a/Code/client/Systems/PlaybackClock.h b/Code/client/Systems/PlaybackClock.h new file mode 100644 index 000000000..ba5f74ac3 --- /dev/null +++ b/Code/client/Systems/PlaybackClock.h @@ -0,0 +1,70 @@ +#pragma once + +#include +#include +#include + +/** + * @brief Picks which owner tick of a remote actor to show, just far enough behind for the next snapshot to be there. + * + * The delay comes from capture ticks vs. arrival times, so the clocks needn't be in sync, and changes are eased in by + * playing slightly faster or slower. Times are ms on one monotonic clock. Arrivals should be network receive times, so + * our own long frames don't look like jitter. + */ +struct PlaybackClock +{ + // Returns false for a duplicate + bool AddSample(double aArrivalTime, uint64_t aCaptureTick) noexcept; + // Returns the time elapsed since the last call + double Advance(double aNow) noexcept; + + [[nodiscard]] bool IsRunning() const noexcept { return m_sampleCount > 0; } + // The owner tick to show now + [[nodiscard]] double GetRenderTick() const noexcept { return m_renderTick; } + [[nodiscard]] double GetTargetDelay() const noexcept { return m_targetDelay; } + [[nodiscard]] double GetJitter() const noexcept { return m_jitter; } + [[nodiscard]] double GetSnapshotInterval() const noexcept { return m_snapshotInterval; } + [[nodiscard]] double GetPlaybackRate() const noexcept { return m_playbackRate; } + + // Estimates use the samples that arrived within this window + static constexpr double cSampleWindow = 3000.0; + static constexpr size_t cMaxSamples = 128; + // Until two snapshots have arrived + static constexpr double cDefaultSnapshotInterval = 100.0; + // Later arrivals are left to extrapolation + static constexpr double cJitterPercentile = 0.95; + static constexpr double cSafetyMargin = 8.0; + static constexpr double cMinTargetDelay = 16.0; + static constexpr double cMaxTargetDelay = 250.0; + // Most playback may speed up or slow down while catching up + static constexpr double cMaxRateAdjustment = 0.1; + // Error in ms that gets the full rate adjustment + static constexpr double cFullRateAdjustmentError = 50.0; + // Larger errors jump instead of catching up + static constexpr double cSnapThreshold = 250.0; + +private: + struct Sample + { + double ArrivalTime; + uint64_t CaptureTick; + }; + + void UpdateEstimates(double aNewestArrivalTime) noexcept; + [[nodiscard]] double GetDesiredRenderTick(double aNow) const noexcept; + + std::array m_samples{}; + size_t m_sampleCount{0}; + size_t m_nextSample{0}; + + double m_minTransit{0.0}; + double m_jitter{0.0}; + double m_snapshotInterval{cDefaultSnapshotInterval}; + double m_targetDelay{cDefaultSnapshotInterval + cSafetyMargin}; + + double m_renderTick{0.0}; + double m_lastAdvanceTime{0.0}; + double m_playbackRate{1.0}; + // Left from the last Advance(), the playback rate is set to close it + double m_error{0.0}; +}; diff --git a/Code/encoding/Messages/ClientMessageFactory.h b/Code/encoding/Messages/ClientMessageFactory.h index a3b1352b4..4f94d6d10 100644 --- a/Code/encoding/Messages/ClientMessageFactory.h +++ b/Code/encoding/Messages/ClientMessageFactory.h @@ -44,6 +44,7 @@ #include #include #include +#include #include #include #include @@ -69,7 +70,7 @@ struct ClientMessageFactory PartyChangeLeaderRequest, PartyKickRequest, RequestActorValueChanges, RequestActorMaxValueChanges, EnterExteriorCellRequest, RequestHealthChangeBroadcast, ActivateRequest, LockChangeRequest, AssignObjectsRequest, RequestDeathStateChange, ShiftGridCellRequest, RequestOwnershipTransfer, RequestOwnershipClaim, RequestObjectInventoryChanges, SpellCastRequest, ProjectileLaunchRequest, InterruptCastRequest, AddTargetRequest, ScriptAnimationRequest, DrawWeaponRequest, MountRequest, NewPackageRequest, RequestRespawn, SyncExperienceRequest, RequestEquipmentChanges, SendChatMessageRequest, TeleportCommandRequest, PlayerRespawnRequest, DialogueRequest, SubtitleRequest, PlayerDialogueRequest, PlayerLevelRequest, TeleportRequest, RequestPlayerHealthUpdate, RequestWeatherChange, RequestCurrentWeather, RequestSetWaypoint, - RequestRemoveWaypoint, RemoveSpellRequest, SetTimeCommandRequest>; + RequestRemoveWaypoint, RemoveSpellRequest, SetTimeCommandRequest, RequestActionEvents>; return s_visitor(std::forward(func)); } diff --git a/Code/encoding/Messages/ClientReferencesMoveRequest.h b/Code/encoding/Messages/ClientReferencesMoveRequest.h index e76c40ecf..ef013fc89 100644 --- a/Code/encoding/Messages/ClientReferencesMoveRequest.h +++ b/Code/encoding/Messages/ClientReferencesMoveRequest.h @@ -1,10 +1,13 @@ #pragma once #include "Message.h" -#include +#include using TiltedPhoques::String; +/** + * @brief Movement snapshot of actors owned by the sender. Sent unreliably, so it can be lost or arrive out of order. + */ struct ClientReferencesMoveRequest final : ClientMessage { static constexpr ClientOpcode Opcode = kClientReferencesMoveRequest; @@ -19,8 +22,9 @@ struct ClientReferencesMoveRequest final : ClientMessage void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; - bool operator==(const ClientReferencesMoveRequest& acRhs) const noexcept { return Updates == acRhs.Updates && GetOpcode() == acRhs.GetOpcode(); } + bool operator==(const ClientReferencesMoveRequest& acRhs) const noexcept { return Updates == acRhs.Updates && Tick == acRhs.Tick && GetOpcode() == acRhs.GetOpcode(); } + // Synchronized clock tick at which the owner captured the snapshot uint64_t Tick{}; - TiltedPhoques::Map Updates{}; + TiltedPhoques::Map Updates{}; }; diff --git a/Code/encoding/Messages/NotifyActionEvents.cpp b/Code/encoding/Messages/NotifyActionEvents.cpp new file mode 100644 index 000000000..cfa55925d --- /dev/null +++ b/Code/encoding/Messages/NotifyActionEvents.cpp @@ -0,0 +1,34 @@ +#include +#include + +void NotifyActionEvents::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Serialization::WriteVarInt(aWriter, Actions.size()); + + for (const auto& [serverId, actions] : Actions) + { + Serialization::WriteVarInt(aWriter, serverId); + Serialization::WriteVarInt(aWriter, actions.size()); + + for (const auto& action : actions) + action.GenerateDifferential(ActionEvent{}, aWriter); + } +} + +void NotifyActionEvents::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ServerMessage::DeserializeRaw(aReader); + + const auto count = Serialization::ReadVarInt(aReader); + + for (auto i = 0u; i < count; ++i) + { + const uint32_t cServerId = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; + auto& actions = Actions[cServerId]; + + actions.resize(Serialization::ReadVarInt(aReader)); + + for (auto& action : actions) + action.ApplyDifferential(aReader); + } +} diff --git a/Code/encoding/Messages/NotifyActionEvents.h b/Code/encoding/Messages/NotifyActionEvents.h new file mode 100644 index 000000000..ed4df50c1 --- /dev/null +++ b/Code/encoding/Messages/NotifyActionEvents.h @@ -0,0 +1,25 @@ +#pragma once + +#include "Message.h" +#include + +/** + * @brief Animation actions relayed as soon as the owner's RequestActionEvents arrives. Sent reliably. + */ +struct NotifyActionEvents final : ServerMessage +{ + static constexpr ServerOpcode Opcode = kNotifyActionEvents; + + NotifyActionEvents() + : ServerMessage(Opcode) + { + } + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + bool operator==(const NotifyActionEvents& acRhs) const noexcept { return Actions == acRhs.Actions && GetOpcode() == acRhs.GetOpcode(); } + + // Server id -> actions in the order they were performed + TiltedPhoques::Map> Actions{}; +}; diff --git a/Code/encoding/Messages/RequestActionEvents.cpp b/Code/encoding/Messages/RequestActionEvents.cpp new file mode 100644 index 000000000..3a8ab8491 --- /dev/null +++ b/Code/encoding/Messages/RequestActionEvents.cpp @@ -0,0 +1,34 @@ +#include +#include + +void RequestActionEvents::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Serialization::WriteVarInt(aWriter, Actions.size()); + + for (const auto& [serverId, actions] : Actions) + { + Serialization::WriteVarInt(aWriter, serverId); + Serialization::WriteVarInt(aWriter, actions.size()); + + for (const auto& action : actions) + action.GenerateDifferential(ActionEvent{}, aWriter); + } +} + +void RequestActionEvents::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ClientMessage::DeserializeRaw(aReader); + + const auto count = Serialization::ReadVarInt(aReader); + + for (auto i = 0u; i < count; ++i) + { + const uint32_t cServerId = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; + auto& actions = Actions[cServerId]; + + actions.resize(Serialization::ReadVarInt(aReader)); + + for (auto& action : actions) + action.ApplyDifferential(aReader); + } +} diff --git a/Code/encoding/Messages/RequestActionEvents.h b/Code/encoding/Messages/RequestActionEvents.h new file mode 100644 index 000000000..825188608 --- /dev/null +++ b/Code/encoding/Messages/RequestActionEvents.h @@ -0,0 +1,27 @@ +#pragma once + +#include "Message.h" +#include + +/** + * @brief Animation actions performed by actors owned by the sender, sent reliably along with each movement snapshot. + */ +struct RequestActionEvents final : ClientMessage +{ + static constexpr ClientOpcode Opcode = kRequestActionEvents; + + RequestActionEvents() + : ClientMessage(Opcode) + { + } + + virtual ~RequestActionEvents() = default; + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + bool operator==(const RequestActionEvents& acRhs) const noexcept { return Actions == acRhs.Actions && GetOpcode() == acRhs.GetOpcode(); } + + // Server id -> actions in the order they were performed + TiltedPhoques::Map> Actions{}; +}; diff --git a/Code/encoding/Messages/ServerMessageFactory.h b/Code/encoding/Messages/ServerMessageFactory.h index fe078ee6d..516878b1d 100644 --- a/Code/encoding/Messages/ServerMessageFactory.h +++ b/Code/encoding/Messages/ServerMessageFactory.h @@ -58,6 +58,7 @@ #include #include #include +#include using TiltedPhoques::UniquePtr; @@ -72,7 +73,7 @@ struct ServerMessageFactory NotifyActorValueChanges, NotifyPartyJoined, NotifyPartyLeft, NotifyActorMaxValueChanges, NotifyHealthChangeBroadcast, NotifyActivate, NotifyLockChange, AssignObjectsResponse, NotifyDeathStateChange, NotifyOwnershipTransfer, NotifyObjectInventoryChanges, NotifySpellCast, NotifyProjectileLaunch, NotifyInterruptCast, NotifyAddTarget, NotifyScriptAnimation, NotifyDrawWeapon, NotifyMount, NotifyNewPackage, NotifyRespawn, NotifySyncExperience, NotifyEquipmentChanges, NotifyChatMessageBroadcast, TeleportCommandResponse, NotifyPlayerRespawn, NotifyDialogue, NotifySubtitle, NotifyPlayerDialogue, NotifyActorTeleport, NotifyPlayerLeft, NotifyPlayerJoined, NotifyDialogue, NotifySubtitle, NotifyPlayerDialogue, NotifyPlayerLevel, NotifyPlayerCellChanged, NotifyTeleport, NotifyPlayerHealthUpdate, NotifySettingsChange, - NotifyWeatherChange, NotifySetWaypoint, NotifyRemoveWaypoint, NotifySetTimeResult, NotifyRemoveSpell>; + NotifyWeatherChange, NotifySetWaypoint, NotifyRemoveWaypoint, NotifySetTimeResult, NotifyRemoveSpell, NotifyActionEvents>; return s_visitor(std::forward(func)); } diff --git a/Code/encoding/Messages/ServerReferencesMoveRequest.h b/Code/encoding/Messages/ServerReferencesMoveRequest.h index 72abbd603..c7ab95c7d 100644 --- a/Code/encoding/Messages/ServerReferencesMoveRequest.h +++ b/Code/encoding/Messages/ServerReferencesMoveRequest.h @@ -1,10 +1,13 @@ #pragma once #include "Message.h" -#include +#include using TiltedPhoques::String; +/** + * @brief Movement snapshot relayed as soon as the owner's ClientReferencesMoveRequest arrives. Sent unreliably. + */ struct ServerReferencesMoveRequest final : ServerMessage { static constexpr ServerOpcode Opcode = kServerReferencesMoveRequest; @@ -19,6 +22,7 @@ struct ServerReferencesMoveRequest final : ServerMessage bool operator==(const ServerReferencesMoveRequest& acRhs) const noexcept { return Updates == acRhs.Updates && Tick == acRhs.Tick && GetOpcode() == acRhs.GetOpcode(); } + // The owner's capture tick, forwarded untouched uint64_t Tick{}; - TiltedPhoques::Map Updates{}; + TiltedPhoques::Map Updates{}; }; diff --git a/Code/encoding/Opcodes.h b/Code/encoding/Opcodes.h index 3e421e97e..baab493cc 100644 --- a/Code/encoding/Opcodes.h +++ b/Code/encoding/Opcodes.h @@ -53,6 +53,7 @@ enum ClientOpcode : unsigned char kRequestSetWaypoint, kRequestRemoveWaypoint, kSetTimeCommandRequest, + kRequestActionEvents, kClientOpcodeMax }; @@ -112,5 +113,6 @@ enum ServerOpcode : unsigned char kNotifySetWaypoint, kNotifyRemoveWaypoint, kNotifySetTimeResult, + kNotifyActionEvents, kServerOpcodeMax }; diff --git a/Code/encoding/Structs/ReferenceUpdate.cpp b/Code/encoding/Structs/ReferenceUpdate.cpp deleted file mode 100644 index d3a224353..000000000 --- a/Code/encoding/Structs/ReferenceUpdate.cpp +++ /dev/null @@ -1,41 +0,0 @@ -#include -#include -#include - -using TiltedPhoques::Serialization; - -bool ReferenceUpdate::operator==(const ReferenceUpdate& acRhs) const noexcept -{ - return UpdatedMovement == acRhs.UpdatedMovement && ActionEvents == acRhs.ActionEvents; -} - -bool ReferenceUpdate::operator!=(const ReferenceUpdate& acRhs) const noexcept -{ - return !this->operator==(acRhs); -} - -void ReferenceUpdate::Serialize(TiltedPhoques::Buffer::Writer& aWriter) const noexcept -{ - UpdatedMovement.Serialize(aWriter); - - Serialization::WriteVarInt(aWriter, ActionEvents.size()); - - for (auto& entry : ActionEvents) - { - entry.GenerateDifferential(ActionEvent{}, aWriter); - } -} - -void ReferenceUpdate::Deserialize(TiltedPhoques::Buffer::Reader& aReader) -{ - UpdatedMovement.Deserialize(aReader); - - const auto count = Serialization::ReadVarInt(aReader); - - ActionEvents.resize(count); - - for (auto i = 0u; i < count; ++i) - { - ActionEvents[i].ApplyDifferential(aReader); - } -} diff --git a/Code/encoding/Structs/ReferenceUpdate.h b/Code/encoding/Structs/ReferenceUpdate.h deleted file mode 100644 index ff5a344d3..000000000 --- a/Code/encoding/Structs/ReferenceUpdate.h +++ /dev/null @@ -1,22 +0,0 @@ -#pragma once - -#include -#include - -using TiltedPhoques::Buffer; -using TiltedPhoques::Vector; - -struct ReferenceUpdate -{ - ReferenceUpdate() = default; - ~ReferenceUpdate() = default; - - bool operator==(const ReferenceUpdate& acRhs) const noexcept; - bool operator!=(const ReferenceUpdate& acRhs) const noexcept; - - void Serialize(TiltedPhoques::Buffer::Writer& aWriter) const noexcept; - void Deserialize(TiltedPhoques::Buffer::Reader& aReader); - - Movement UpdatedMovement{}; - Vector ActionEvents{}; -}; diff --git a/Code/server/Components/AnimationComponent.h b/Code/server/Components/AnimationComponent.h index 2310ed4b6..f3d9ee1d3 100644 --- a/Code/server/Components/AnimationComponent.h +++ b/Code/server/Components/AnimationComponent.h @@ -9,7 +9,5 @@ struct AnimationComponent { - Vector Actions; - ActionEvent CurrentAction; ActionReplayCache ActionsReplayCache; }; diff --git a/Code/server/Components/MovementComponent.h b/Code/server/Components/MovementComponent.h index b7768eda6..a9b94b86d 100644 --- a/Code/server/Components/MovementComponent.h +++ b/Code/server/Components/MovementComponent.h @@ -13,6 +13,4 @@ struct MovementComponent glm::vec3 Rotation; AnimationVariables Variables; float Direction; - - bool Sent; }; diff --git a/Code/server/Game/Player.cpp b/Code/server/Game/Player.cpp index 8bfbcf798..c71ae269e 100644 --- a/Code/server/Game/Player.cpp +++ b/Code/server/Game/Player.cpp @@ -95,7 +95,7 @@ void Player::SetCellComponent(const CellIdComponent& aCellComponent) noexcept m_cell = aCellComponent; } -void Player::Send(const ServerMessage& acServerMessage) const +void Player::Send(const ServerMessage& acServerMessage, const TiltedPhoques::EPacketFlags aPacketFlags) const { - GameServer::Get()->Send(GetConnectionId(), acServerMessage); + GameServer::Get()->Send(GetConnectionId(), acServerMessage, aPacketFlags); } diff --git a/Code/server/Game/Player.h b/Code/server/Game/Player.h index ed80ad004..c1aa7e860 100644 --- a/Code/server/Game/Player.h +++ b/Code/server/Game/Player.h @@ -40,7 +40,7 @@ struct Player void SetCellComponent(const CellIdComponent& aCellComponent) noexcept; - void Send(const ServerMessage& acServerMessage) const; + void Send(const ServerMessage& acServerMessage, TiltedPhoques::EPacketFlags aPacketFlags = TiltedPhoques::kReliable) const; private: uint32_t m_id{0}; diff --git a/Code/server/GameServer.cpp b/Code/server/GameServer.cpp index bc03fd1eb..886854afa 100644 --- a/Code/server/GameServer.cpp +++ b/Code/server/GameServer.cpp @@ -660,7 +660,7 @@ void GameServer::OnDisconnection(const ConnectionId_t aConnectionId, EDisconnect UpdateTitle(); } -void GameServer::Send(const ConnectionId_t aConnectionId, const ServerMessage& acServerMessage) const +void GameServer::Send(const ConnectionId_t aConnectionId, const ServerMessage& acServerMessage, const TiltedPhoques::EPacketFlags aPacketFlags) const { static thread_local TiltedPhoques::ScratchAllocator s_allocator{1 << 18}; @@ -671,7 +671,7 @@ void GameServer::Send(const ConnectionId_t aConnectionId, const ServerMessage& a acServerMessage.Serialize(writer); TiltedPhoques::PacketView packet(reinterpret_cast(buffer.GetWriteData()), static_cast(writer.Size())); - Server::Send(aConnectionId, &packet); + Server::Send(aConnectionId, &packet, aPacketFlags); s_allocator.Reset(); } diff --git a/Code/server/GameServer.h b/Code/server/GameServer.h index d29365372..1660e285d 100644 --- a/Code/server/GameServer.h +++ b/Code/server/GameServer.h @@ -54,7 +54,7 @@ struct GameServer final : Server void UpdateSettings(); // Packet dispatching - void Send(ConnectionId_t aConnectionId, const ServerMessage& acServerMessage) const; + void Send(ConnectionId_t aConnectionId, const ServerMessage& acServerMessage, TiltedPhoques::EPacketFlags aPacketFlags = TiltedPhoques::kReliable) const; void Send(ConnectionId_t aConnectionId, const ServerAdminMessage& acServerMessage) const; void SendToLoaded(const ServerMessage& acServerMessage) const; void SendToPlayers(const ServerMessage& acServerMessage, const Player* apExcludeSender = nullptr) const; diff --git a/Code/server/Scripting/Component_Bindings.cpp b/Code/server/Scripting/Component_Bindings.cpp index d4887d035..441445148 100644 --- a/Code/server/Scripting/Component_Bindings.cpp +++ b/Code/server/Scripting/Component_Bindings.cpp @@ -18,7 +18,6 @@ void BindMovementComponent(sol::state_view aState) table["Rotation"] = &MovementComponent::Rotation; // movementComponentType["Variables"] = &MovementComponent::Variables; table["Direction"] = &MovementComponent::Direction; - table["Sent"] = &MovementComponent::Sent; } } // namespace diff --git a/Code/server/Services/CharacterService.cpp b/Code/server/Services/CharacterService.cpp index e1e4b040d..94212bd5d 100644 --- a/Code/server/Services/CharacterService.cpp +++ b/Code/server/Services/CharacterService.cpp @@ -17,6 +17,8 @@ #include #include #include +#include +#include #include #include #include @@ -56,6 +58,7 @@ CharacterService::CharacterService(World& aWorld, entt::dispatcher& aDispatcher) , m_removeCharacterConnection(aDispatcher.sink().connect<&CharacterService::OnCharacterRemoveEvent>(this)) , m_characterSpawnedConnection(aDispatcher.sink().connect<&CharacterService::OnCharacterSpawned>(this)) , m_referenceMovementSnapshotConnection(aDispatcher.sink>().connect<&CharacterService::OnReferencesMoveRequest>(this)) + , m_actionEventsConnection(aDispatcher.sink>().connect<&CharacterService::OnActionEventsRequest>(this)) , m_factionsChangesConnection(aDispatcher.sink>().connect<&CharacterService::OnFactionsChanges>(this)) , m_mountConnection(aDispatcher.sink>().connect<&CharacterService::OnMountRequest>(this)) , m_newPackageConnection(aDispatcher.sink>().connect<&CharacterService::OnNewPackageRequest>(this)) @@ -136,7 +139,6 @@ void CharacterService::Serialize(World& aRegistry, entt::entity aEntity, Charact void CharacterService::OnUpdate(const UpdateEvent&) const noexcept { ProcessFactionsChanges(); - ProcessMovementChanges(); } void CharacterService::OnCharacterExteriorCellChange(const CharacterExteriorCellChangeEvent& acEvent) const noexcept @@ -302,7 +304,6 @@ void CharacterService::OnOwnershipTransferRequest(const PacketEvent(*it); movementComponent.Position = message.Position; - movementComponent.Sent = true; } // A normal release starts a fresh search. A declined grant continues the current @@ -369,13 +370,17 @@ void CharacterService::OnCharacterSpawned(const CharacterSpawnedEvent& acEvent) void CharacterService::OnReferencesMoveRequest(const PacketEvent& acMessage) const noexcept { - OwnerView view(m_world, acMessage.GetSender()); + OwnerView view(m_world, acMessage.GetSender()); auto& message = acMessage.Packet; - for (auto& entry : message.Updates) + // Relayed as soon as it arrives, with the owner's capture tick, so receivers can schedule it on the owner's timeline. + // Snapshots can arrive out of order, receivers sort them out. + Map messages; + + for (auto& [id, movement] : message.Updates) { - const auto entity = static_cast(entry.first); + const auto entity = static_cast(id); auto itor = view.find(entity); if (itor == std::end(view)) @@ -386,15 +391,9 @@ void CharacterService::OnReferencesMoveRequest(const PacketEvent(*itor); auto& cellIdComponent = view.get(*itor); - auto& animationComponent = view.get(*itor); + const auto& characterComponent = view.get(*itor); movementComponent.Tick = message.Tick; - - const auto movementCopy = movementComponent; - - auto& update = entry.second; - auto& movement = update.UpdatedMovement; - movementComponent.Position = movement.Position; movementComponent.Rotation = glm::vec3(movement.Rotation.x, 0.f, movement.Rotation.y); movementComponent.Variables = movement.Variables; @@ -404,20 +403,53 @@ void CharacterService::OnReferencesMoveRequest(const PacketEventGetWorld().GetScriptService().HandleCharacterMove(entity); - if (canceled) + if (pPlayer == acMessage.pPlayer || !cellIdComponent.IsInRange(pPlayer->GetCellComponent(), characterComponent.IsDragon())) continue; - animationComponent.CurrentAction = action; + auto& relay = messages[pPlayer]; + relay.Tick = message.Tick; + relay.Updates[id] = movement; + } + } + + // Nagle is left on so relays from different owners within a few ms share packets + for (auto& [pPlayer, relay] : messages) + pPlayer->Send(relay, TiltedPhoques::kUnreliable); +} - animationComponent.Actions.push_back(animationComponent.CurrentAction); +void CharacterService::OnActionEventsRequest(const PacketEvent& acMessage) const noexcept +{ + OwnerView view(m_world, acMessage.GetSender()); + + auto& message = acMessage.Packet; + + for (auto& [id, actions] : message.Actions) + { + const auto entity = static_cast(id); + + auto itor = view.find(entity); + if (itor == std::end(view)) + { + spdlog::debug("{:x} requested actions of {:x} but does not exist", acMessage.pPlayer->GetConnectionId(), World::ToInteger(entity)); + continue; } - animationComponent.ActionsReplayCache.AppendAll(update.ActionEvents); + NotifyActionEvents notify; + auto& acceptedActions = notify.Actions[id]; + + for (auto& action : actions) + { + auto [canceled, reason] = GameServer::Get()->GetWorld().GetScriptService().HandleCharacterMove(entity); + if (!canceled) + acceptedActions.push_back(action); + } - movementComponent.Sent = false; + view.get(*itor).ActionsReplayCache.AppendAll(actions); + + if (!acceptedActions.empty()) + GameServer::Get()->SendToPlayersInRange(notify, entity, acMessage.pPlayer); } } @@ -647,7 +679,6 @@ void CharacterService::CreateCharacter(const PacketEvent movementComponent.Tick = pServer->GetTick(); movementComponent.Position = message.Position; movementComponent.Rotation = {message.Rotation.x, 0.f, message.Rotation.y}; - movementComponent.Sent = false; m_world.emplace(cEntity); @@ -946,76 +977,3 @@ void CharacterService::ProcessFactionsChanges() const noexcept pPlayer->Send(message); } } - -void CharacterService::ProcessMovementChanges() const noexcept -{ - static std::chrono::steady_clock::time_point lastSendTimePoint; - constexpr auto cDelayBetweenSnapshots = 1000ms / 50; - - const auto now = std::chrono::steady_clock::now(); - if (now - lastSendTimePoint < cDelayBetweenSnapshots) - return; - - lastSendTimePoint = now; - - const auto characterView = m_world.view(); - - TiltedPhoques::Map messages; - - for (auto pPlayer : m_world.GetPlayerManager()) - { - auto& message = messages[pPlayer]; - - message.Tick = GameServer::Get()->GetTick(); - } - - for (auto entity : characterView) - { - auto& characterComponent = characterView.get(entity); - auto& movementComponent = characterView.get(entity); - auto& cellIdComponent = characterView.get(entity); - auto& ownerComponent = characterView.get(entity); - auto& animationComponent = characterView.get(entity); - - // If we have nothing new to send skip this - if (movementComponent.Sent == true) - continue; - - for (auto pPlayer : m_world.GetPlayerManager()) - { - if (pPlayer == ownerComponent.GetOwner()) - continue; - - if (!cellIdComponent.IsInRange(pPlayer->GetCellComponent(), characterComponent.IsDragon())) - continue; - - auto& message = messages[pPlayer]; - auto& update = message.Updates[World::ToInteger(entity)]; - auto& movement = update.UpdatedMovement; - - movement.Position = movementComponent.Position; - - movement.Rotation.x = movementComponent.Rotation.x; - movement.Rotation.y = movementComponent.Rotation.z; - - movement.Direction = movementComponent.Direction; - movement.Variables = movementComponent.Variables; - - update.ActionEvents = animationComponent.Actions; - } - } - - m_world.view().each([](AnimationComponent& animationComponent) - { - // Remove actions we've sent - animationComponent.Actions.clear(); - }); - - m_world.view().each([](MovementComponent& movementComponent) { movementComponent.Sent = true; }); - - for (auto& [pPlayer, message] : messages) - { - if (!message.Updates.empty()) - pPlayer->Send(message); - } -} diff --git a/Code/server/Services/CharacterService.h b/Code/server/Services/CharacterService.h index 866321c2f..78b2cc94e 100644 --- a/Code/server/Services/CharacterService.h +++ b/Code/server/Services/CharacterService.h @@ -11,6 +11,7 @@ struct AssignCharacterRequest; struct AssignCharacterResponse; struct CharacterSpawnRequest; struct ClientReferencesMoveRequest; +struct RequestActionEvents; struct RequestFactionsChanges; struct GridCellCoords; struct RequestOwnershipTransfer; @@ -58,6 +59,7 @@ struct CharacterService void OnCharacterRemoveEvent(const CharacterRemoveEvent& acEvent) const noexcept; void OnCharacterSpawned(const CharacterSpawnedEvent& acEvent) const noexcept; void OnReferencesMoveRequest(const PacketEvent& acMessage) const noexcept; + void OnActionEventsRequest(const PacketEvent& acMessage) const noexcept; void OnFactionsChanges(const PacketEvent& acMessage) const noexcept; void OnMountRequest(const PacketEvent& acMessage) const noexcept; void OnNewPackageRequest(const PacketEvent& acMessage) const noexcept; @@ -75,7 +77,6 @@ struct CharacterService ActorData BuildActorData(const entt::entity acEntity) const noexcept; void ProcessFactionsChanges() const noexcept; - void ProcessMovementChanges() const noexcept; private: World& m_world; @@ -90,6 +91,7 @@ struct CharacterService entt::scoped_connection m_removeCharacterConnection; entt::scoped_connection m_characterSpawnedConnection; entt::scoped_connection m_referenceMovementSnapshotConnection; + entt::scoped_connection m_actionEventsConnection; entt::scoped_connection m_factionsChangesConnection; entt::scoped_connection m_mountConnection; entt::scoped_connection m_newPackageConnection; diff --git a/Code/tests/encoding.cpp b/Code/tests/encoding.cpp index 0e137d35e..defb80841 100644 --- a/Code/tests/encoding.cpp +++ b/Code/tests/encoding.cpp @@ -430,8 +430,10 @@ TEST_CASE("Packets", "[encoding.packets]") GIVEN("ClientReferencesMoveRequest") { ClientReferencesMoveRequest sendMessage, recvMessage; - auto& update = sendMessage.Updates[1]; - auto& move = update.UpdatedMovement; + sendMessage.Tick = 1712345678901; + auto& move = sendMessage.Updates[1]; + move.Position.x = 1234.5f; + move.Direction = 0.75f; AnimationVariables vars; vars.Booleans.resize(76); @@ -465,7 +467,65 @@ TEST_CASE("Packets", "[encoding.packets]") recvMessage.DeserializeRaw(reader); - REQUIRE(recvMessage.Updates[1].UpdatedMovement == sendMessage.Updates[1].UpdatedMovement); + REQUIRE(recvMessage.Tick == sendMessage.Tick); + REQUIRE(recvMessage.Updates[1] == sendMessage.Updates[1]); + } + + GIVEN("RequestActionEvents") + { + ActionEvent firstAction; + firstAction.Tick = 1712345678901; + firstAction.ActionId = 42; + firstAction.TargetId = 963741; + firstAction.State1 = 6547; + firstAction.EventName = "jumpStandingStart"; + + ActionEvent secondAction = firstAction; + secondAction.Tick += 120; + secondAction.ActionId = 43; + secondAction.EventName = "attackStart"; + secondAction.TargetEventName = "toast"; + + RequestActionEvents sendMessage, recvMessage; + sendMessage.Actions[1] = {firstAction, secondAction}; + sendMessage.Actions[7] = {secondAction}; + + Buffer buff(1000); + Buffer::Writer writer(&buff); + sendMessage.Serialize(writer); + + Buffer::Reader reader(&buff); + + uint64_t trash; + reader.ReadBits(trash, 8); // pop opcode + + recvMessage.DeserializeRaw(reader); + + REQUIRE(sendMessage == recvMessage); + } + + GIVEN("NotifyActionEvents") + { + ActionEvent action; + action.Tick = 1712345678901; + action.ActionId = 42; + action.EventName = "blockStart"; + + NotifyActionEvents sendMessage; + sendMessage.Actions[3] = {action, action}; + + Buffer buff(1000); + Buffer::Writer writer(&buff); + sendMessage.Serialize(writer); + + Buffer::Reader reader(&buff); + + const ServerMessageFactory factory; + auto pMessage = factory.Extract(reader); + + REQUIRE(pMessage); + REQUIRE(pMessage->GetOpcode() == sendMessage.GetOpcode()); + REQUIRE(*CastUnique(std::move(pMessage)) == sendMessage); } } diff --git a/Code/tests/playback_clock.cpp b/Code/tests/playback_clock.cpp new file mode 100644 index 000000000..fd1c47c77 --- /dev/null +++ b/Code/tests/playback_clock.cpp @@ -0,0 +1,260 @@ +#include + +#include "../client/Systems/PlaybackClock.h" + +#include +#include +#include +#include + +namespace +{ +constexpr double cFrameTime = 1000.0 / 60.0; +// Estimates settle within the sample window, only judge the clock after that +constexpr double cWarmup = PlaybackClock::cSampleWindow; + +struct NetworkConditions +{ + // Like RunLocalUpdates, the owner sends on the first of its frames that is at least this long after its previous snapshot + double SnapshotInterval = 30.0; + double OwnerFrameTime = 1000.0 / 60.0; + // Each owner frame is up to this much shorter or longer + double OwnerFrameJitter = 0.0; + double Latency = 10.0; + // Every snapshot is delayed by a uniformly random extra amount up to this + double MaxJitter = 0.0; + // The owner's clock reads this much more than the local one, the clock must not care + uint64_t OwnerClockOffset = 1'000'000; + // Our frame starting at HitchTime takes this much longer, snapshots keep arriving meanwhile + double HitchTime = 0.0; + double HitchLength = 0.0; + double Duration = 10000.0; + uint32_t Seed = 1; +}; + +struct SimulationResult +{ + // How far behind the owner the displayed tick is, network latency included + double MaxDelay = 0.0; + double MeanDelay = 0.0; + // Frames that displayed a tick no snapshot had reached yet, i.e. that had to extrapolate + size_t StarvedFrames = 0; + size_t Frames = 0; + // Whether playback always advanced at close to real time speed, never jumped or ran backwards + bool IsAlwaysSmooth = true; +}; + +// Snapshots are captured on the owner's frames, arrive after the network delay and are handled on the receiver's next frame +SimulationResult Simulate(const NetworkConditions& acConditions) +{ + struct InFlightSnapshot + { + double ArrivalTime; + uint64_t CaptureTick; + }; + + std::mt19937 random(acConditions.Seed); + std::uniform_real_distribution jitter(0.0, acConditions.MaxJitter); + std::uniform_real_distribution frameJitter(-acConditions.OwnerFrameJitter, acConditions.OwnerFrameJitter); + + std::vector snapshots; + double lastCaptureTime = -acConditions.SnapshotInterval; + for (double captureTime = 0.0; captureTime < acConditions.Duration; + captureTime += acConditions.OwnerFrameTime + (acConditions.OwnerFrameJitter > 0.0 ? frameJitter(random) : 0.0)) + { + if (captureTime - lastCaptureTime < acConditions.SnapshotInterval) + continue; + + lastCaptureTime = captureTime; + + const double cArrivalTime = captureTime + acConditions.Latency + (acConditions.MaxJitter > 0.0 ? jitter(random) : 0.0); + snapshots.push_back({cArrivalTime, acConditions.OwnerClockOffset + static_cast(captureTime)}); + } + + std::sort(snapshots.begin(), snapshots.end(), [](const auto& acLhs, const auto& acRhs) { return acLhs.ArrivalTime < acRhs.ArrivalTime; }); + + PlaybackClock clock; + SimulationResult result; + size_t nextSnapshot = 0; + uint64_t newestCaptureTick = 0; + double totalDelay = 0.0; + + for (double now = 0.0; now < acConditions.Duration; now += cFrameTime) + { + if (now >= acConditions.HitchTime && now < acConditions.HitchTime + cFrameTime) + now += acConditions.HitchLength; + + while (nextSnapshot < snapshots.size() && snapshots[nextSnapshot].ArrivalTime <= now) + { + // Stamped with when the network received it, not when this frame got to it + clock.AddSample(snapshots[nextSnapshot].ArrivalTime, snapshots[nextSnapshot].CaptureTick); + newestCaptureTick = std::max(newestCaptureTick, snapshots[nextSnapshot].CaptureTick); + ++nextSnapshot; + } + + const double cPreviousRenderTick = clock.GetRenderTick(); + const double cDeltaTime = clock.Advance(now); + + if (!clock.IsRunning() || now < cWarmup) + continue; + + const double cRenderTick = clock.GetRenderTick(); + const double cAdvanced = cRenderTick - cPreviousRenderTick; + const double cMaxDeviation = cDeltaTime * PlaybackClock::cMaxRateAdjustment + 1e-6; + if (std::abs(cAdvanced - cDeltaTime) > cMaxDeviation) + result.IsAlwaysSmooth = false; + + const double cDelay = static_cast(acConditions.OwnerClockOffset) + now - cRenderTick; + result.MaxDelay = std::max(result.MaxDelay, cDelay); + totalDelay += cDelay; + + if (cRenderTick > static_cast(newestCaptureTick)) + ++result.StarvedFrames; + + ++result.Frames; + } + + result.MeanDelay = result.Frames ? totalDelay / static_cast(result.Frames) : 0.0; + return result; +} +} // namespace + +TEST_CASE("Playback clock", "[client.playback_clock]") +{ + SECTION("Stays well under the old fixed 300ms delay on a stable connection") + { + const auto result = Simulate({}); + + REQUIRE(result.Frames > 0); + REQUIRE(result.StarvedFrames == 0); + REQUIRE(result.IsAlwaysSmooth); + // 10ms latency + the gap between snapshots + safety margin + REQUIRE(result.MeanDelay > 10.0); + REQUIRE(result.MaxDelay < 90.0); + } + + SECTION("Buffers more on a jittery connection and rarely runs out of snapshots") + { + NetworkConditions conditions; + conditions.MaxJitter = 40.0; + conditions.Duration = 30000.0; + + const auto result = Simulate(conditions); + + REQUIRE(result.IsAlwaysSmooth); + REQUIRE(result.MeanDelay > Simulate({}).MeanDelay); + REQUIRE(result.MaxDelay < 160.0); + REQUIRE(static_cast(result.StarvedFrames) < 0.05 * static_cast(result.Frames)); + } + + SECTION("Plays low rate actors further behind instead of starving them") + { + NetworkConditions conditions; + conditions.SnapshotInterval = 100.0; + + const auto result = Simulate(conditions); + + REQUIRE(result.StarvedFrames == 0); + REQUIRE(result.IsAlwaysSmooth); + REQUIRE(result.MaxDelay < 160.0); + } + + SECTION("Keeps up with a 60 fps owner whose frame times vary") + { + NetworkConditions conditions; + conditions.OwnerFrameJitter = 2.0; + conditions.Duration = 30000.0; + + const auto result = Simulate(conditions); + + REQUIRE(result.IsAlwaysSmooth); + REQUIRE(static_cast(result.StarvedFrames) < 0.01 * static_cast(result.Frames)); + REQUIRE(result.MaxDelay < 90.0); + } + + SECTION("Is not thrown off by a long frame on our side") + { + NetworkConditions conditions; + conditions.HitchTime = 5000.0; + conditions.HitchLength = 800.0; + + const auto result = Simulate(conditions); + + REQUIRE(result.IsAlwaysSmooth); + REQUIRE(result.StarvedFrames == 0); + // About the same as without the hitch + REQUIRE(result.MaxDelay < Simulate({}).MaxDelay + 10.0); + } + + SECTION("A long frame does not carry a catch-up past where playback should be") + { + // Playback starts with a conservative delay and speeds up a little once the second snapshot shows it can do with less + PlaybackClock clock; + double now = 0.0; + uint64_t tick = 1000; + clock.AddSample(now, tick); + clock.Advance(now); + + now += 33.0; + tick += 33; + clock.AddSample(now, tick); + clock.Advance(now); + + REQUIRE(clock.GetPlaybackRate() > 1.0); + + // Snapshots keep arriving during one long frame of ours + while (now < 1000.0) + { + now += 33.0; + tick += 33; + clock.AddSample(now, tick); + } + clock.Advance(now); + + // Caught up exactly, rather than overshooting and having to slow down again + REQUIRE(clock.GetPlaybackRate() == Approx(1.0).margin(0.01)); + } + + SECTION("Does not depend on the owner's clock matching ours") + { + NetworkConditions nearClock; + NetworkConditions farClock; + farClock.OwnerClockOffset = 1'712'345'678'901; + + REQUIRE(Simulate(nearClock).MeanDelay == Approx(Simulate(farClock).MeanDelay).margin(0.01)); + } + + SECTION("Jumps instead of crawling when the owner's timeline jumps") + { + PlaybackClock clock; + double now = 0.0; + uint64_t tick = 1000; + for (; now < 1000.0; tick += 33, now += 33.0) + { + clock.AddSample(now, tick); + clock.Advance(now); + } + + // E.g. a new owner whose clock is far ahead of the previous one + tick += 10000; + clock.AddSample(now, tick); + + const double cRenderTick = clock.GetRenderTick(); + clock.Advance(now); + + REQUIRE(clock.GetPlaybackRate() == 1.0); + REQUIRE(clock.GetRenderTick() - cRenderTick > 9000.0); + REQUIRE(clock.GetRenderTick() < static_cast(tick)); + } + + SECTION("Ignores duplicate snapshots") + { + PlaybackClock clock; + REQUIRE(clock.AddSample(0.0, 1000)); + REQUIRE(clock.AddSample(33.0, 1033)); + REQUIRE_FALSE(clock.AddSample(40.0, 1033)); + + REQUIRE(clock.GetSnapshotInterval() == Approx(33.0)); + REQUIRE(clock.GetJitter() == Approx(0.0)); + } +} diff --git a/Code/tests/xmake.lua b/Code/tests/xmake.lua index 1ce224fa8..19abce574 100644 --- a/Code/tests/xmake.lua +++ b/Code/tests/xmake.lua @@ -6,6 +6,8 @@ target("TPTests") ".", "../encoding") add_headerfiles("**.h") add_files("*.cpp") + -- Has no game dependencies, so the client's remote playback timing can be tested here + add_files("../client/Systems/PlaybackClock.cpp") add_deps("SkyrimEncoding") add_packages( "tiltedcore", From 0a6784b855ebf87c424217124b29335229454308 Mon Sep 17 00:00:00 2001 From: Daniil Zakharov Date: Sat, 10 Oct 2026 15:58:08 +0300 Subject: [PATCH 2/4] Reset remote playback on ownership change and relay sooner Owners stamp snapshots with their synchronized clock, which was resynced using the time the sync message was processed rather than when it arrived. An owner stall during a sync left their timeline behind, which froze their actors on other clients for seconds --- Code/client/Services/Generic/CharacterService.cpp | 8 ++++++-- Code/server/Services/CharacterService.cpp | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Code/client/Services/Generic/CharacterService.cpp b/Code/client/Services/Generic/CharacterService.cpp index d22173d57..aeef57ace 100644 --- a/Code/client/Services/Generic/CharacterService.cpp +++ b/Code/client/Services/Generic/CharacterService.cpp @@ -837,8 +837,12 @@ void CharacterService::OnOwnershipTransfer(const NotifyOwnershipTransfer& acMess { m_world.emplace_or_replace(cEntity, acMessage.ServerId, pFormIdComponent->Id, acMessage.OwnershipEpoch); - if (!m_world.all_of(cEntity)) - InterpolationSystem::Setup(m_world, cEntity); + // The new owner carries on from its own, older view of the actor and stamps snapshots with its own clock, so the + // previous owner's snapshots and timing estimates would only drag the actor back. Start over from where it is now. + const auto* pOldInterpolationComponent = m_world.try_get(cEntity); + const glm::vec3 cPosition = pActor ? pActor->position : (pOldInterpolationComponent ? pOldInterpolationComponent->Position : glm::vec3{}); + InterpolationSystem::Setup(m_world, cEntity).Position = cPosition; + if (!m_world.all_of(cEntity)) AnimationSystem::Setup(m_world, cEntity); } diff --git a/Code/server/Services/CharacterService.cpp b/Code/server/Services/CharacterService.cpp index 94212bd5d..3bc7b80c4 100644 --- a/Code/server/Services/CharacterService.cpp +++ b/Code/server/Services/CharacterService.cpp @@ -414,9 +414,9 @@ void CharacterService::OnReferencesMoveRequest(const PacketEventSend(relay, TiltedPhoques::kUnreliable); + pPlayer->Send(relay, TiltedPhoques::kUnreliableNoNagle); } void CharacterService::OnActionEventsRequest(const PacketEvent& acMessage) const noexcept From b0e46d613705efbff461d017302ab44f9ede4c6a Mon Sep 17 00:00:00 2001 From: Daniil Zakharov Date: Sat, 10 Oct 2026 17:02:03 +0300 Subject: [PATCH 3/4] Bump TiltedConnect to include the Nagle change --- Libraries/TiltedConnect | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Libraries/TiltedConnect b/Libraries/TiltedConnect index 293fab992..e6f575936 160000 --- a/Libraries/TiltedConnect +++ b/Libraries/TiltedConnect @@ -1 +1 @@ -Subproject commit 293fab99288b7414bd129ace25ae018114cefb2a +Subproject commit e6f5759364a64462e717db1153781a7eae26a13e From a4a337449944ddf6b6601a6f7bfa68a44871b342 Mon Sep 17 00:00:00 2001 From: Daniil Zakharov Date: Sun, 11 Oct 2026 13:59:09 +0300 Subject: [PATCH 4/4] Stop remote NPCs stuttering for seconds after leaving combat When an NPC left combat its owner dropped from ~30 to ~10 snapshots per second, but the snapshot interval was averaged over a 3 s window, so playback kept running out of snapshots and extrapolating for 2-3 s. The interval now also takes the average of the newest 4 gaps when that is longer, which cuts the stutter to under a second with little change elsewhere. --- Code/client/Systems/PlaybackClock.cpp | 14 +++++++++- Code/client/Systems/PlaybackClock.h | 39 ++++++++++++++++++++------- Code/tests/playback_clock.cpp | 19 ++++++++++++- 3 files changed, 61 insertions(+), 11 deletions(-) diff --git a/Code/client/Systems/PlaybackClock.cpp b/Code/client/Systems/PlaybackClock.cpp index b7a59d15b..ab5edf3dc 100644 --- a/Code/client/Systems/PlaybackClock.cpp +++ b/Code/client/Systems/PlaybackClock.cpp @@ -2,6 +2,7 @@ #include #include +#include #include bool PlaybackClock::AddSample(const double aArrivalTime, const uint64_t aCaptureTick) noexcept @@ -40,6 +41,7 @@ double PlaybackClock::Advance(const double aNow) noexcept // The playback rate was picked to close the last error. A long frame must not carry the correction past it, or playback // would end up off by as much the other way, e.g. ahead of every snapshot that has arrived. + // Simply put: speed up or slow down only until the gap is closed, never past it. const double cCorrection = std::clamp(delta * (m_playbackRate - 1.0), std::min(m_error, 0.0), std::max(m_error, 0.0)); m_renderTick += delta + cCorrection; @@ -64,6 +66,7 @@ double PlaybackClock::Advance(const double aNow) noexcept void PlaybackClock::UpdateEstimates(const double aNewestArrivalTime) noexcept { std::array transits; + std::array captureTicks; size_t count = 0; uint64_t oldestCaptureTick = std::numeric_limits::max(); uint64_t newestCaptureTick = 0; @@ -74,6 +77,7 @@ void PlaybackClock::UpdateEstimates(const double aNewestArrivalTime) noexcept if (sample.ArrivalTime < aNewestArrivalTime - cSampleWindow) continue; + captureTicks[count] = sample.CaptureTick; // Includes the network delay and the unknown offset between both clocks, only differences between transits are meaningful transits[count++] = sample.ArrivalTime - static_cast(sample.CaptureTick); oldestCaptureTick = std::min(oldestCaptureTick, sample.CaptureTick); @@ -89,10 +93,18 @@ void PlaybackClock::UpdateEstimates(const double aNewestArrivalTime) noexcept std::nth_element(transits.begin(), percentile, end); m_jitter = *percentile - m_minTransit; - // An average is enough because owners send at an even pace, see RunLocalUpdates + // Owners send at an even pace while their send rate holds, see RunLocalUpdates if (count >= 2) m_snapshotInterval = static_cast(newestCaptureTick - oldestCaptureTick) / static_cast(count - 1); + // The average takes seconds to notice the owner sending less often, e.g. an NPC leaving combat + if (count > cRecentGaps) + { + const auto recentCaptureTick = captureTicks.begin() + cRecentGaps; + std::nth_element(captureTicks.begin(), recentCaptureTick, captureTicks.begin() + count, std::greater<>{}); + m_snapshotInterval = std::max(m_snapshotInterval, static_cast(newestCaptureTick - *recentCaptureTick) / static_cast(cRecentGaps)); + } + m_targetDelay = std::clamp(m_snapshotInterval + m_jitter + cSafetyMargin, cMinTargetDelay, cMaxTargetDelay); } diff --git a/Code/client/Systems/PlaybackClock.h b/Code/client/Systems/PlaybackClock.h index ba5f74ac3..10c695a38 100644 --- a/Code/client/Systems/PlaybackClock.h +++ b/Code/client/Systems/PlaybackClock.h @@ -7,9 +7,24 @@ /** * @brief Picks which owner tick of a remote actor to show, just far enough behind for the next snapshot to be there. * - * The delay comes from capture ticks vs. arrival times, so the clocks needn't be in sync, and changes are eased in by - * playing slightly faster or slower. Times are ms on one monotonic clock. Arrivals should be network receive times, so - * our own long frames don't look like jitter. + * What it looks like in game: + * - Remote actors are shown a little in the past, their ping plus about one snapshot interval. E.g. ~65 ms for a player + * at 20 ms ping, more for NPCs out of combat, which are sent less often. + * - Too little delay and playback runs out of snapshots: the actor keeps moving on its own for a moment and is pulled back + * when the late snapshot arrives, see InterpolationSystem. Too much delay and the actor lags, e.g. attacks show up late. + * - When the delay has to change, the actor moves slightly faster or slower for a moment instead of jumping. It only jumps + * when playback is far off, e.g. after the owner's game froze. + * The "Component debug" window shows these numbers live for the selected actor, the "Network" window can simulate a bad + * connection. + * + * How the delay is picked: + * renderTick = now - minTransit - targetDelay + * - Transit is a snapshot's arrival time minus its capture tick: the network delay plus the unknown offset between both + * clocks, so the clocks needn't be in sync. now - minTransit is the newest owner tick that could have arrived by now. + * - targetDelay = snapshot interval + jitter + safety margin, long enough for the next snapshot to arrive in most cases. + * + * Ticks are on the owner's clock, times on ours, both in ms. Arrival times should be network receive times, so our own + * long frames don't look like jitter. */ struct PlaybackClock { @@ -26,21 +41,27 @@ struct PlaybackClock [[nodiscard]] double GetSnapshotInterval() const noexcept { return m_snapshotInterval; } [[nodiscard]] double GetPlaybackRate() const noexcept { return m_playbackRate; } - // Estimates use the samples that arrived within this window + // Estimates only use snapshots that arrived within this window, so they follow changing network conditions static constexpr double cSampleWindow = 3000.0; + // Holds a whole window of snapshots at the ~30 per second players are sent at static constexpr size_t cMaxSamples = 128; - // Until two snapshots have arrived + // Assumed until two snapshots have arrived, as slow as NPCs out of combat are sent static constexpr double cDefaultSnapshotInterval = 100.0; - // Later arrivals are left to extrapolation + // How many of the newest gaps between snapshots are checked for the owner sending less often + static constexpr size_t cRecentGaps = 4; + // Playback waits for all but the latest 5% of snapshots, the actor briefly extrapolates over those instead static constexpr double cJitterPercentile = 0.95; + // Extra delay for small variations the estimates miss, like uneven frame times static constexpr double cSafetyMargin = 8.0; + // A floor for safety, the snapshot interval alone is already longer static constexpr double cMinTargetDelay = 16.0; + // On a very bad connection the actor extrapolates more rather than lagging further and further behind static constexpr double cMaxTargetDelay = 250.0; - // Most playback may speed up or slow down while catching up + // Playback runs at most 10% faster or slower while catching up, too little to notice static constexpr double cMaxRateAdjustment = 0.1; - // Error in ms that gets the full rate adjustment + // How far off in ms playback has to be for the full speed change, smaller errors get proportionally less static constexpr double cFullRateAdjustmentError = 50.0; - // Larger errors jump instead of catching up + // Further off than this, playback jumps instead, catching up at 10% would take seconds. Unrelated to cMaxTargetDelay. static constexpr double cSnapThreshold = 250.0; private: diff --git a/Code/tests/playback_clock.cpp b/Code/tests/playback_clock.cpp index fd1c47c77..f8f574543 100644 --- a/Code/tests/playback_clock.cpp +++ b/Code/tests/playback_clock.cpp @@ -17,6 +17,9 @@ struct NetworkConditions { // Like RunLocalUpdates, the owner sends on the first of its frames that is at least this long after its previous snapshot double SnapshotInterval = 30.0; + // From then on the owner sends less often, like when an NPC leaves combat + double SlowdownTime = 0.0; + double SlowSnapshotInterval = 0.0; double OwnerFrameTime = 1000.0 / 60.0; // Each owner frame is up to this much shorter or longer double OwnerFrameJitter = 0.0; @@ -62,7 +65,8 @@ SimulationResult Simulate(const NetworkConditions& acConditions) for (double captureTime = 0.0; captureTime < acConditions.Duration; captureTime += acConditions.OwnerFrameTime + (acConditions.OwnerFrameJitter > 0.0 ? frameJitter(random) : 0.0)) { - if (captureTime - lastCaptureTime < acConditions.SnapshotInterval) + const bool cIsSlow = acConditions.SlowSnapshotInterval > 0.0 && captureTime >= acConditions.SlowdownTime; + if (captureTime - lastCaptureTime < (cIsSlow ? acConditions.SlowSnapshotInterval : acConditions.SnapshotInterval)) continue; lastCaptureTime = captureTime; @@ -159,6 +163,19 @@ TEST_CASE("Playback clock", "[client.playback_clock]") REQUIRE(result.MaxDelay < 160.0); } + SECTION("Quickly plays further behind when the owner starts sending less often") + { + NetworkConditions conditions; + conditions.SlowdownTime = 5000.0; + conditions.SlowSnapshotInterval = 100.0; + + const auto result = Simulate(conditions); + + REQUIRE(result.IsAlwaysSmooth); + // Only the first longer gaps can't be foreseen, the window average alone would starve for seconds + REQUIRE(result.StarvedFrames < 25); + } + SECTION("Keeps up with a 60 fps owner whose frame times vary") { NetworkConditions conditions;