LYN-7655 Fix Race Condition When Launching at Editor-Server (#4946)

* Fix a race condition where the editor tries to connect to the editor-server before the editor-server is ready (originally discovered on lower-spec Jenkin machines).  Change editor-server so that editor waits to receive a EditorServerReadyForInit before trying to send all the level data.

* The editor might not be the connector so make sure to connect to the actual MP simulation even if the editor isn't the editor-server connect (if editorsv_launch=true then the editor-server will connect to the editor)

* Adding warnings if MPEditorConnection cannot find certain cvars

Signed-off-by: Gene Walters <genewalt@amazon.com>
This commit is contained in:
Gene Walters
2021-10-27 10:38:09 -07:00
committed by GitHub
parent c5c043ecc5
commit e6650f1ff4
7 changed files with 205 additions and 100 deletions
@@ -0,0 +1,27 @@
/*
* Copyright (c) Contributors to the Open 3D Engine Project.
* For complete copyright and license terms please see the LICENSE at the root of this distribution.
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*
*/
#pragma once
#include <AzCore/EBus/EBus.h>
namespace Multiplayer
{
class MultiplayerEditorServerRequests : public AZ::EBusTraits
{
public:
static const AZ::EBusHandlerPolicy HandlerPolicy = AZ::EBusHandlerPolicy::Single;
static const AZ::EBusAddressPolicy AddressPolicy = AZ::EBusAddressPolicy::Single;
//! Sends a packet that initializes a local server launched from the editor.
//! The editor will package the data required for loading the current editor level on the editor-server; data includes entities and asset data.
//! @param connection The connection to the editor-server
virtual void SendEditorServerLevelDataPacket(AzNetworking::IConnection* connection) = 0;
};
using MultiplayerEditorServerRequestBus = AZ::EBus<MultiplayerEditorServerRequests>;
} // namespace Multiplayer
@@ -4,13 +4,15 @@
<Include File="AzNetworking/AutoGen/CorePackets.AutoPackets.h" />
<Include File="Multiplayer/MultiplayerTypes.h" />
<Include File="Multiplayer/NetworkTime/INetworkTime.h" />
<Packet Name="EditorServerInit" Desc="A packet that initializes a local server launched from the editor">
<Packet Name="EditorServerReadyForLevelData" Desc="A packet the editor-server will send on startup once it's ready to receive all the current level data from the Editor."/>
<Packet Name="EditorServerLevelData" Desc="A packet that initializes the editor-server with level data from the editor. The packet includes data required for loading the current level on the server (entities and asset data).">
<Member Type="bool" Name="lastUpdate" Init="false"/>
<!--16379 is 16384 (max TCP packet size) - 1 byte (bool lastUpdate) - 4 bytes (serialization overhead for ByteBuffer) -->
<Member Type="AzNetworking::ByteBuffer&lt;16379&gt;" Name="assetData"/>
</Packet>
<Packet Name="EditorServerReady" Desc="A response packet the local server should send when ready for traffic"/>
<Packet Name="EditorServerReady" Desc="A response packet the editor-server should send after getting the editor's level data when it's ready to begin the actual game-mode network simulation."/>
</PacketGroup>
@@ -9,6 +9,7 @@
#include <Multiplayer/IMultiplayer.h>
#include <Multiplayer/INetworkSpawnableLibrary.h>
#include <Multiplayer/MultiplayerConstants.h>
#include <Multiplayer/MultiplayerEditorServerBus.h>
#include <Editor/MultiplayerEditorConnection.h>
#include <Source/AutoGen/AutoComponentTypes.h>
@@ -17,7 +18,6 @@
#include <AzCore/Utils/Utils.h>
#include <AzCore/Serialization/SerializeContext.h>
#include <AzCore/Serialization/Utils.h>
#include <AzFramework/Spawnable/Spawnable.h>
#include <AzNetworking/ConnectionLayer/IConnection.h>
#include <AzNetworking/Framework/INetworking.h>
@@ -35,13 +35,28 @@ namespace Multiplayer
m_networkEditorInterface->SetTimeoutMs(AZ::TimeMs{ 0 }); // Disable timeouts on this network interface
if (editorsv_isDedicated)
{
uint16_t editorServerPort = DefaultServerEditorPort;
if (auto console = AZ::Interface<AZ::IConsole>::Get(); console)
uint16_t editorsv_port = DefaultServerEditorPort;
const auto console = AZ::Interface<AZ::IConsole>::Get();
if (console->GetCvarValue("editorsv_port", editorsv_port) != AZ::GetValueResult::Success)
{
console->GetCvarValue("editorsv_port", editorServerPort);
AZ_Assert( false,
"MultiplayerEditorConnection failed! Could not find the editorsv_port cvar; we may not be able to connect to the editor's port! Please update this code to use a valid cvar!")
}
AZ_Assert(m_networkEditorInterface, "MP Editor Network Interface was unregistered before Editor Server could start listening.")
// Check if there's already an Editor out there waiting to connect
const ConnectionId editorServerToEditorConnectionId = m_networkEditorInterface->Connect(IpAddress(LocalHost.data(), editorsv_port, ProtocolType::Tcp));
// If there wasn't an Editor waiting for this server to start, then assume this is an editor-server launched by hand... listen and wait for the editor to request a connection
if (editorServerToEditorConnectionId == InvalidConnectionId)
{
m_networkEditorInterface->Listen(editorsv_port);
}
else
{
m_networkEditorInterface->SendReliablePacket(editorServerToEditorConnectionId, MultiplayerEditorPackets::EditorServerReadyForLevelData());
}
AZ_Assert(m_networkEditorInterface, "MP Editor Network Interface was unregistered before Editor Server could start listening.");
m_networkEditorInterface->Listen(editorServerPort);
}
}
@@ -49,7 +64,7 @@ namespace Multiplayer
(
[[maybe_unused]] AzNetworking::IConnection* connection,
[[maybe_unused]] const IPacketHeader& packetHeader,
[[maybe_unused]] MultiplayerEditorPackets::EditorServerInit& packet
[[maybe_unused]] MultiplayerEditorPackets::EditorServerLevelData& packet
)
{
// Editor Server Init is intended for non-release targets
@@ -76,7 +91,7 @@ namespace Multiplayer
AZ::Data::AssetData* assetDatum = AZ::Utils::LoadObjectFromStream<AZ::Data::AssetData>(m_byteStream, nullptr);
if (!assetDatum)
{
AZLOG_ERROR("EditorServerInit packet contains no asset data. Asset: %s", assetHint.c_str());
AZLOG_ERROR("EditorServerLevelData packet contains no asset data. Asset: %s", assetHint.c_str())
return false;
}
assetSize = m_byteStream.GetCurPos() - assetSize;
@@ -105,18 +120,21 @@ namespace Multiplayer
// Load the level via the root spawnable that was registered
const AZ::CVarFixedString loadLevelString = "LoadLevel Root.spawnable";
AZ::Interface<AZ::IConsole>::Get()->PerformCommand(loadLevelString.c_str());
const auto console = AZ::Interface<AZ::IConsole>::Get();
console->PerformCommand(loadLevelString.c_str());
// Setup the normal multiplayer connection
AZ::Interface<IMultiplayer>::Get()->InitializeMultiplayer(MultiplayerAgentType::DedicatedServer);
INetworkInterface* networkInterface = AZ::Interface<INetworking>::Get()->RetrieveNetworkInterface(AZ::Name(MpNetworkInterfaceName));
uint16_t serverPort = DefaultServerPort;
if (auto console = AZ::Interface<AZ::IConsole>::Get(); console)
uint16_t sv_port = DefaultServerPort;
if (console->GetCvarValue("sv_port", sv_port) != AZ::GetValueResult::Success)
{
console->GetCvarValue("sv_port", serverPort);
AZ_Assert(false,
"MultiplayerEditorConnection::HandleRequest for EditorServerLevelData failed! Could not find the sv_port cvar; we won't be able to listen on the correct port for incoming network messages! Please update this code to use a valid cvar!")
}
networkInterface->Listen(serverPort);
networkInterface->Listen(sv_port);
AZLOG_INFO("Editor Server completed asset receive, responding to Editor...");
return connection->SendReliablePacket(MultiplayerEditorPackets::EditorServerReady());
@@ -125,6 +143,15 @@ namespace Multiplayer
return true;
}
bool MultiplayerEditorConnection::HandleRequest(
[[maybe_unused]] AzNetworking::IConnection* connection,
[[maybe_unused]] const AzNetworking::IPacketHeader& packetHeader,
[[maybe_unused]] MultiplayerEditorPackets::EditorServerReadyForLevelData& packet)
{
MultiplayerEditorServerRequestBus::Broadcast(&MultiplayerEditorServerRequestBus::Events::SendEditorServerLevelDataPacket, connection);
return true;
}
bool MultiplayerEditorConnection::HandleRequest
(
[[maybe_unused]] AzNetworking::IConnection* connection,
@@ -132,23 +159,29 @@ namespace Multiplayer
[[maybe_unused]] MultiplayerEditorPackets::EditorServerReady& packet
)
{
if (connection->GetConnectionRole() == ConnectionRole::Connector)
{
// Receiving this packet means Editor sync is done, disconnect
connection->Disconnect(AzNetworking::DisconnectReason::TerminatedByClient, AzNetworking::TerminationEndpoint::Local);
// Receiving this packet means Editor sync is done, disconnect
connection->Disconnect(AzNetworking::DisconnectReason::TerminatedByClient, AzNetworking::TerminationEndpoint::Local);
const auto console = AZ::Interface<AZ::IConsole>::Get();
AZ::CVarFixedString editorsv_serveraddr = AZ::CVarFixedString(LocalHost);
uint16_t sv_port = DefaultServerEditorPort;
if (auto console = AZ::Interface<AZ::IConsole>::Get(); console)
{
AZ::CVarFixedString remoteAddress;
uint16_t remotePort;
if (console->GetCvarValue("editorsv_serveraddr", remoteAddress) != AZ::GetValueResult::ConsoleVarNotFound &&
console->GetCvarValue("sv_port", remotePort) != AZ::GetValueResult::ConsoleVarNotFound)
{
// Connect the Editor to the editor server for Multiplayer simulation
AZ::Interface<IMultiplayer>::Get()->Connect(remoteAddress.c_str(), remotePort);
}
}
if (console->GetCvarValue("sv_port", sv_port) != AZ::GetValueResult::Success)
{
AZ_Assert(false,
"MultiplayerEditorConnection::HandleRequest for EditorServerReady failed! Could not find the sv_port cvar; we may not be able to "
"connect to the correct port for incoming network messages! Please update this code to use a valid cvar!")
}
if (console->GetCvarValue("editorsv_serveraddr", editorsv_serveraddr) != AZ::GetValueResult::Success)
{
AZ_Assert(false,
"MultiplayerEditorConnection::HandleRequest for EditorServerReady failed! Could not find the editorsv_serveraddr cvar; we may not be able to "
"connect to the correct port for incoming network messages! Please update this code to use a valid cvar!")
}
// Connect the Editor to the editor server for Multiplayer simulation
AZ::Interface<IMultiplayer>::Get()->Connect(editorsv_serveraddr.c_str(), sv_port);
return true;
}
@@ -171,26 +204,5 @@ namespace Multiplayer
{
return MultiplayerEditorPackets::DispatchPacket(connection, packetHeader, serializer, *this);
}
void MultiplayerEditorConnection::OnPacketLost([[maybe_unused]] IConnection* connection, [[maybe_unused]] PacketId packetId)
{
;
}
void MultiplayerEditorConnection::OnDisconnect([[maybe_unused]] AzNetworking::IConnection* connection, [[maybe_unused]] DisconnectReason reason, [[maybe_unused]] TerminationEndpoint endpoint)
{
bool editorLaunch = false;
if (auto console = AZ::Interface<AZ::IConsole>::Get(); console)
{
console->GetCvarValue("editorsv_launch", editorLaunch);
}
if (editorsv_isDedicated && editorLaunch && m_networkEditorInterface->GetConnectionSet().GetConnectionCount() == 1)
{
if (m_networkEditorInterface->GetPort() != 0)
{
m_networkEditorInterface->StopListening();
}
}
}
}
@@ -10,8 +10,6 @@
#include <Source/AutoGen/MultiplayerEditor.AutoPacketDispatcher.h>
#include <AzCore/Component/Component.h>
#include <AzCore/Component/TickBus.h>
#include <AzCore/Console/IConsole.h>
#include <AzCore/Console/ILogger.h>
#include <AzCore/IO/ByteContainerStream.h>
@@ -33,7 +31,8 @@ namespace Multiplayer
MultiplayerEditorConnection();
~MultiplayerEditorConnection() = default;
bool HandleRequest(AzNetworking::IConnection* connection, const AzNetworking::IPacketHeader& packetHeader, MultiplayerEditorPackets::EditorServerInit& packet);
bool HandleRequest(AzNetworking::IConnection* connection, const AzNetworking::IPacketHeader& packetHeader, MultiplayerEditorPackets::EditorServerReadyForLevelData& packet);
bool HandleRequest(AzNetworking::IConnection* connection, const AzNetworking::IPacketHeader& packetHeader, MultiplayerEditorPackets::EditorServerLevelData& packet);
bool HandleRequest(AzNetworking::IConnection* connection, const AzNetworking::IPacketHeader& packetHeader, MultiplayerEditorPackets::EditorServerReady& packet);
//! IConnectionListener interface
@@ -41,8 +40,8 @@ namespace Multiplayer
AzNetworking::ConnectResult ValidateConnect(const AzNetworking::IpAddress& remoteAddress, const AzNetworking::IPacketHeader& packetHeader, AzNetworking::ISerializer& serializer) override;
void OnConnect(AzNetworking::IConnection* connection) override;
AzNetworking::PacketDispatchResult OnPacketReceived(AzNetworking::IConnection* connection, const AzNetworking::IPacketHeader& packetHeader, AzNetworking::ISerializer& serializer) override;
void OnPacketLost(AzNetworking::IConnection* connection, AzNetworking::PacketId packetId) override;
void OnDisconnect(AzNetworking::IConnection* connection, AzNetworking::DisconnectReason reason, AzNetworking::TerminationEndpoint endpoint) override;
void OnPacketLost([[maybe_unused]]AzNetworking::IConnection* connection, [[maybe_unused]]AzNetworking::PacketId packetId) override {}
void OnDisconnect([[maybe_unused]]AzNetworking::IConnection* connection, [[maybe_unused]]AzNetworking::DisconnectReason reason, [[maybe_unused]]AzNetworking::TerminationEndpoint endpoint) override {}
//! @}
private:
@@ -71,6 +71,7 @@ namespace Multiplayer
{
AzFramework::GameEntityContextEventBus::Handler::BusConnect();
AzToolsFramework::EditorEvents::Bus::Handler::BusConnect();
MultiplayerEditorServerRequestBus::Handler::BusConnect();
AZ::Interface<IMultiplayer>::Get()->AddServerAcceptanceReceivedHandler(m_serverAcceptanceReceivedHandler);
}
@@ -78,6 +79,7 @@ namespace Multiplayer
{
AzToolsFramework::EditorEvents::Bus::Handler::BusDisconnect();
AzFramework::GameEntityContextEventBus::Handler::BusDisconnect();
MultiplayerEditorServerRequestBus::Handler::BusDisconnect();
}
void MultiplayerEditorSystemComponent::NotifyRegisterViews()
@@ -107,8 +109,8 @@ namespace Multiplayer
m_serverProcess->TerminateProcess(0);
m_serverProcess = nullptr;
}
INetworkInterface* editorNetworkInterface = AZ::Interface<INetworking>::Get()->RetrieveNetworkInterface(AZ::Name(MpEditorInterfaceName));
if (editorNetworkInterface)
if (INetworkInterface* editorNetworkInterface = AZ::Interface<INetworking>::Get()->RetrieveNetworkInterface(AZ::Name(MpEditorInterfaceName)))
{
editorNetworkInterface->Disconnect(m_editorConnId, AzNetworking::DisconnectReason::TerminatedByClient);
}
@@ -191,53 +193,49 @@ namespace Multiplayer
}
const AZ::CVarFixedString remoteAddress = editorsv_serveraddr;
if (editorsv_launch && LocalHost == remoteAddress)
if (editorsv_launch)
{
if (LocalHost != remoteAddress)
{
AZ_Warning(
"MultiplayerEditor", false,
"Launching EditorServer skipped because incompatible cvars. editorsv_launch=true, meaning you want to launch an editor-server on this machine, but the editorsv_serveraddr is %s instead of the local address (127.0.0.1). "
"Please either set editorsv_launch=false and keep the remote editor-server, or set editorsv_launch=true and editorsv_serveraddr=127.0.0.1.",
remoteAddress.c_str())
return;
}
// Begin listening for MPEditor packets before we launch the editor-server.
// The editor-server will send us (the editor) an "EditorServerReadyForLevelData" packet to let us know it's ready to receive data.
INetworkInterface* editorNetworkInterface =
AZ::Interface<INetworking>::Get()->RetrieveNetworkInterface(AZ::Name(MpEditorInterfaceName));
AZ_Assert(editorNetworkInterface, "MP Editor Network Interface was unregistered before Editor could connect.");
editorNetworkInterface->Listen(editorsv_port);
// Launch the editor-server
m_serverProcess = LaunchEditorServer();
}
// Spawnable library needs to be rebuilt since now we have newly registered in-memory spawnable assets
AZ::Interface<INetworkSpawnableLibrary>::Get()->BuildSpawnablesList();
// Now that the server has launched, attempt to connect the NetworkInterface
INetworkInterface* editorNetworkInterface = AZ::Interface<INetworking>::Get()->RetrieveNetworkInterface(AZ::Name(MpEditorInterfaceName));
AZ_Assert(editorNetworkInterface, "MP Editor Network Interface was unregistered before Editor could connect.");
m_editorConnId = editorNetworkInterface->Connect(
AzNetworking::IpAddress(remoteAddress.c_str(), editorsv_port, AzNetworking::ProtocolType::Tcp));
if (m_editorConnId == AzNetworking::InvalidConnectionId)
else
{
AZ_Warning(
"MultiplayerEditor", false,
"Could not connect to server targeted by Editor. If using a local server, check that it's built and editorsv_launch is true.");
return;
}
// Editorsv_launch=false, so we're expecting an editor-server already exists.
// Connect to the editor-server and then send the EditorServerLevelData packet.
INetworkInterface* editorNetworkInterface = AZ::Interface<INetworking>::Get()->RetrieveNetworkInterface(AZ::Name(MpEditorInterfaceName));
AZ_Assert(editorNetworkInterface, "MP Editor Network Interface was unregistered before Editor could connect.")
m_editorConnId = editorNetworkInterface->Connect(AzNetworking::IpAddress(remoteAddress.c_str(), editorsv_port, AzNetworking::ProtocolType::Tcp));
// Read the buffer into EditorServerInit packets until we've flushed the whole thing
byteStream.Seek(0, AZ::IO::GenericStream::SeekMode::ST_SEEK_BEGIN);
while (byteStream.GetCurPos() < byteStream.GetLength())
{
MultiplayerEditorPackets::EditorServerInit packet;
auto& outBuffer = packet.ModifyAssetData();
// Size the packet's buffer appropriately
size_t readSize = outBuffer.GetCapacity();
size_t byteStreamSize = byteStream.GetLength() - byteStream.GetCurPos();
if (byteStreamSize < readSize)
if (m_editorConnId == AzNetworking::InvalidConnectionId)
{
readSize = byteStreamSize;
AZ_Warning(
"MultiplayerEditor", false,
"Editor multiplayer game-mode failed! Could not connect to an editor-server. editorsv_launch is false so we're assuming you're running your own editor-server at editorsv_serveraddr(%s) on editorsv_port(%i). "
"Either set editorsv_launch=true so the editor launches an editor-server for you, or launch your own editor-server by hand before entering game-mode. Remember editor-servers must use editorsv_isDedicated=true.",
remoteAddress.c_str(),
static_cast < uint16_t>(editorsv_port))
return;
}
outBuffer.Resize(readSize);
byteStream.Read(readSize, outBuffer.GetBuffer());
// If we've run out of buffer, mark that we're done
if (byteStream.GetCurPos() == byteStream.GetLength())
{
packet.SetLastUpdate(true);
}
editorNetworkInterface->SendReliablePacket(m_editorConnId, packet);
SendEditorServerLevelDataPacket(editorNetworkInterface->GetConnectionSet().GetConnection(m_editorConnId));
}
}
}
@@ -253,4 +251,64 @@ namespace Multiplayer
// but since we're in Editor, we're already in the level.
AZ::Interface<IMultiplayer>::Get()->SendReadyForEntityUpdates(true);
}
void MultiplayerEditorSystemComponent::SendEditorServerLevelDataPacket(AzNetworking::IConnection* connection)
{
const auto prefabEditorEntityOwnershipInterface = AZ::Interface<AzToolsFramework::PrefabEditorEntityOwnershipInterface>::Get();
if (!prefabEditorEntityOwnershipInterface)
{
AZ_Error("MultiplayerEditor", prefabEditorEntityOwnershipInterface != nullptr, "PrefabEditorEntityOwnershipInterface unavailable")
return;
}
const AZStd::vector<AZ::Data::Asset<AZ::Data::AssetData>>& assetData = prefabEditorEntityOwnershipInterface->GetPlayInEditorAssetData();
AZStd::vector<uint8_t> buffer;
AZ::IO::ByteContainerStream byteStream(&buffer);
// Serialize Asset information and AssetData into a potentially large buffer
for (const auto& asset : assetData)
{
AZ::Data::AssetId assetId = asset.GetId();
AZStd::string assetHint = asset.GetHint();
auto hintSize = aznumeric_cast<uint32_t>(assetHint.size());
byteStream.Write(sizeof(AZ::Data::AssetId), reinterpret_cast<void*>(&assetId));
byteStream.Write(sizeof(uint32_t), reinterpret_cast<void*>(&hintSize));
byteStream.Write(assetHint.size(), assetHint.data());
AZ::Utils::SaveObjectToStream(byteStream, AZ::DataStream::ST_BINARY, asset.GetData(), asset.GetData()->GetType());
}
// Spawnable library needs to be rebuilt since now we have newly registered in-memory spawnable assets
AZ::Interface<INetworkSpawnableLibrary>::Get()->BuildSpawnablesList();
// Read the buffer into EditorServerLevelData packets until we've flushed the whole thing
byteStream.Seek(0, AZ::IO::GenericStream::SeekMode::ST_SEEK_BEGIN);
while (byteStream.GetCurPos() < byteStream.GetLength())
{
MultiplayerEditorPackets::EditorServerLevelData editorServerLevelDataPacket;
auto& outBuffer = editorServerLevelDataPacket.ModifyAssetData();
// Size the packet's buffer appropriately
size_t readSize = outBuffer.GetCapacity();
const size_t byteStreamSize = byteStream.GetLength() - byteStream.GetCurPos();
if (byteStreamSize < readSize)
{
readSize = byteStreamSize;
}
outBuffer.Resize(readSize);
byteStream.Read(readSize, outBuffer.GetBuffer());
// If we've run out of buffer, mark that we're done
if (byteStream.GetCurPos() == byteStream.GetLength())
{
editorServerLevelDataPacket.SetLastUpdate(true);
}
connection->SendReliablePacket(editorServerLevelDataPacket);
}
}
}
@@ -9,7 +9,7 @@
#pragma once
#include <Multiplayer/IMultiplayer.h>
#include <Multiplayer/MultiplayerEditorServerBus.h>
#include <IEditor.h>
#include <Editor/MultiplayerEditorConnection.h>
@@ -35,6 +35,7 @@ namespace Multiplayer
, private AzFramework::GameEntityContextEventBus::Handler
, private AzToolsFramework::EditorEvents::Bus::Handler
, private IEditorNotifyListener
, private MultiplayerEditorServerRequestBus::Handler
{
public:
AZ_COMPONENT(MultiplayerEditorSystemComponent, "{9F335CC0-5574-4AD3-A2D8-2FAEF356946C}");
@@ -73,6 +74,11 @@ namespace Multiplayer
void OnGameEntitiesReset() override;
//! @}
//! MultiplayerEditorServerRequestBus::Handler
//! @{
void SendEditorServerLevelDataPacket(AzNetworking::IConnection* connection) override;
//! @}
IEditor* m_editor = nullptr;
AzFramework::ProcessWatcher* m_serverProcess = nullptr;
AzNetworking::ConnectionId m_editorConnId;
@@ -13,6 +13,7 @@ set(FILES
Include/Multiplayer/MultiplayerConstants.h
Include/Multiplayer/MultiplayerStats.h
Include/Multiplayer/MultiplayerTypes.h
Include/Multiplayer/MultiplayerEditorServerBus.h
Include/Multiplayer/Components/LocalPredictionPlayerInputComponent.h
Include/Multiplayer/Components/MultiplayerComponent.h
Include/Multiplayer/Components/MultiplayerComponentRegistry.h